From 0047fb0f557a00922c22be40cc979af3a35bef66 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 8 Jun 2026 22:32:20 +0530 Subject: [PATCH 1/4] Refactor auth and profile forms to use react-hook-form and zod for validation --- frontend/package.json | 5 +- frontend/src/components/ContactTab.tsx | 98 ++- frontend/src/components/EmailInputModal.tsx | 172 +++-- frontend/src/components/GeneralTab.tsx | 150 ++-- frontend/src/components/PasswordTab.tsx | 193 ++++-- frontend/src/components/ProfessionalTab.tsx | 123 +++- frontend/src/screens/ProfileEditScreen.tsx | 183 +---- frontend/src/screens/auth/LoginScreen.tsx | 285 ++++---- .../src/screens/auth/NewPasswordScreen.tsx | 368 +++++----- .../src/screens/auth/SignUpScreenFirst.tsx | 330 +++++---- .../src/screens/auth/SignUpScreenSecond.tsx | 163 +++-- frontend/yarn.lock | 650 +++++++++--------- 12 files changed, 1448 insertions(+), 1272 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 9d6c3098..fc628baa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "@expo/ngrok": "^4.1.3", "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "^5.2.6", + "@hookform/resolvers": "^5.4.0", "@lottiefiles/dotlottie-react": "^0.13.5", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/netinfo": "11.4.1", @@ -70,6 +71,7 @@ "qrcode-terminal": "^0.12.0", "react": "19.1.0", "react-dom": "19.1.0", + "react-hook-form": "^7.78.0", "react-native": "0.81.5", "react-native-chart-kit": "^6.12.0", "react-native-collapsible-tab-view": "^8.0.1", @@ -99,7 +101,8 @@ "react-native-worklets": "0.5.1", "react-redux": "^9.2.0", "socket.io-client": "^4.8.1", - "tamagui": "^1.135.6" + "tamagui": "^1.135.6", + "zod": "^4.4.3" }, "devDependencies": { "@testing-library/jest-native": "^5.4.3", diff --git a/frontend/src/components/ContactTab.tsx b/frontend/src/components/ContactTab.tsx index 8abb2289..6e57c2e5 100644 --- a/frontend/src/components/ContactTab.tsx +++ b/frontend/src/components/ContactTab.tsx @@ -5,17 +5,42 @@ import { TouchableOpacity, View, } from 'react-native'; -import React from 'react'; +import React, {useEffect} from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; import {PRIMARY_COLOR} from '../helper/Theme'; -import {ProfileEditContactTab} from '../type'; + +const contactSchema = z.object({ + phone_number: z.string().min(10, 'Please enter a valid phone number'), + contact_email: z.string().email('Please enter a valid email'), +}); +export type ContactFormData = z.infer; + +export interface ProfileEditContactTab { + user: any; + handleSubmitContactDetails: (data: ContactFormData) => void; +} const ContactTab = ({ - phone_number, - contact_email, - setContactNumber, - setContactEmail, + user, handleSubmitContactDetails, }: ProfileEditContactTab) => { + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(contactSchema), + mode: 'onChange', + defaultValues: { + phone_number: user?.contact_detail?.phone_no || '', + contact_email: user?.contact_detail?.email_id || '', + } + }); + + useEffect(() => { + reset({ + phone_number: user?.contact_detail?.phone_no || '', + contact_email: user?.contact_detail?.email_id || '', + }); + }, [user, reset]); return ( {/* Content Container */} @@ -23,35 +48,55 @@ const ContactTab = ({ {/* Phone Number Input */} Phone Number - setContactNumber(text)} + ( + <> + + {error && {error.message}} + + )} /> {/* Contact Email Input */} Contact Email - setContactEmail(text)} + ( + <> + + {error && {error.message}} + + )} /> {/* Save Button */} - + Save @@ -109,4 +154,9 @@ const styles = StyleSheet.create({ fontWeight: 'bold', color: 'white', }, + errorText: { + color: 'red', + fontSize: 12, + marginTop: 4, + }, }); diff --git a/frontend/src/components/EmailInputModal.tsx b/frontend/src/components/EmailInputModal.tsx index bed346d9..19845073 100644 --- a/frontend/src/components/EmailInputModal.tsx +++ b/frontend/src/components/EmailInputModal.tsx @@ -2,10 +2,18 @@ import React, { useEffect, useState } from 'react'; import { YStack, Button, Input, Spacer, Text, XStack, Card, Circle, Paragraph, ScrollView as TamaguiScrollView } from 'tamagui'; import { Sheet } from '@tamagui/sheet'; import { KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; import { EmailInputModalProp } from '../type'; import Icon from '@expo/vector-icons/MaterialCommunityIcons'; import Feather from '@expo/vector-icons/Feather'; +const emailInputSchema = z.object({ + email: z.string().min(1, "Email is required").email("Please enter a valid email address"), +}); +type EmailInputFormData = z.infer; + export default function EmailInputBottomSheet({ visible, callback, @@ -13,23 +21,32 @@ export default function EmailInputBottomSheet({ onDismiss, isRequestVerification, }: EmailInputModalProp) { - const [email, setEmail] = useState(''); - const [isValid, setIsValid] = useState(true); const [open, setOpen] = useState(visible); const [isFocused, setIsFocused] = useState(false); + const { + control, + handleSubmit, + reset, + watch, + formState: { isValid, errors }, + } = useForm({ + resolver: zodResolver(emailInputSchema), + mode: 'onChange', + defaultValues: { + email: '', + }, + }); + + const emailValue = watch('email'); + useEffect(() => { setOpen(visible); }, [visible]); - const verifyEmail = () => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const valid = emailRegex.test(email); - setIsValid(valid); - if (valid) { - callback(email); - setEmail(''); - } + const verifyEmail = (data: EmailInputFormData) => { + callback(data.email); + reset(); }; const handleOpenChange = (isOpen: boolean) => { @@ -40,8 +57,7 @@ export default function EmailInputBottomSheet({ }; const handleBackClick = () => { - setIsValid(true); - setEmail(''); + reset(); setIsFocused(false); backButtonClick(); setOpen(false); @@ -100,13 +116,13 @@ export default function EmailInputBottomSheet({ {/* Title */} - {isValid + {!errors.email ? isRequestVerification ? 'Email Verification' : 'Forgot Password?' @@ -122,7 +138,7 @@ export default function EmailInputBottomSheet({ lineHeight={22} marginBottom="$2" > - {isValid + {!errors.email ? isRequestVerification ? 'Please enter your registered email to receive the verification link.' : 'Enter your email address and we\'ll send you a code to reset your password.' @@ -134,62 +150,70 @@ export default function EmailInputBottomSheet({ Email Address - - - { - setEmail(text); - setIsValid(true); - }} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - keyboardType="email-address" - autoCapitalize="none" - autoCorrect={false} - borderWidth={2} - borderColor={ - !isValid - ? '$red8' - : isFocused - ? '$blue8' - : '$gray6' - } - backgroundColor={!isValid ? '$red1' : '$gray1'} - focusStyle={{ - borderColor: isValid ? '$blue9' : '$red8', - backgroundColor: 'white', - }} - pointerEvents="auto" - /> - - {!isValid && ( - - - - Please enter a valid email address - - - )} + ( + <> + + + setIsFocused(true)} + onBlur={() => { + setIsFocused(false); + onBlur(); + }} + keyboardType="email-address" + autoCapitalize="none" + autoCorrect={false} + borderWidth={2} + borderColor={ + error + ? '$red8' + : isFocused + ? '$blue8' + : '$gray6' + } + backgroundColor={error ? '$red1' : '$gray1'} + focusStyle={{ + borderColor: !error ? '$blue9' : '$red8', + backgroundColor: 'white', + }} + pointerEvents="auto" + /> + + {error && ( + + + + {error.message} + + + )} + + )} + /> @@ -199,13 +223,13 @@ export default function EmailInputBottomSheet({ - + ( + + {error && ( + + {error.message} + + )} + + + + + + + )} + /> { borderRadius="$4" fontWeight="700" alignSelf="center" - onPress={() => { - if (__DEV__) { - console.log('Login button pressed!'); - } - validateAndSubmit(); - }} - disabled={loginPending} - opacity={loginPending ? 0.5 : 1} + onPress={handleSubmit(onSubmit)} + disabled={loginPending || !isValid} + opacity={loginPending || !isValid ? 0.5 : 1} width="100%"> Login @@ -517,8 +502,8 @@ const LoginScreen = ({navigation, route}: LoginScreenProp) => { onSuccess: () => { /** Check Status */ Alert.alert('Verification Email Sent'); - setEmail(''); - setPassword(''); + setValue('password', ''); + setValue('email', ''); }, onError: (error: AxiosError) => { if (__DEV__) { diff --git a/frontend/src/screens/auth/NewPasswordScreen.tsx b/frontend/src/screens/auth/NewPasswordScreen.tsx index a60ac509..9d515e8b 100644 --- a/frontend/src/screens/auth/NewPasswordScreen.tsx +++ b/frontend/src/screens/auth/NewPasswordScreen.tsx @@ -12,6 +12,9 @@ import { XStack, YStack } from 'tamagui'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; import { ON_PRIMARY_COLOR, PRIMARY_COLOR } from '@/src/helper/Theme'; import { useChangePasswordMutation } from '@/src/hooks/useChangePassword'; @@ -22,16 +25,40 @@ import { AxiosError } from 'axios'; import Loader from '../../components/Loader'; import { NewPasswordScreenProp } from '../../type'; +const newPasswordSchema = z.object({ + password: z.string() + .min(6, 'At least 6 characters with lowercase letter') + .regex(/(?=.*[a-z]).{6,}/, 'At least 6 characters with lowercase letter'), + confirmPassword: z.string().min(1, 'Please confirm your password'), +}).refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], +}); +type NewPasswordFormData = z.infer; + export default function NewPasswordScreen({ navigation, route, }: NewPasswordScreenProp) { const {email} = route.params; - const [password, setPassword] = useState(''); - // removed isDarkMode variable - const [confirmPassword, setConfirmPassword] = useState(''); - const [passwordVerify, setPasswordVerify] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); + + const { + control, + handleSubmit, + watch, + formState: { isValid, errors }, + } = useForm({ + resolver: zodResolver(newPasswordSchema), + mode: 'onChange', + defaultValues: { + password: '', + confirmPassword: '', + }, + }); + + const password = watch('password'); + const confirmPassword = watch('confirmPassword'); + const passwordVerify = !errors.password && password.length >= 6; const [secureTextEntry, setSecureTextEntry] = useState(true); const [secureNewTextEntry, setSecureNewTextEntry] = useState(true); @@ -47,29 +74,7 @@ export default function NewPasswordScreen({ setSecureNewTextEntry(!secureNewTextEntry); }; - const handlePasswordSubmit = () => { - const showError = (msg: string) => { - Alert.alert(msg); - setErrorMessage(msg); - }; - - if (!password?.trim()) { - return showError('Please give a password'); - } - - if (!passwordVerify) { - return showError('Please enter a valid password'); - } - - if (!confirmPassword?.trim()) { - return showError('Please confirm your password'); - } - - if (password !== confirmPassword) { - return showError('Confirmation password does not match the new password'); - } - - setErrorMessage(null); + const handlePasswordSubmit = (data: NewPasswordFormData) => { changePassword( { @@ -107,28 +112,7 @@ export default function NewPasswordScreen({ ); }; - const handlePassword = (e: string) => { - let pass = e; - setErrorMessage(null); - setPassword(pass); - setPasswordVerify(false); - - if (/(?=.*[a-z]).{6,}/.test(pass)) { - setPassword(pass); - setPasswordVerify(true); - } - }; - - const handleConfirmPassword = (e: string) => { - let pass = e; - setErrorMessage(null); - setConfirmPassword(pass); - setPasswordVerify(false); - - if (/(?=.*[a-z]).{6,}/.test(pass)) { - setConfirmPassword(pass); - setPasswordVerify(true); - } + ); }; const insets = useSafeAreaInsets(); @@ -203,57 +187,71 @@ export default function NewPasswordScreen({ New Password - - - - + + )} + /> - ) : password ? ( + ) : errors.password ? ( <> - - At least 6 characters with lowercase letter + + {errors.password.message} ) : ( @@ -295,64 +293,78 @@ export default function NewPasswordScreen({ Confirm Password - - - - + + )} + /> - Passwords don't match + {errors.confirmPassword?.message || "Passwords don't match"} )} @@ -388,32 +400,11 @@ export default function NewPasswordScreen({ - {/* Error Message */} - {errorMessage && ( - - ⚠️ - - {errorMessage} - - - )} - {/* Confirm Button */} - + ( + + + + + + {error && {error.message}} + + )} + /> {/* Role Dropdown */} - setIsFocus(true)} - onBlur={() => setIsFocus(false)} - onChange={item => { - setRole(item.value); - setIsFocus(false); - }} + ( + + setIsFocus(true)} + onBlur={() => setIsFocus(false)} + onChange={item => { + onChange(item.value); + setIsFocus(false); + }} + /> + {error && {error.message}} + + )} /> {/* Submit Button */} @@ -497,9 +559,11 @@ const SignupPageFirst = ({navigation}: SignUpScreenFirstProp) => { alignItems="center" alignSelf="center" width="100%" - onPress={handleSubmit}> + onPress={handleSubmit(onSubmit)} + disabled={!isValid || registerPending} + opacity={!isValid || registerPending ? 0.5 : 1}> - {role === 'general' ? 'Register' : 'Continue'} + {watch('role') === 'general' ? 'Register' : 'Continue'} diff --git a/frontend/src/screens/auth/SignUpScreenSecond.tsx b/frontend/src/screens/auth/SignUpScreenSecond.tsx index ecfd354c..44d7d50d 100644 --- a/frontend/src/screens/auth/SignUpScreenSecond.tsx +++ b/frontend/src/screens/auth/SignUpScreenSecond.tsx @@ -3,6 +3,9 @@ import {Alert} from 'react-native'; import {ScrollView, YStack, XStack, Text, Input, Button, Image, useTheme} from 'tamagui'; import Icon from '@expo/vector-icons/MaterialIcons'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; import {Contactdetail, SignUpScreenSecondProp} from '../../type'; import {AxiosError} from 'axios'; import EmailVerifiedModal from '../../components/VerifiedModal'; @@ -14,20 +17,42 @@ import {SafeAreaView} from 'react-native-safe-area-context'; import {useVerificationMailMutation} from '@/src/hooks/useMailVerification'; import {useRegdMutation} from '@/src/hooks/useUserRegistration'; let validator = require('email-validator'); +const signupSecondSchema = z.object({ + specialization: z.string().min(1, 'Specialization is required'), + education: z.string().min(1, 'Educational Qualification is required'), + experience: z.string().min(1, 'Years of Experience is required'), + businessEmail: z.string().email('Please enter a valid email'), + phone: z.string().min(10, 'Phone number must be at least 10 characters'), +}); +type SignupSecondFormData = z.infer; const SignupPageSecond = ({navigation, route}: SignUpScreenSecondProp) => { const {user} = route.params; const {uploadImage, loading} = useUploadImage(); - const [specialization, setSpecialization] = useState(''); - const [education, setEducation] = useState(''); - const [experience, setExperience] = useState(''); - const [businessEmail, setBusinessEmail] = useState(''); - const [phone, setPhone] = useState(''); const [token, setToken] = useState(''); const [verifyBtntext, setVerifyBtntxt] = useState('Request Verification'); const [verifiedModalVisible, setVerifiedModalVisible] = useState(false); const [securityWarningVisible, setSecurityWarningVisible] = useState(false); - const [pendingContactDetail, setPendingContactDetail] = useState(null); + const [pendingSubmitData, setPendingSubmitData] = useState<{ + contactDetail: Contactdetail; + data: SignupSecondFormData; + } | null>(null); + + const { + control, + handleSubmit, + formState: { isValid }, + } = useForm({ + resolver: zodResolver(signupSecondSchema), + mode: 'onChange', + defaultValues: { + specialization: '', + education: '', + experience: '', + businessEmail: '', + phone: '', + }, + }); const {mutate: verifyEmailMutation, isPending: verifyMutationPending} = useVerificationMailMutation(); const {mutate: register, isPending: registerPending} = useRegdMutation(); @@ -35,6 +60,7 @@ const SignupPageSecond = ({navigation, route}: SignUpScreenSecondProp) => { const callRegisterAPI = ( profile_url: string, contactDetail: Contactdetail, + data: SignupSecondFormData, ) => { register( { @@ -43,9 +69,9 @@ const SignupPageSecond = ({navigation, route}: SignUpScreenSecondProp) => { email: user.email, password: user.password, isDoctor: true, - specialization: specialization, - qualification: education, - Years_of_experience: experience, + specialization: data.specialization, + qualification: data.education, + Years_of_experience: data.experience, Profile_image: profile_url, contact_detail: contactDetail, }, @@ -158,51 +184,34 @@ const SignupPageSecond = ({navigation, route}: SignUpScreenSecondProp) => { } }; - const handleSubmit = () => { - if ( - !specialization || - !education || - !experience || - !businessEmail || - !phone - ) { - Alert.alert('Please fill in all fields'); - return; - } else if (validator.validate(businessEmail) === false) { - Alert.alert('Please enter a valid mail id'); - return; - } else if (phone.length < 10) { - Alert.alert('Please enter a valid phone number'); - return; - } else { - let contactDetail: Contactdetail = { - email_id: - businessEmail && businessEmail !== '' ? businessEmail : user.email, - phone_no: phone, - }; + const onSubmit = (data: SignupSecondFormData) => { + let contactDetail: Contactdetail = { + email_id: + data.businessEmail && data.businessEmail !== '' ? data.businessEmail : user.email, + phone_no: data.phone, + }; - // Show security warning before proceeding with registration - setPendingContactDetail(contactDetail); - setSecurityWarningVisible(true); - } + // Show security warning before proceeding with registration + setPendingSubmitData({ contactDetail, data }); + setSecurityWarningVisible(true); }; const handleSecurityWarningContinue = () => { setSecurityWarningVisible(false); - if (pendingContactDetail) { - registerDoctor(pendingContactDetail); - setPendingContactDetail(null); + if (pendingSubmitData) { + registerDoctor(pendingSubmitData.contactDetail, pendingSubmitData.data); + setPendingSubmitData(null); } }; const handleSecurityWarningCancel = () => { setSecurityWarningVisible(false); - setPendingContactDetail(null); + setPendingSubmitData(null); }; - const registerDoctor = async (contactDetail: Contactdetail) => { + const registerDoctor = async (contactDetail: Contactdetail, data: SignupSecondFormData) => { if (!user.profile_image || user.profile_image === '') { - callRegisterAPI('', contactDetail); + callRegisterAPI('', contactDetail, data); } else { Alert.alert( '', @@ -217,7 +226,7 @@ const SignupPageSecond = ({navigation, route}: SignUpScreenSecondProp) => { text: 'Your profile image will not be uploaded.', duration: Snackbar.LENGTH_SHORT, }); - callRegisterAPI('', contactDetail); + callRegisterAPI('', contactDetail, data); }, style: 'cancel', }, @@ -231,7 +240,7 @@ const SignupPageSecond = ({navigation, route}: SignUpScreenSecondProp) => { result = await uploadImage(user.profile_image); } - callRegisterAPI(result ?? "", contactDetail); + callRegisterAPI(result ?? "", contactDetail, data); } catch (err) { console.error('Registration failed', err); @@ -319,59 +328,65 @@ const SignupPageSecond = ({navigation, route}: SignUpScreenSecondProp) => { {[ { placeholder: 'What is your Specialization?', - value: specialization, - onChangeText: setSpecialization, + name: 'specialization', icon: 'business', }, { placeholder: 'Educational Qualification', - value: education, - onChangeText: setEducation, + name: 'education', icon: 'school', }, { placeholder: 'Years of Experience', - value: experience, - onChangeText: setExperience, + name: 'experience', icon: 'numbers', keyboardType: 'numeric', maxLength: 3, }, { placeholder: 'Professional Email', - value: businessEmail, - onChangeText: setBusinessEmail, + name: 'businessEmail', icon: 'email', keyboardType: 'email-address', }, { placeholder: 'Phone number with country code', - value: phone, - onChangeText: setPhone, + name: 'phone', icon: 'phone', keyboardType: 'phone-pad', maxLength: 14, }, ].map((field, index) => ( - - - - - - + ( + + + + + + + + {error && {error.message}} + + )} + /> ))} {/* Submit Button */} @@ -382,7 +397,9 @@ const SignupPageSecond = ({navigation, route}: SignUpScreenSecondProp) => { width="100%" size="$6" marginTop="$2" - onPress={handleSubmit}> + onPress={handleSubmit(onSubmit)} + disabled={!isValid || registerPending} + opacity={!isValid || registerPending ? 0.5 : 1}> Register diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 31df16da..47fa0caf 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -28,7 +28,7 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629" integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== -"@babel/core@^7.0.0", "@babel/core@^7.0.0 || ^8.0.0-0", "@babel/core@^7.0.0-0", "@babel/core@^7.11.6", "@babel/core@^7.12.0", "@babel/core@^7.12.3", "@babel/core@^7.20.0", "@babel/core@^7.21.3", "@babel/core@^7.23.9", "@babel/core@^7.25.2", "@babel/core@^7.4.0 || ^8.0.0-0 <8.0.0", "@babel/core@^7.8.0": +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.20.0", "@babel/core@^7.21.3", "@babel/core@^7.23.9", "@babel/core@^7.25.2": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7" integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== @@ -730,7 +730,7 @@ "@babel/plugin-transform-modules-commonjs" "^7.29.7" "@babel/plugin-transform-typescript" "^7.29.7" -"@babel/runtime@*", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.0", "@babel/runtime@^7.25.0": +"@babel/runtime@^7.18.6", "@babel/runtime@^7.20.0", "@babel/runtime@^7.25.0": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768" integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw== @@ -1082,7 +1082,7 @@ parse-png "^2.1.0" semver "^7.6.0" -"@expo/json-file@^10.0.16", "@expo/json-file@~10.0.16": +"@expo/json-file@^10.0.16", "@expo/json-file@~10.0.16", "@expo/json-file@~10.0.8": version "10.0.16" resolved "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz" integrity sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw== @@ -1098,15 +1098,7 @@ "@babel/code-frame" "^7.20.0" json5 "^2.2.3" -"@expo/json-file@~10.0.8": - version "10.0.16" - resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-10.0.16.tgz#5c715dd360178f64ed3a23a0d7ea2d95ac9c09cd" - integrity sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw== - dependencies: - "@babel/code-frame" "~7.10.4" - json5 "^2.2.3" - -"@expo/metro-config@~54.0.16", "@expo/metro-config@54.0.16": +"@expo/metro-config@54.0.16", "@expo/metro-config@~54.0.16": version "54.0.16" resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-54.0.16.tgz#aa284cce981375df52ce5a947554b82e50995398" integrity sha512-3LLb9ZQl0VlqSlsalJ7+CYjfz60PBoSDHvpE1UF71aTM1Nx0Vb4LhXo7bCCC+PYP9q/GPB58LLbIROQ8PjKX2w== @@ -1153,6 +1145,56 @@ metro-transform-plugins "0.83.3" metro-transform-worker "0.83.3" +"@expo/ngrok-bin-darwin-arm64@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-darwin-arm64/-/ngrok-bin-darwin-arm64-2.3.41.tgz#d6b4a15be00f1166dc1d8dc99d0c773dc5e72048" + integrity sha512-TPf95xp6SkvbRONZjltTOFcCJbmzAH7lrQ36Dv+djrOckWGPVq4HCur48YAeiGDqspmFEmqZ7ykD5c/bDfRFOA== + +"@expo/ngrok-bin-darwin-x64@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-darwin-x64/-/ngrok-bin-darwin-x64-2.3.41.tgz#ff006f92c01887070d2302c67455c53d43ccb9f1" + integrity sha512-29QZHfX4Ec0p0pQF5UrqiP2/Qe7t2rI96o+5b8045VCEl9AEAKHceGuyo+jfUDR4FSQBGFLSDb06xy8ghL3ZYA== + +"@expo/ngrok-bin-freebsd-ia32@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-freebsd-ia32/-/ngrok-bin-freebsd-ia32-2.3.41.tgz#c488ce27823c199e2876eb80c5049856a0541b1d" + integrity sha512-YYXgwNZ+p0aIrwgb+1/RxJbsWhGEzBDBhZulKg1VB7tKDAd2C8uGnbK1rOCuZy013iOUsJDXaj9U5QKc13iIXw== + +"@expo/ngrok-bin-freebsd-x64@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-freebsd-x64/-/ngrok-bin-freebsd-x64-2.3.41.tgz#9e17cf0b4cd76dd1050db40463dfc9fc9b43938c" + integrity sha512-1Ei6K8BB+3etmmBT0tXYC4dyVkJMigT4ELbRTF5jKfw1pblqeXM9Qpf3p8851PTlH142S3bockCeO39rSkOnkg== + +"@expo/ngrok-bin-linux-arm64@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-linux-arm64/-/ngrok-bin-linux-arm64-2.3.41.tgz#dc7c761b7f12d8baae997565c360939b34da78f0" + integrity sha512-eC8GA/xPcmQJy4h+g2FlkuQB3lf5DjITy8Y6GyydmPYMByjUYAGEXe0brOcP893aalAzRqbNOAjSuAw1lcCLSQ== + +"@expo/ngrok-bin-linux-arm@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-linux-arm/-/ngrok-bin-linux-arm-2.3.41.tgz#c914a3e86d166e0595d13b3e501535bf441834ca" + integrity sha512-B6+rW/+tEi7ZrKWQGkRzlwmKo7c1WJhNODFBSgkF/Sj9PmmNhBz67mer91S2+6nNt5pfcwLLd61CjtWfR1LUHQ== + +"@expo/ngrok-bin-linux-ia32@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-linux-ia32/-/ngrok-bin-linux-ia32-2.3.41.tgz#5079fc5aa2f886d6e33a25e913ec240be57eac73" + integrity sha512-w5Cy31wSz4jYnygEHS7eRizR1yt8s9TX6kHlkjzayIiRTFRb2E1qD2l0/4T2w0LJpBjM5ZFPaaKqsNWgCUIEow== + +"@expo/ngrok-bin-linux-x64@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-linux-x64/-/ngrok-bin-linux-x64-2.3.41.tgz#34c117c14b3d82f6d51cd25df025d9679d866577" + integrity sha512-LcU3MbYHv7Sn2eFz8Yzo2rXduufOvX1/hILSirwCkH+9G8PYzpwp2TeGqVWuO+EmvtBe6NEYwgdQjJjN6I4L1A== + +"@expo/ngrok-bin-sunos-x64@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-sunos-x64/-/ngrok-bin-sunos-x64-2.3.41.tgz#8ab01f96344d5c2aae2944009fbea67aed74c022" + integrity sha512-bcOj45BLhiV2PayNmLmEVZlFMhEiiGpOr36BXC0XSL+cHUZHd6uNaS28AaZdz95lrRzGpeb0hAF8cuJjo6nq4g== + +"@expo/ngrok-bin-win32-ia32@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-win32-ia32/-/ngrok-bin-win32-ia32-2.3.41.tgz#b5fbcd49f2ae38fdb4105f8248159dc75c9ba9e3" + integrity sha512-0+vPbKvUA+a9ERgiAknmZCiWA3AnM5c6beI+51LqmjKEM4iAAlDmfXNJ89aAbvZMUtBNwEPHzJHnaM4s2SeBhA== + "@expo/ngrok-bin-win32-x64@2.3.41": version "2.3.41" resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-win32-x64/-/ngrok-bin-win32-x64-2.3.41.tgz#91c56b695c9c8d81424a335795e204d7bd011fcf" @@ -1362,7 +1404,7 @@ "@firebase/util" "1.14.0" tslib "^2.1.0" -"@firebase/app-compat@0.5.9", "@firebase/app-compat@0.x": +"@firebase/app-compat@0.5.9": version "0.5.9" resolved "https://registry.yarnpkg.com/@firebase/app-compat/-/app-compat-0.5.9.tgz#464efce323951283c6812893d251dddee15d61da" integrity sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug== @@ -1373,12 +1415,12 @@ "@firebase/util" "1.14.0" tslib "^2.1.0" -"@firebase/app-types@0.9.3", "@firebase/app-types@0.x": +"@firebase/app-types@0.9.3": version "0.9.3" resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.9.3.tgz#8408219eae9b1fb74f86c24e7150a148460414ad" integrity sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw== -"@firebase/app@0.14.9", "@firebase/app@0.x": +"@firebase/app@0.14.9": version "0.14.9" resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.14.9.tgz#b7f740904deee2889a3d6115736b16fdbdc853c7" integrity sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA== @@ -1671,7 +1713,7 @@ "@firebase/util" "1.14.0" tslib "^2.1.0" -"@firebase/util@1.14.0", "@firebase/util@1.x": +"@firebase/util@1.14.0": version "1.14.0" resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.14.0.tgz#e0a5998fc30a065fe5cba8bd7546ae8f095f3d3e" integrity sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw== @@ -1759,6 +1801,13 @@ protobufjs "^7.2.5" yargs "^17.7.2" +"@hookform/resolvers@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-5.4.0.tgz#89ff709a08576766fbef849e5ec60e549a888006" + integrity sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw== + dependencies: + "@standard-schema/utils" "^0.3.0" + "@humanfs/core@^0.19.2": version "0.19.2" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60" @@ -1965,7 +2014,7 @@ "@jest/schemas@30.4.1": version "30.4.1" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.4.1.tgz#c3703fdd71357e2c83aa59bd38469e60a11529c6" + resolved "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz" integrity sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q== dependencies: "@sinclair/typebox" "^0.34.0" @@ -1977,13 +2026,6 @@ dependencies: "@sinclair/typebox" "^0.27.8" -"@jest/schemas@30.4.1": - version "30.4.1" - resolved "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz" - integrity sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q== - dependencies: - "@sinclair/typebox" "^0.34.0" - "@jest/source-map@^29.6.3": version "29.6.3" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.6.3.tgz#d90ba772095cf37a34a5eb9413f1b562a08554c4" @@ -2372,7 +2414,7 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.1.tgz#eaee5900122c110a3dbcb728c0597014a2621774" integrity sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg== -"@react-native-async-storage/async-storage@^2.2.0", "@react-native-async-storage/async-storage@2.2.0": +"@react-native-async-storage/async-storage@2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz#a3aa565253e46286655560172f4e366e8969f5ad" integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw== @@ -2389,7 +2431,7 @@ resolved "https://registry.yarnpkg.com/@react-native-community/slider/-/slider-5.0.1.tgz#478789e526af31e0660c6f49fa5c5429d8d4287b" integrity sha512-K3JRWkIW4wQ79YJ6+BPZzp1SamoikxfPRw7Yw4B4PElEQmqZFrmH9M5LxvIo460/3QSrZF/wCgi3qizJt7g/iw== -"@react-native-firebase/app@^23.4.1", "@react-native-firebase/app@23.8.8": +"@react-native-firebase/app@^23.4.1": version "23.8.8" resolved "https://registry.yarnpkg.com/@react-native-firebase/app/-/app-23.8.8.tgz#a96aadde079b5c90e5814e94937c1083159c8894" integrity sha512-9KeKToqsEPhaL+KlTYIdUB0bsXYAcKk0Zzpu608zf2qsXbyTscuYs1caLWxk8AdGtt3+j7ZsoOFt/8qnlo4Ugg== @@ -2530,7 +2572,7 @@ "@react-native/normalize-colors@0.81.5": version "0.81.5" - resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz#1ca6cb6772bb7324df2b11aab35227eacd6bdfe7" + resolved "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz" integrity sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g== "@react-native/normalize-colors@^0.74.1": @@ -2538,11 +2580,6 @@ resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz#b8ac17d1bbccd3ef9a1f921665d04d42cff85976" integrity sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg== -"@react-native/normalize-colors@0.81.5": - version "0.81.5" - resolved "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz" - integrity sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g== - "@react-native/virtualized-lists@0.81.5": version "0.81.5" resolved "https://registry.yarnpkg.com/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz#24123fded16992d7e46ecc4ccd473be939ea8c1b" @@ -2583,7 +2620,7 @@ use-latest-callback "^0.2.4" use-sync-external-store "^1.5.0" -"@react-navigation/native@^7.1.8", "@react-navigation/native@^7.2.5": +"@react-navigation/native@^7.1.8": version "7.2.5" resolved "https://registry.yarnpkg.com/@react-navigation/native/-/native-7.2.5.tgz#f98db27148d1c8734ad021b204f117e18255a087" integrity sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg== @@ -2764,7 +2801,7 @@ "@sentry/browser" "10.53.1" "@sentry/core" "10.53.1" -"@shopify/flash-list@>=1.0.0", "@shopify/flash-list@2.0.2": +"@shopify/flash-list@2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.0.2.tgz#644748f883fccf8cf2e0ca251e0ef88673b89120" integrity sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w== @@ -2869,7 +2906,7 @@ "@svgr/babel-plugin-transform-react-native-svg" "8.1.0" "@svgr/babel-plugin-transform-svg-component" "8.0.0" -"@svgr/core@*", "@svgr/core@^8.1.0": +"@svgr/core@^8.1.0": version "8.1.0" resolved "https://registry.yarnpkg.com/@svgr/core/-/core-8.1.0.tgz#41146f9b40b1a10beaf5cc4f361a16a3c1885e88" integrity sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA== @@ -2994,7 +3031,7 @@ "@tamagui/use-presence" "1.144.4" "@tamagui/web" "1.144.4" -"@tamagui/animations-react-native@^1.135.6", "@tamagui/animations-react-native@1.144.4": +"@tamagui/animations-react-native@1.144.4", "@tamagui/animations-react-native@^1.135.6": version "1.144.4" resolved "https://registry.yarnpkg.com/@tamagui/animations-react-native/-/animations-react-native-1.144.4.tgz#5c5645a0ef4e5d1e6756b44ea87ced71f23c13cd" integrity sha512-l83+Y9IpXZoI7Pyylop5tDrVGBRxxf9ZQLoxhhZyhGJMQBQVZith1SxpawmvMdEA4lxbl/W+R3wM3cjce14kyg== @@ -3557,7 +3594,7 @@ "@tamagui/stacks" "1.144.4" "@tamagui/web" "1.144.4" -"@tamagui/sheet@^1.135.6", "@tamagui/sheet@1.144.4": +"@tamagui/sheet@1.144.4", "@tamagui/sheet@^1.135.6": version "1.144.4" resolved "https://registry.yarnpkg.com/@tamagui/sheet/-/sheet-1.144.4.tgz#0f44198a0b6738e9ec929c076488ee254020c807" integrity sha512-wffx7+Pn+lRqPPBKjvTdiCicg2hg36Km5bgJ8XZskiXXdIBuYU3v49tYbLkEd6jKUciKQYrzJ7pF8KuSsMBL1g== @@ -3908,6 +3945,13 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.1.tgz#35adc6222e3662fa2222ce123b961476a746b9ea" integrity sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ== +"@tybys/wasm-util@^0.10.1": + version "0.10.2" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e" + integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== + dependencies: + tslib "^2.4.0" + "@types/babel__core@^7.1.14": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -4072,7 +4116,7 @@ dependencies: "@types/node-fetch" "*" -"@types/react@*", "@types/react@^18.2.25 || ^19", "@types/react@^19.1.0", "@types/react@~19.1.0": +"@types/react@~19.1.0": version "19.1.17" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.17.tgz#8be0b9c546cede389b930a98eb3fad1897f209c3" integrity sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA== @@ -4214,6 +4258,115 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.1.tgz#0e8f34854df7966b09304a18e808b23997bb9fc1" integrity sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ== +"@unrs/resolver-binding-android-arm-eabi@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz#98a9fee62c01f209747a4ab5855f1ced38a6d03a" + integrity sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w== + +"@unrs/resolver-binding-android-arm64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz#46b7e8a1393f907462324f1576e8883529acf066" + integrity sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ== + +"@unrs/resolver-binding-darwin-arm64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz#0ea07b00e2583ab004b853d4c02ec5f0745d490c" + integrity sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w== + +"@unrs/resolver-binding-darwin-x64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz#a2a6901ed58449b91b4438e582f6890cba956049" + integrity sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA== + +"@unrs/resolver-binding-freebsd-x64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz#ebe6fe7f6706b7378ea4a48a024602e9c2f48f89" + integrity sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg== + +"@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz#e6040fedaa240124419d35b25b69c5fa15ddb499" + integrity sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A== + +"@unrs/resolver-binding-linux-arm-musleabihf@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz#d217a8fb59f659c131539326c140e7b62e3e3c6a" + integrity sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g== + +"@unrs/resolver-binding-linux-arm64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz#edab13c46a45783a7e01351e113825c04f352e24" + integrity sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg== + +"@unrs/resolver-binding-linux-arm64-musl@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz#e5e195db1130f7d3b6aa2fd67b3c9fe1ea4859a0" + integrity sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA== + +"@unrs/resolver-binding-linux-loong64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz#f01d22e091bae13016f4636698d9dcbbda775c3e" + integrity sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q== + +"@unrs/resolver-binding-linux-loong64-musl@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz#7d23efcb98adf076bfbcecc27b4212c36aa6697d" + integrity sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew== + +"@unrs/resolver-binding-linux-ppc64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz#1f35f1eaa322f33cf2d96dac27f0626a93ffe2f6" + integrity sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg== + +"@unrs/resolver-binding-linux-riscv64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz#674faa696f5ce96f214873946a1e2d6ca96723dd" + integrity sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A== + +"@unrs/resolver-binding-linux-riscv64-musl@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz#37835fdd0b472ecdcffccd4288f19018454b138c" + integrity sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w== + +"@unrs/resolver-binding-linux-s390x-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz#b6edf13db4bb0accdcd1ad482a4eea0301de9224" + integrity sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw== + +"@unrs/resolver-binding-linux-x64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz#daddad00bf65a405202284da1eb1db8eb83b218f" + integrity sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ== + +"@unrs/resolver-binding-linux-x64-musl@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz#dfdff1e0c2bad25420b41c76a746011c3983b9bb" + integrity sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A== + +"@unrs/resolver-binding-openharmony-arm64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz#ce07c4f5e7b42f7bfce45e7629b8659063aefefe" + integrity sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ== + +"@unrs/resolver-binding-wasm32-wasi@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz#82514f0506cfaf65f17fe16095f92d450e487183" + integrity sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A== + dependencies: + "@emnapi/core" "1.10.0" + "@emnapi/runtime" "1.10.0" + "@napi-rs/wasm-runtime" "^1.1.4" + +"@unrs/resolver-binding-win32-arm64-msvc@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz#521427dd59a8f4740ddd1dc7c3bc6af1aa1d260d" + integrity sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g== + +"@unrs/resolver-binding-win32-ia32-msvc@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz#05b63286ff2da37e0ce3083b8390884385efff62" + integrity sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g== + "@unrs/resolver-binding-win32-x64-msvc@1.12.2": version "1.12.2" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz#72da0da48d72b1e87831b9c0308931d3f4669027" @@ -4293,16 +4446,11 @@ acorn-walk@^8.0.2: dependencies: acorn "^8.11.0" -"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.1.0, acorn@^8.11.0, acorn@^8.15.0, acorn@^8.8.1: +acorn@^8.1.0, acorn@^8.11.0, acorn@^8.15.0, acorn@^8.8.1: version "8.16.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== -agent-base@^7.1.2: - version "7.1.4" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" - integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== - agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -4381,16 +4529,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^5.0.0: +ansi-styles@^5.0.0, ansi-styles@^5.2.0: version "5.2.0" resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== -ansi-styles@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" - integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== - any-promise@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" @@ -4635,7 +4778,7 @@ babel-plugin-react-native-web@~0.21.0: resolved "https://registry.yarnpkg.com/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz#d2f7fd673278da82577aa583457edb55d9cccbe0" integrity sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA== -babel-plugin-syntax-hermes-parser@^0.29.1, babel-plugin-syntax-hermes-parser@0.29.1: +babel-plugin-syntax-hermes-parser@0.29.1, babel-plugin-syntax-hermes-parser@^0.29.1: version "0.29.1" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.29.1.tgz#09ca9ecb0330eba1ef939b6d3f1f55bb06a9dc33" integrity sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA== @@ -4765,7 +4908,7 @@ bplist-creator@0.1.0: dependencies: stream-buffers "2.2.x" -bplist-parser@^0.3.1, bplist-parser@0.3.1: +bplist-parser@0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/bplist-parser/-/bplist-parser-0.3.1.tgz#e1c90b2ca2a9f9474cc72f6862bbf3fee8341fd1" integrity sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA== @@ -4808,7 +4951,7 @@ braces@^3.0.3: dependencies: fill-range "^7.1.1" -browserslist@^4.24.0, browserslist@^4.25.0, browserslist@^4.28.1, "browserslist@>= 4.21.0": +browserslist@^4.24.0, browserslist@^4.25.0, browserslist@^4.28.1: version "4.28.2" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.2.tgz#f50b65362ef48974ca9f50b3680566d786b811d2" integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg== @@ -4908,7 +5051,7 @@ caniuse-lite@^1.0.30001782: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz#238887ddf5fcfc8c36d872394d0a78a517312a72" integrity sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA== -chalk@^2.0.1: +chalk@^2.0.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -5028,7 +5171,7 @@ collect-v8-coverage@^1.0.0: resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz#cc1f01eb8d02298cbc9a437c74c70ab4e5210b80" integrity sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw== -color-convert@^1.9.0: +color-convert@^1.9.0, color-convert@^1.9.3: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== @@ -5044,7 +5187,7 @@ color-convert@^2.0.1: color-name@1.1.3: version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== color-name@^1.0.0, color-name@~1.1.4: @@ -5052,11 +5195,6 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - color-string@^1.6.0, color-string@^1.9.0: version "1.9.1" resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" @@ -5067,7 +5205,7 @@ color-string@^1.6.0, color-string@^1.9.0: color2k@^2.0.2: version "2.0.3" - resolved "https://registry.yarnpkg.com/color2k/-/color2k-2.0.3.tgz#a771244f6b6285541c82aa65ff0a0c624046e533" + resolved "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz" integrity sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog== color@^3.1.2: @@ -5086,11 +5224,6 @@ color@^4.2.3: color-convert "^2.0.1" color-string "^1.9.0" -color2k@^2.0.2: - version "2.0.3" - resolved "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz" - integrity sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog== - combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -5326,41 +5459,27 @@ dayjs@^1.11.19: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.21.tgz#57f87562e62de76f3c704bd2b8d522fc33068eb2" integrity sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA== -debug@^2.6.9: +debug@2.6.9, debug@^2.6.9: version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: ms "2.0.0" debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.3, debug@~4.4.1: version "4.4.3" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: - ms "^2.1.1" + ms "^2.1.3" -debug@^3.2.7: +debug@^3.1.0, debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== dependencies: ms "^2.1.1" -debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.3, debug@~4.4.1, debug@4: - version "4.4.3" - resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== - dependencies: - ms "^2.1.3" - -debug@2.6.9: - version "2.6.9" - resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - decimal.js@^10.4.2: version "10.6.0" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" @@ -5443,7 +5562,7 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== -depd@~2.0.0, depd@2.0.0: +depd@2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -5623,16 +5742,11 @@ engine.io-parser@~5.2.1: resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f" integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== -entities@^4.2.0: +entities@^4.2.0, entities@^4.4.0: version "4.5.0" resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== -entities@^4.4.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" - integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== - entities@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" @@ -5870,7 +5984,7 @@ eslint-plugin-expo@^1.0.0: "@typescript-eslint/utils" "^8.29.1" eslint "^9.24.0" -eslint-plugin-import@*, eslint-plugin-import@^2.30.0: +eslint-plugin-import@^2.30.0: version "2.32.0" resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz#602b55faa6e4caeaa5e970c198b5c00a37708980" integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== @@ -5947,7 +6061,7 @@ eslint-visitor-keys@^5.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== -eslint@*, "eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9", "eslint@^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7", "eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.57.0 || ^9.0.0 || ^10.0.0", eslint@^9.24.0, eslint@^9.25.0, eslint@>=8.10: +eslint@^9.24.0, eslint@^9.25.0: version "9.39.4" resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.4.tgz#855da1b2e2ad66dc5991195f35e262bcec8117b5" integrity sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ== @@ -6071,7 +6185,7 @@ expo-application@~7.0.8: resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-7.0.8.tgz#320af0d6c39b331456d3bc833b25763c702d23db" integrity sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q== -expo-asset@*, expo-asset@^12.0.9, expo-asset@~12.0.13: +expo-asset@^12.0.9, expo-asset@~12.0.13: version "12.0.13" resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-12.0.13.tgz#1974ed7abee2ad987a519dbdcbf7f0c647dddf5b" integrity sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ== @@ -6144,7 +6258,7 @@ expo-file-system@~19.0.23: resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.23.tgz#ad2c88ed039ba0fd5a739338818f42304171eddd" integrity sha512-MeGkid9OeNILfT/qonaXHp4f2c15xaB28U/bcN7pqZej0Kx0+6+V7e9ZIXpPHm07zVatxA+QkMTPQEGfmvVOxA== -expo-font@>=14.0.4, expo-font@~14.0.12, expo-font@~14.0.9: +expo-font@~14.0.12, expo-font@~14.0.9: version "14.0.12" resolved "https://registry.yarnpkg.com/expo-font/-/expo-font-14.0.12.tgz#ff8b176b3fee241e2ae5d3e2eabf3c783460b956" integrity sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ== @@ -6273,7 +6387,7 @@ expo-web-browser@~15.0.8: resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-15.0.11.tgz#6a8134e2398031ef79c89f516b15a18103903ef5" integrity sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw== -expo@*, expo@>=47.0.0, expo@>=49.0.0, expo@~54.0.19: +expo@~54.0.19: version "54.0.35" resolved "https://registry.yarnpkg.com/expo/-/expo-54.0.35.tgz#d9cf371aa4f51fc762abc7896ec14ca1996ce781" integrity sha512-E+tXpQwjGm5fK/uwa55p0Xx/kuo5dXDKfVJ95IargTNa5KiFt26lSTXXa9KnHbI4EDLwFD38/xTKZvzPTlGTdg== @@ -6401,7 +6515,7 @@ finalhandler@1.1.2: statuses "~1.5.0" unpipe "~1.0.0" -find-up@^4.0.0: +find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== @@ -6519,6 +6633,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@^2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" @@ -6754,7 +6873,7 @@ hermes-estree@0.35.0: resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.35.0.tgz#767cce0b14a68b4bc06cd5db7efe889f6188c565" integrity sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg== -hermes-parser@^0.29.1, hermes-parser@0.29.1: +hermes-parser@0.29.1, hermes-parser@^0.29.1: version "0.29.1" resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.29.1.tgz#436b24bcd7bb1e71f92a04c396ccc0716c288d56" integrity sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA== @@ -6938,7 +7057,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@^2.0.3, inherits@~2.0.3, inherits@~2.0.4, inherits@2: +inherits@2, inherits@^2.0.3, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -6964,7 +7083,7 @@ internal-slot@^1.1.0: hasown "^2.0.2" side-channel "^1.1.0" -invariant@*, invariant@^2.2.4, invariant@2.2.4: +invariant@*, invariant@2.2.4, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -7381,7 +7500,7 @@ jest-config@^29.7.0: jest-diff@30.4.1: version "30.4.1" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.4.1.tgz#26691c73975768409af4a66b2754cea3182aa2dc" + resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz" integrity sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA== dependencies: "@jest/diff-sequences" "30.4.0" @@ -7399,16 +7518,6 @@ jest-diff@^29.0.1, jest-diff@^29.7.0: jest-get-type "^29.6.3" pretty-format "^29.7.0" -jest-diff@30.4.1: - version "30.4.1" - resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz" - integrity sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA== - dependencies: - "@jest/diff-sequences" "30.4.0" - "@jest/get-type" "30.1.0" - chalk "^4.1.2" - pretty-format "30.4.1" - jest-docblock@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.7.0.tgz#8fddb6adc3cdc955c93e2a87f61cfd350d5d119a" @@ -7567,7 +7676,7 @@ jest-resolve-dependencies@^29.7.0: jest-regex-util "^29.6.3" jest-snapshot "^29.7.0" -jest-resolve@*, jest-resolve@^29.7.0: +jest-resolve@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.7.0.tgz#64d6a8992dd26f635ab0c01e5eef4399c6bcbc30" integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== @@ -7733,7 +7842,7 @@ jest-worker@^29.7.0: merge-stream "^2.0.0" supports-color "^8.0.0" -"jest@^27.0.0 || ^28.0.0 || ^29.0.0", jest@^29.2.1, jest@>=29.0.0: +jest@^29.2.1: version "29.7.0" resolved "https://registry.yarnpkg.com/jest/-/jest-29.7.0.tgz#994676fc24177f088f1c5e3737f5697204ff2613" integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== @@ -7920,6 +8029,56 @@ lighthouse-logger@^1.0.0: debug "^2.6.9" marky "^1.2.2" +lightningcss-android-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968" + integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg== + +lightningcss-darwin-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5" + integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ== + +lightningcss-darwin-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e" + integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w== + +lightningcss-freebsd-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575" + integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig== + +lightningcss-linux-arm-gnueabihf@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d" + integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw== + +lightningcss-linux-arm64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335" + integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ== + +lightningcss-linux-arm64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133" + integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg== + +lightningcss-linux-x64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6" + integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== + +lightningcss-linux-x64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b" + integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== + +lightningcss-win32-arm64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38" + integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw== + lightningcss-win32-x64-msvc@1.32.0: version "1.32.0" resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a" @@ -8192,17 +8351,12 @@ metro-cache@0.84.4: metro-config@0.83.3: version "0.83.3" - resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.83.3.tgz#a30e7a69b5cf8c4ac4c4b68b1b4c33649ae129a2" - integrity sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA== + resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.83.3.tgz#007e93f7d1983777da8988dfb103ad897c9835b8" + integrity sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw== dependencies: - connect "^3.6.5" flow-enums-runtime "^0.0.6" - jest-validate "^29.7.0" - metro "0.83.3" - metro-cache "0.83.3" - metro-core "0.83.3" - metro-runtime "0.83.3" - yaml "^2.6.1" + lodash.throttle "^4.1.1" + metro-resolver "0.83.3" metro-config@0.83.7, metro-config@^0.83.1: version "0.83.7" @@ -8218,7 +8372,7 @@ metro-config@0.83.7, metro-config@^0.83.1: metro-runtime "0.83.7" yaml "^2.6.1" -metro-config@^0.84.4, metro-config@0.84.4: +metro-config@0.84.4, metro-config@^0.84.4: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.84.4.tgz#456190b984a4ce16eb10448a3c726880cec895e9" integrity sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q== @@ -8232,21 +8386,16 @@ metro-config@^0.84.4, metro-config@0.84.4: metro-runtime "0.84.4" yaml "^2.6.1" -metro-config@0.83.3: +metro-core@0.83.3: version "0.83.3" - resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.83.3.tgz#007e93f7d1983777da8988dfb103ad897c9835b8" + resolved "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz" integrity sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw== dependencies: - connect "^3.6.5" flow-enums-runtime "^0.0.6" - jest-validate "^29.7.0" - metro "0.83.3" - metro-cache "0.83.3" - metro-core "0.83.3" - metro-runtime "0.83.3" - yaml "^2.6.1" + lodash.throttle "^4.1.1" + metro-resolver "0.83.3" -metro-core@^0.83.1, metro-core@0.83.7: +metro-core@0.83.7, metro-core@^0.83.1: version "0.83.7" resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.83.7.tgz#45cc9eebd979c75021015f190dbd023e833bdd16" integrity sha512-6yn3w1wnltT6RQl7p7YES2l95ArC+mWrOssEiH8p5/DDrJS65/szf9LsC9JrBv8c5DdvSY3V3f0GRYg0Ox7hCg== @@ -8255,15 +8404,6 @@ metro-core@^0.83.1, metro-core@0.83.7: lodash.throttle "^4.1.1" metro-resolver "0.83.7" -metro-core@0.83.3: - version "0.83.3" - resolved "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz" - integrity sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw== - dependencies: - flow-enums-runtime "^0.0.6" - lodash.throttle "^4.1.1" - metro-resolver "0.83.3" - metro-core@0.84.4: version "0.84.4" resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.84.4.tgz#e4afffab9bd2e9b304c89c59322344d59911c31b" @@ -8363,7 +8503,7 @@ metro-resolver@0.84.4: dependencies: flow-enums-runtime "^0.0.6" -metro-runtime@^0.83.1, metro-runtime@0.83.3: +metro-runtime@0.83.3, metro-runtime@^0.83.1: version "0.83.3" resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.83.3.tgz#ff504df5d93f38b1af396715b327e589ba8d184d" integrity sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw== @@ -8387,7 +8527,7 @@ metro-runtime@0.84.4: "@babel/runtime" "^7.25.0" flow-enums-runtime "^0.0.6" -metro-source-map@^0.83.1, metro-source-map@0.83.3: +metro-source-map@0.83.3, metro-source-map@^0.83.1: version "0.83.3" resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.83.3.tgz#04bb464f7928ea48bcdfd18912c8607cf317c898" integrity sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg== @@ -8562,51 +8702,6 @@ metro-transform-worker@0.84.4: metro-transform-plugins "0.84.4" nullthrows "^1.1.1" -metro@^0.83.1, metro@0.83.7: - version "0.83.7" - resolved "https://registry.npmjs.org/metro/-/metro-0.83.7.tgz" - integrity sha512-SPaPEyvTsTmd0LpT7RaZciQyDw2i/JB7+iY9L5VfBo72+psescFxBqpI1TL9dnL+pmnfkU+l/J1mEEGLeF65EQ== - dependencies: - "@babel/code-frame" "^7.29.0" - "@babel/core" "^7.25.2" - "@babel/generator" "^7.29.1" - "@babel/parser" "^7.29.0" - "@babel/template" "^7.28.6" - "@babel/traverse" "^7.29.0" - "@babel/types" "^7.29.0" - accepts "^2.0.0" - ci-info "^2.0.0" - connect "^3.6.5" - debug "^4.4.0" - error-stack-parser "^2.0.6" - flow-enums-runtime "^0.0.6" - graceful-fs "^4.2.4" - hermes-parser "0.35.0" - image-size "^1.0.2" - invariant "^2.2.4" - jest-worker "^29.7.0" - jsc-safe-url "^0.2.2" - lodash.throttle "^4.1.1" - metro-babel-transformer "0.83.7" - metro-cache "0.83.7" - metro-cache-key "0.83.7" - metro-config "0.83.7" - metro-core "0.83.7" - metro-file-map "0.83.7" - metro-resolver "0.83.7" - metro-runtime "0.83.7" - metro-source-map "0.83.7" - metro-symbolicate "0.83.7" - metro-transform-plugins "0.83.7" - metro-transform-worker "0.83.7" - mime-types "^3.0.1" - nullthrows "^1.1.1" - serialize-error "^2.1.0" - source-map "^0.5.6" - throat "^5.0.0" - ws "^7.5.10" - yargs "^17.6.2" - metro@0.83.3: version "0.83.3" resolved "https://registry.yarnpkg.com/metro/-/metro-0.83.3.tgz#1e7e04c15519af746f8932c7f9c553d92c39e922" @@ -8751,21 +8846,16 @@ micromatch@^4.0.4: braces "^3.0.3" picomatch "^2.3.1" -mime-db@1.52.0: +mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -"mime-db@>= 1.43.0 < 2", mime-db@^1.54.0: +mime-db@^1.54.0: version "1.54.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== -"mime-db@>= 1.43.0 < 2", mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" @@ -8889,7 +8979,7 @@ natural-compare@^1.4.0: negotiator@0.6.3: version "0.6.3" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== negotiator@^1.0.0: @@ -8902,11 +8992,6 @@ negotiator@~0.6.4: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7" integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== -negotiator@0.6.3: - version "0.6.3" - resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" - integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - nested-error-stacks@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz#d2cc9fc5235ddb371fc44d506234339c8e4b0a4b" @@ -9361,7 +9446,7 @@ picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== -"picomatch@^3 || ^4", picomatch@^4.0.4: +picomatch@^4.0.3, picomatch@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== @@ -9428,7 +9513,7 @@ pretty-bytes@^5.6.0: pretty-format@30.4.1, pretty-format@^30.0.5: version "30.4.1" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.4.1.tgz#0911652e92e1e91f475e3e6a16e628e50649ea69" + resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz" integrity sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw== dependencies: "@jest/schemas" "30.4.1" @@ -9445,16 +9530,6 @@ pretty-format@^29.0.0, pretty-format@^29.0.3, pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" -pretty-format@^30.0.5, pretty-format@30.4.1: - version "30.4.1" - resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz" - integrity sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw== - dependencies: - "@jest/schemas" "30.4.1" - ansi-styles "^5.2.0" - react-is-18 "npm:react-is@^18.3.1" - react-is-19 "npm:react-is@^19.2.5" - proc-log@^4.0.0: version "4.2.0" resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-4.2.0.tgz#b6f461e4026e75fdfe228b265e9f7a00779d7034" @@ -9551,7 +9626,7 @@ pure-rand@^6.0.0: qrcode-terminal@0.11.0: version "0.11.0" - resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz#ffc6c28a2fc0bfb47052b47e23f4f446a5fbdb9e" + resolved "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz" integrity sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ== qrcode-terminal@^0.12.0: @@ -9559,11 +9634,6 @@ qrcode-terminal@^0.12.0: resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== -qrcode-terminal@0.11.0: - version "0.11.0" - resolved "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz" - integrity sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ== - query-string@^7.1.3: version "7.1.3" resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.3.tgz#a1cf90e994abb113a325804a972d98276fe02328" @@ -9614,7 +9684,7 @@ react-devtools-core@^6.1.5: shell-quote "^1.6.1" ws "^7" -react-dom@*, "react-dom@^18.0.0 || ^19.0.0", react-dom@>=16.8.0, react-dom@>=17.0.0, react-dom@19.1.0: +react-dom@19.1.0: version "19.1.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.1.0.tgz#133558deca37fa1d682708df8904b25186793623" integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g== @@ -9626,6 +9696,11 @@ react-freeze@^1.0.0, react-freeze@^1.0.3: resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.4.tgz#cbbea2762b0368b05cbe407ddc9d518c57c6f3ad" integrity sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA== +react-hook-form@^7.78.0: + version "7.78.0" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.78.0.tgz#b5a7d496d9077a71f36c8f2cacd3990464af2643" + integrity sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA== + "react-is-18@npm:react-is@^18.3.1": version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" @@ -9636,16 +9711,11 @@ react-freeze@^1.0.0, react-freeze@^1.0.3: resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.7.tgz#57668ee86a78574a542b0a539455212b2c086df2" integrity sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A== -react-is@^16.13.1: +react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^16.7.0: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" - integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== - react-is@^18.0.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" @@ -9693,7 +9763,7 @@ react-native-fs@^2.20.0: base-64 "^0.1.0" utf8 "^3.0.0" -"react-native-gesture-handler@>= 2.0.0", react-native-gesture-handler@>=2.0.0, react-native-gesture-handler@>=2.16.1, react-native-gesture-handler@~2.28.0: +react-native-gesture-handler@~2.28.0: version "2.28.0" resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz#07fb4f5eae72f810aac3019b060d26c1835bfd0c" integrity sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A== @@ -9728,7 +9798,7 @@ react-native-is-edge-to-edge@^1.2.1: resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz#feb9a6a8faf0874298947edd556e5af22044e139" integrity sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA== -react-native-keyboard-controller@>=1.0.0, react-native-keyboard-controller@1.18.5: +react-native-keyboard-controller@1.18.5: version "1.18.5" resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.18.5.tgz#ae12131f2019c574178479d2c55784f55e08bb68" integrity sha512-wbYN6Tcu3G5a05dhRYBgjgd74KqoYWuUmroLpigRg9cXy5uYo7prTMIvMgvLtARQtUF7BOtFggUnzgoBOgk0TQ== @@ -9740,7 +9810,7 @@ react-native-mmkv@^4.3.1: resolved "https://registry.npmjs.org/react-native-mmkv/-/react-native-mmkv-4.3.1.tgz" integrity sha512-APyGGaaHtayVgT18dBM8QGGZKr9pGfSTiBwbbPNzhGGfJQSU7awLGRGq879OqYl31HmVks9hOBLCs+qfgacRZg== -react-native-nitro-modules@*, react-native-nitro-modules@^0.35.9: +react-native-nitro-modules@^0.35.9: version "0.35.9" resolved "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.9.tgz" integrity sha512-yCO6eJ85SPPUo4a4an7H5oj6wPCSIT72fbjr5WZ/20n6zswaJ2gNNpnWtg2We0AZwkAOjSqkOJ0Vjc05p6kGiA== @@ -9764,7 +9834,7 @@ react-native-pell-rich-editor@^1.10.0: resolved "https://registry.yarnpkg.com/react-native-pell-rich-editor/-/react-native-pell-rich-editor-1.10.0.tgz#5463f0af7bf641cd8de652efb45f2bedf8ceabde" integrity sha512-MRL7lokAQOIxcZ900FxHeVA0B3ZRcFlIhZ9FSQIrjHmutN2at+hax139xV0AQ1DwvJ3fYwlUuTr7Pf4hHpy/PA== -react-native-reanimated@>=3.0.0, "react-native-reanimated@>=3.0.0 || ^4.0.0", "react-native-reanimated@>=3.16.0 || >=4.0.0-", react-native-reanimated@>=3.8.1, react-native-reanimated@~4.1.1: +react-native-reanimated@~4.1.1: version "4.1.7" resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz#b4e8524503a1b6ec1b5a40c460ee807a6a9fd2cf" integrity sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg== @@ -9772,12 +9842,12 @@ react-native-reanimated@>=3.0.0, "react-native-reanimated@>=3.0.0 || ^4.0.0", "r react-native-is-edge-to-edge "^1.2.1" semver "^7.7.2" -react-native-safe-area-context@*, "react-native-safe-area-context@>= 4.0.0", react-native-safe-area-context@>=5.0.0, react-native-safe-area-context@~5.6.0: +react-native-safe-area-context@~5.6.0: version "5.6.2" resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz#283e006f5b434fb247fcb4be0971ad7473d5c560" integrity sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg== -"react-native-screens@>= 4.0.0", react-native-screens@~4.16.0: +react-native-screens@~4.16.0: version "4.16.0" resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-4.16.0.tgz#efa42e77a092aa0b5277c9ae41391ea0240e0870" integrity sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q== @@ -9806,7 +9876,7 @@ react-native-svg-transformer@^1.5.1: "@svgr/plugin-svgo" "^8.1.0" path-dirname "^1.0.2" -"react-native-svg@> 6.4.1", react-native-svg@>=12.0.0, react-native-svg@15.12.1: +react-native-svg@15.12.1: version "15.12.1" resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-15.12.1.tgz#7ba756dd6a235f86a2c312a1e7911f9b0d18ad3a" integrity sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g== @@ -9828,7 +9898,7 @@ react-native-version-check@^3.5.0: lodash "^4.17.21" semver "^6.1.1" -react-native-web@*, react-native-web@~0.21.0: +react-native-web@~0.21.0: version "0.21.2" resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.21.2.tgz#0f6983dfea600d9cc1c66fda87ff9ca585eaa647" integrity sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg== @@ -9842,7 +9912,7 @@ react-native-web@*, react-native-web@~0.21.0: postcss-value-parser "^4.2.0" styleq "^0.1.3" -react-native-webview@*, "react-native-webview@>= 13.13.2", react-native-webview@>=7.5.2, react-native-webview@13.15.0: +react-native-webview@13.15.0: version "13.15.0" resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-13.15.0.tgz#b6d2f8d8dd65897db76659ddd8198d2c74ec5a79" integrity sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ== @@ -9850,7 +9920,7 @@ react-native-webview@*, "react-native-webview@>= 13.13.2", react-native-webview@ escape-string-regexp "^4.0.0" invariant "2.2.4" -"react-native-worklets@0.5 - 0.8", react-native-worklets@0.5.1: +react-native-worklets@0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.5.1.tgz#d153242655e3757b6c62a474768831157316ad33" integrity sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w== @@ -9872,7 +9942,7 @@ react-native-zoom-reanimated@^1.5.2: resolved "https://registry.yarnpkg.com/react-native-zoom-reanimated/-/react-native-zoom-reanimated-1.5.3.tgz#65a3c43d0e385dcbfd652553f33b1201d390fa14" integrity sha512-IuaRbzs/Ku2lyOcG0p1xMro+1K1bCC+jtWoQecUaqK8/ME97uRwNgwi6k/Fx8cEsx/R60HLA/mqpbw8pfrUECw== -react-native@*, "react-native@^0.0.0-0 || >=0.65 <1.0", "react-native@>= 0.50.0", react-native@>=0.46, react-native@>=0.48.0, react-native@>=0.50.0, react-native@>=0.59, react-native@>=0.59.0, react-native@>=0.60, react-native@>=0.64.0, react-native@>=0.65.0, react-native@>=0.71, "react-native@0.78 - 0.82", react-native@0.81.5: +react-native@0.81.5: version "0.81.5" resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.81.5.tgz#6c963f137d3979b22aef2d8482067775c8fe2fed" integrity sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw== @@ -9912,7 +9982,7 @@ react-native@*, "react-native@^0.0.0-0 || >=0.65 <1.0", "react-native@>= 0.50.0" ws "^6.2.3" yargs "^17.6.2" -"react-redux@^7.2.1 || ^8.1.3 || ^9.0.0", react-redux@^9.2.0: +react-redux@^9.2.0: version "9.3.0" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.3.0.tgz#a30113bb6d95c0a715d54dda4308d450fca6ce09" integrity sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g== @@ -9920,22 +9990,12 @@ react-native@*, "react-native@^0.0.0-0 || >=0.65 <1.0", "react-native@>= 0.50.0" "@types/use-sync-external-store" "^0.0.6" use-sync-external-store "^1.4.0" -react-refresh@^0.14.0: - version "0.14.2" - resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz" - integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== - -react-refresh@^0.14.2: +react-refresh@^0.14.0, react-refresh@^0.14.2: version "0.14.2" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9" integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== -"react-refresh@>=0.14.0 <1.0.0": - version "0.18.0" - resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz" - integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw== - -react-test-renderer@>=16.0.0, react-test-renderer@>=18.2.0, react-test-renderer@19.1.0: +react-test-renderer@19.1.0: version "19.1.0" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-19.1.0.tgz#89e1baa9e45a6da064b9760f92251d5b8e1f34ab" integrity sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw== @@ -9943,7 +10003,7 @@ react-test-renderer@>=16.0.0, react-test-renderer@>=18.2.0, react-test-renderer@ react-is "^19.1.0" scheduler "^0.26.0" -react@*, "react@^16.14.0 || 17.x || 18.x || 19.x", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.9.0 || ^17.0.0 || ^18 || ^19", "react@^18 || ^19", "react@^18.0 || ^19", "react@^18.0.0 || ^19.0.0", react@^19.1.0, "react@> 16.7.0", "react@>= 18.2.0", react@>=16, react@>=16.0.0, react@>=16.3.0, react@>=16.8, react@>=16.8.0, react@>=17.0.0, react@>=18.0.0, react@>=18.2.0, react@19.1.0: +react@19.1.0: version "19.1.0" resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75" integrity sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg== @@ -9961,7 +10021,7 @@ redux-thunk@^3.1.0: resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== -redux@^5.0.0, redux@^5.0.1: +redux@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== @@ -10109,7 +10169,7 @@ resolve@^1.20.0, resolve@^1.22.11, resolve@^1.22.2: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -resolve@^2.0.0-next.5: +resolve@^2.0.0-next.5, resolve@^2.0.0-next.6: version "2.0.0-next.7" resolved "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz" integrity sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ== @@ -10121,18 +10181,6 @@ resolve@^2.0.0-next.5: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -resolve@^2.0.0-next.6: - version "2.0.0-next.7" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.7.tgz#ba3b035d4b1ee7c522426eee73cabcb0fd5515dd" - integrity sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ== - dependencies: - es-errors "^1.3.0" - is-core-module "^2.16.2" - node-exports-info "^1.6.0" - object-keys "^1.1.1" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - resolve@~1.7.1: version "1.7.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.7.1.tgz#aadd656374fd298aee895bc026b8297418677fd3" @@ -10178,7 +10226,7 @@ safe-array-concat@^1.1.3: has-symbols "^1.1.0" isarray "^2.0.5" -safe-buffer@>=5.1.0, safe-buffer@5.2.1: +safe-buffer@5.2.1, safe-buffer@>=5.1.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -10205,7 +10253,7 @@ safe-regex-test@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sax@^1.5.0, sax@>=0.6.0: +sax@>=0.6.0, sax@^1.5.0: version "1.6.0" resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.0.tgz#da59637629307b97e7c4cb28e080a7bc38560d5b" integrity sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA== @@ -10217,14 +10265,14 @@ saxes@^6.0.0: dependencies: xmlchars "^2.2.0" -scheduler@^0.26.0, scheduler@0.26.0: +scheduler@0.26.0, scheduler@^0.26.0: version "0.26.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.26.0.tgz#4ce8a8c2a2095f13ea11bf9a445be50c555d6337" integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA== semver@7.7.2: version "7.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== semver@^6.1.1, semver@^6.3.0, semver@^6.3.1: @@ -10232,46 +10280,16 @@ semver@^6.1.1, semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.1.3, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.7.1, semver@^7.7.2, semver@^7.7.3: +semver@^7.1.3, semver@^7.3.5: version "7.8.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.2.tgz#194bd65723a28cf82542d2bf176b91c26b343be1" integrity sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ== -semver@^7.5.3: - version "7.8.1" - resolved "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz" - integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== - -semver@^7.5.4: - version "7.8.1" - resolved "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz" - integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== - -semver@^7.6.0: - version "7.8.1" - resolved "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz" - integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== - -semver@^7.7.1: +semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.7.1, semver@^7.7.2, semver@^7.7.3: version "7.8.1" resolved "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz" integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== -semver@^7.7.2: - version "7.8.1" - resolved "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz" - integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== - -semver@^7.7.3: - version "7.8.1" - resolved "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz" - integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== - -semver@7.7.2: - version "7.7.2" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== - send@^0.19.0, send@~0.19.1: version "0.19.2" resolved "https://registry.yarnpkg.com/send/-/send-0.19.2.tgz#59bc0da1b4ea7ad42736fd642b1c4294e114ff29" @@ -10499,7 +10517,7 @@ source-map-support@0.5.13: buffer-from "^1.0.0" source-map "^0.6.0" -source-map-support@~0.5.21: +source-map-support@~0.5.20, source-map-support@~0.5.21: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== @@ -10509,7 +10527,7 @@ source-map-support@~0.5.21: source-map@0.5.6: version "0.5.6" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" + resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz" integrity sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA== source-map@^0.5.6: @@ -10522,11 +10540,6 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@0.5.6: - version "0.5.6" - resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz" - integrity sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA== - split-on-first@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" @@ -11095,7 +11108,7 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.1.0" reflect.getprototypeof "^1.0.10" -"typescript@^5.0.0 || ^5.0.0-0", typescript@>=4.8.4, "typescript@>=4.8.4 <6.1.0", typescript@>=4.9.5, typescript@~5.9.2: +typescript@~5.9.2: version "5.9.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -11312,7 +11325,7 @@ walker@^1.0.7, walker@^1.0.8: dependencies: makeerror "1.0.12" -warn-once@^0.1.0, warn-once@0.1.1: +warn-once@0.1.1, warn-once@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/warn-once/-/warn-once-0.1.1.tgz#952088f4fb56896e73fd4e6a3767272a3fccce43" integrity sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q== @@ -11499,12 +11512,7 @@ ws@^6.2.3: dependencies: async-limiter "~1.0.0" -ws@^7: - version "7.5.11" - resolved "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz" - integrity sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA== - -ws@^7.5.10: +ws@^7, ws@^7.5.10: version "7.5.11" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.11.tgz#9460daf1812bb81a423c5b9eac746941a86310fa" integrity sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA== @@ -11608,7 +11616,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^4.1.11: +zod@^4.1.11, zod@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/zod/-/zod-4.4.3.tgz#b680f172885d18bbebf21a834ea25e55a1bbf356" integrity sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ== From 8191aa73b922f46d6d5ee7f3240d526bfef4c62e Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 8 Jun 2026 22:58:48 +0530 Subject: [PATCH 2/4] feat: track podcast playback position using AsyncStorage --- frontend/src/components/PodcastCard.tsx | 35 ++++++++- .../src/components/StructuredPodcastCard.tsx | 44 +++++++++++- frontend/src/helper/PlaybackManager.ts | 65 +++++++++++++++++ frontend/src/screens/PodcastDetail.tsx | 72 ++++++++++++++----- 4 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 frontend/src/helper/PlaybackManager.ts diff --git a/frontend/src/components/PodcastCard.tsx b/frontend/src/components/PodcastCard.tsx index e846ff7c..518843f7 100644 --- a/frontend/src/components/PodcastCard.tsx +++ b/frontend/src/components/PodcastCard.tsx @@ -10,8 +10,9 @@ import Share from 'react-native-share'; import {GET_STORAGE_DATA} from '../helper/APIUtils'; import {GlassStyles, ProfessionalColors, BorderRadius} from '../styles/GlassStyles'; import {useSelector} from 'react-redux'; -import {useNavigation} from '@react-navigation/native'; +import {useNavigation, useFocusEffect} from '@react-navigation/native'; import { PODCAST_CARD } from '@/constants/podcastCard'; +import {getPlaybackPosition, PlaybackPosition} from '../helper/PlaybackManager'; interface PodcastProps { id: string; @@ -49,6 +50,21 @@ const PodcastCard = ({ const sheetRef = useRef(null); const {isGuest} = useSelector((state: any) => state.user); const navigation = useNavigation(); + const [progress, setProgress] = React.useState(null); + + useFocusEffect( + React.useCallback(() => { + let isMounted = true; + getPlaybackPosition(id).then(pos => { + if (isMounted) { + setProgress(pos); + } + }); + return () => { + isMounted = false; + }; + }, [id]) + ); const handleOpenSheet = () => { if (isGuest) { @@ -99,6 +115,11 @@ const PodcastCard = ({ + {progress && progress.duration > 0 && ( + + + + )} @@ -224,6 +245,18 @@ const styles = StyleSheet.create({ alignItems: 'center', zIndex: 10, }, + progressBarContainer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 4, + backgroundColor: 'rgba(255, 255, 255, 0.3)', + }, + progressBar: { + height: '100%', + backgroundColor: ProfessionalColors.primary, + }, }); export default PodcastCard; \ No newline at end of file diff --git a/frontend/src/components/StructuredPodcastCard.tsx b/frontend/src/components/StructuredPodcastCard.tsx index 9c833504..77bdce63 100644 --- a/frontend/src/components/StructuredPodcastCard.tsx +++ b/frontend/src/components/StructuredPodcastCard.tsx @@ -6,8 +6,9 @@ import { TouchableOpacity, useColorScheme, } from 'react-native'; -import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { NavigationProp, useNavigation, useFocusEffect } from '@react-navigation/native'; import { RootStackParamList } from '../type'; +import { getPlaybackPosition, PlaybackPosition } from '../helper/PlaybackManager'; interface PodcastEpisode { id: string; @@ -33,6 +34,30 @@ const StructuredPodcastCard: React.FC = ({ const episodeBg = isDark ? '#253525' : '#FFFFFF'; const navigation = useNavigation>(); + const [progresses, setProgresses] = React.useState>({}); + + useFocusEffect( + React.useCallback(() => { + let isMounted = true; + const fetchProgresses = async () => { + if (!relatedEpisodes) return; + const results: Record = {}; + for (const ep of relatedEpisodes) { + const pos = await getPlaybackPosition(ep.id); + if (pos) { + results[ep.id] = pos; + } + } + if (isMounted) { + setProgresses(results); + } + }; + fetchProgresses(); + return () => { + isMounted = false; + }; + }, [relatedEpisodes]) + ); if (!relatedEpisodes || relatedEpisodes.length === 0) return null; @@ -60,6 +85,11 @@ const StructuredPodcastCard: React.FC = ({ {episode.description} 🕐 {episode.durationMinutes} min · {episode.topic} + {progresses[episode.id] && progresses[episode.id].duration > 0 && ( + + + + )} ))} @@ -114,6 +144,18 @@ const styles = StyleSheet.create({ fontSize: 11, fontWeight: '500', }, + progressBarContainer: { + height: 3, + backgroundColor: 'rgba(150, 150, 150, 0.2)', + borderRadius: 2, + marginTop: 8, + width: '100%', + overflow: 'hidden', + }, + progressBar: { + height: '100%', + borderRadius: 2, + }, }); export default StructuredPodcastCard; diff --git a/frontend/src/helper/PlaybackManager.ts b/frontend/src/helper/PlaybackManager.ts new file mode 100644 index 00000000..1ac85c9d --- /dev/null +++ b/frontend/src/helper/PlaybackManager.ts @@ -0,0 +1,65 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export interface PlaybackPosition { + position: number; + duration: number; +} + +const PODCAST_PROGRESS_KEY = '@podcast_positions'; + +// Get the full position map +export const getAllPlaybackPositions = async (): Promise> => { + try { + const data = await AsyncStorage.getItem(PODCAST_PROGRESS_KEY); + if (data) { + return JSON.parse(data); + } + } catch (error) { + console.error('Error getting podcast positions:', error); + } + return {}; +}; + +// Get the position for a specific track +export const getPlaybackPosition = async (trackId: string): Promise => { + try { + const positions = await getAllPlaybackPositions(); + return positions[trackId] || null; + } catch (error) { + console.error('Error getting playback position for track', trackId, error); + return null; + } +}; + +// Save position for a specific track +export const savePlaybackPosition = async (trackId: string, position: number, duration: number) => { + try { + // If the episode is almost complete (>95%), clear the position instead + if (duration > 0 && position / duration > 0.95) { + await clearPlaybackPosition(trackId); + return; + } + + // Only save if we actually made some progress (>2 seconds) + if (position > 2) { + const positions = await getAllPlaybackPositions(); + positions[trackId] = { position, duration }; + await AsyncStorage.setItem(PODCAST_PROGRESS_KEY, JSON.stringify(positions)); + } + } catch (error) { + console.error('Error saving playback position for track', trackId, error); + } +}; + +// Clear the position for a specific track +export const clearPlaybackPosition = async (trackId: string) => { + try { + const positions = await getAllPlaybackPositions(); + if (positions[trackId]) { + delete positions[trackId]; + await AsyncStorage.setItem(PODCAST_PROGRESS_KEY, JSON.stringify(positions)); + } + } catch (error) { + console.error('Error clearing playback position for track', trackId, error); + } +}; diff --git a/frontend/src/screens/PodcastDetail.tsx b/frontend/src/screens/PodcastDetail.tsx index 5a127208..f3249bfc 100644 --- a/frontend/src/screens/PodcastDetail.tsx +++ b/frontend/src/screens/PodcastDetail.tsx @@ -34,6 +34,7 @@ import {Theme, XStack, YStack, Text, ScrollView} from 'tamagui'; import LottieView from 'lottie-react-native'; import {useGetSinglePodcastDetails} from '../hooks/useGetSinglePodcastDetails'; import {useLikePodcast} from '../hooks/useLikePodcast'; +import {getPlaybackPosition, savePlaybackPosition} from '../helper/PlaybackManager'; const isAllowedUrl = (urlStr?: string | null): boolean => { if (!urlStr) return false; @@ -112,16 +113,63 @@ const PodcastDetail = ({navigation, route}: PodcastDetailScreenProp) => { }, [podcast?.audio_url, player, loadedSource]); useEffect(() => { + let lastSaveTime = 0; const interval = setInterval(() => { if (player.playing) { - setPosition(player.currentTime || 0); - setDuration(player.duration || 1); - // setIsPlaying(player.playing || false); + const currentPos = player.currentTime || 0; + const totalDur = player.duration || 1; + setPosition(currentPos); + setDuration(totalDur); + + // Save position every 5 seconds to reduce AsyncStorage writes + const now = Date.now(); + if (now - lastSaveTime > 5000) { + savePlaybackPosition(trackId, currentPos, totalDur); + lastSaveTime = now; + } } }, 500); return () => clearInterval(interval); - }, [player.currentTime, player.duration, player.playing]); + }, [player.currentTime, player.duration, player.playing, trackId]); + + // Check for saved position on mount + useEffect(() => { + let isCancelled = false; + const checkResume = async () => { + const saved = await getPlaybackPosition(trackId); + if (!isCancelled && saved && saved.position > 5) { + Alert.alert( + 'Resume Podcast', + `Do you want to resume from ${formatSecTime(saved.position)}?`, + [ + { + text: 'Start Over', + style: 'cancel', + onPress: async () => { + await player.seekTo(0); + setPosition(0); + } + }, + { + text: 'Resume', + onPress: async () => { + await player.seekTo(saved.position); + setPosition(saved.position); + player.play(); + setIsPlaying(true); + } + } + ] + ); + } + }; + // Give player a brief moment to initialize before asking + setTimeout(() => { + checkResume(); + }, 500); + return () => { isCancelled = true; }; + }, [trackId]); useEffect(()=>{ @@ -172,17 +220,7 @@ const PodcastDetail = ({navigation, route}: PodcastDetailScreenProp) => { return `${mins}:${secs < 10 ? '0' : ''}${secs}`; } }; - // For position update - - useEffect(() => { - const interval = setInterval(async () => { - if (player) { - const status = player.currentStatus; - if (status.isLoaded) setPosition(status.currentTime); - } - }, 500); - return () => clearInterval(interval); - }, [player, player.currentTime, player.duration, player.playing]); + // For position update (removed redundant interval) const handleShare = async () => { try { @@ -220,10 +258,10 @@ const PodcastDetail = ({navigation, route}: PodcastDetailScreenProp) => { const handlePause = async () => { if (!player) return; - player.pause(); - // setUiState('paused'); setIsPlaying(false); + // Ensure we save exact position on pause + savePlaybackPosition(trackId, player.currentTime || 0, player.duration || 1); }; if (isPodcastLoading || isLoading) { From 554510fdc8c0fc8b177dcc7411b7ca8c108ed1ec Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 8 Jun 2026 23:33:37 +0530 Subject: [PATCH 3/4] feat: implement responsive font scaling and 44px tap targets for a11y --- frontend/src/components/ActivityOverview.tsx | 1587 ++++----- .../src/components/AddSpecializationModal.tsx | 297 +- frontend/src/components/AnimatedMenu.tsx | 179 +- .../src/components/ArticleFloatingMenu.tsx | 147 +- frontend/src/components/BenefitsModal.tsx | 325 +- .../components/CategoriesFlatlistModal.tsx | 475 +-- frontend/src/components/CommentItem.tsx | 467 +-- frontend/src/components/ContactTab.tsx | 323 +- frontend/src/components/CreatePlaylist.tsx | 855 ++--- frontend/src/components/CustomAlert.tsx | 143 +- frontend/src/components/EditRequestModal.tsx | 195 +- frontend/src/components/Editor.tsx | 515 +-- frontend/src/components/EmailInputModal.tsx | 545 +-- frontend/src/components/EmptyStates.tsx | 735 +++-- frontend/src/components/FilterModal.tsx | 1185 +++---- frontend/src/components/GeneralTab.tsx | 529 +-- .../src/components/GlossaryBottomSheet.tsx | 309 +- .../src/components/GuestPlaceholderScreen.tsx | 279 +- frontend/src/components/HeaderRightMenu.tsx | 471 +-- frontend/src/components/HomeScreenHeader.tsx | 161 +- frontend/src/components/ImprovementCard.tsx | 487 +-- .../components/LanguagePreferenceSelector.tsx | 681 ++-- frontend/src/components/LoadingSpinner.tsx | 163 +- frontend/src/components/Message.tsx | 139 +- frontend/src/components/NetworkCheck.tsx | 167 +- frontend/src/components/NoInternet.tsx | 179 +- frontend/src/components/PasswordTab.tsx | 487 +-- frontend/src/components/PodcastActions.tsx | 299 +- frontend/src/components/PodcastCard.tsx | 521 +-- .../src/components/PodcastEmptyComponent.tsx | 83 +- frontend/src/components/PodcastPlayer.tsx | 941 +++--- frontend/src/components/ProfessionalTab.tsx | 373 +-- .../src/components/ResearchSummaryCard.tsx | 321 +- frontend/src/components/ReviewCard.tsx | 635 ++-- frontend/src/components/ReviewItem.tsx | 177 +- .../src/components/SecurityWarningModal.tsx | 227 +- frontend/src/components/StatisticsCard.tsx | 233 +- .../src/components/StructuredPodcastCard.tsx | 321 +- frontend/src/components/UpdateModal.tsx | 91 +- frontend/src/components/VerifiedModal.tsx | 181 +- frontend/src/helper/Metric.ts | 7 +- frontend/src/navigations/StackNavigation.tsx | 1807 +++++----- frontend/src/navigations/TabNavigation.tsx | 273 +- frontend/src/screens/AboutPage.tsx | 787 ++--- frontend/src/screens/ChatbotScreen.tsx | 677 ++-- frontend/src/screens/CommentScreen.tsx | 1565 ++++----- .../src/screens/CommunityGuidelinesScreen.tsx | 837 ++--- frontend/src/screens/ContributorPage.tsx | 949 +++--- frontend/src/screens/HomeScreen.tsx | 2089 ++++++------ .../screens/NotificationPreferencesScreen.tsx | 731 ++-- frontend/src/screens/NotificationScreen.tsx | 837 ++--- .../src/screens/OfflinePodcastDetails.tsx | 1081 +++--- frontend/src/screens/OfflinePodcastList.tsx | 305 +- frontend/src/screens/OpenSourcePage.tsx | 329 +- frontend/src/screens/PodcastDetail.tsx | 1563 ++++----- frontend/src/screens/PodcastDiscussion.tsx | 1517 ++++----- frontend/src/screens/PodcastForm.tsx | 1363 ++++---- frontend/src/screens/PodcastPlayer.tsx | 1037 +++--- frontend/src/screens/PodcastProfile.tsx | 1081 +++--- frontend/src/screens/PodcastRecorder.tsx | 1179 +++---- frontend/src/screens/PodcastSearch.tsx | 557 ++-- frontend/src/screens/PodcastsScreen.tsx | 791 ++--- frontend/src/screens/ProfileEditScreen.tsx | 1401 ++++---- frontend/src/screens/ProfileScreen.tsx | 777 ++--- frontend/src/screens/SocialScreen.tsx | 559 ++-- frontend/src/screens/SplashScreen.tsx | 317 +- frontend/src/screens/UserProfileScreen.tsx | 945 +++--- .../article/ArticleDescriptionScreen.tsx | 1559 ++++----- .../src/screens/article/ArticleScreen.tsx | 2939 +++++++++-------- frontend/src/screens/article/EditorScreen.tsx | 903 ++--- .../src/screens/article/PreviewScreen.tsx | 1151 +++---- .../src/screens/article/RenderSuggestion.tsx | 253 +- frontend/src/screens/auth/LoginScreen.tsx | 1185 +++---- frontend/src/screens/auth/LogoutScreen.tsx | 421 +-- .../src/screens/auth/NewPasswordScreen.tsx | 903 ++--- frontend/src/screens/auth/OtpScreen.tsx | 569 ++-- .../src/screens/auth/SignUpScreenFirst.tsx | 1189 +++---- .../src/screens/auth/SignUpScreenSecond.tsx | 863 ++--- .../src/screens/overview/ArticleWorkSpace.tsx | 387 +-- .../overview/ImprovementReviewScreen.tsx | 1381 ++++---- .../screens/overview/ImprovementWorkspace.tsx | 403 +-- .../src/screens/overview/OverviewScreen.tsx | 423 +-- .../src/screens/overview/PodcastWorkSpace.tsx | 539 +-- .../src/screens/overview/ReviewScreen.tsx | 1081 +++--- frontend/src/styles/GlassStyles.ts | 1203 +++---- frontend/src/styles/GlobalStyle.ts | 91 +- scratch/refactor.js | 60 + 87 files changed, 29725 insertions(+), 29567 deletions(-) create mode 100644 scratch/refactor.js diff --git a/frontend/src/components/ActivityOverview.tsx b/frontend/src/components/ActivityOverview.tsx index b58dc6ef..76bb82eb 100644 --- a/frontend/src/components/ActivityOverview.tsx +++ b/frontend/src/components/ActivityOverview.tsx @@ -1,793 +1,794 @@ -import {StyleSheet, Dimensions, ImageSourcePropType} from 'react-native'; -import {useCallback, useEffect, useState} from 'react'; - -import { - YStack, - XStack, - Text, - Card, - ScrollView, - Image, - View, - Separator, -} from 'tamagui'; - -import {PRIMARY_COLOR} from '../helper/Theme'; -//import {LineChart} from 'react-native-gifted-charts'; -import {BarChart} from 'react-native-chart-kit'; -import { getCurrentYear, formatDateShortYear } from '../helper/dateUtils'; -import {fp, hp} from '../helper/Metric'; -import {useSelector} from 'react-redux'; -import {GET_IMAGE} from '../helper/APIUtils'; -import {ArticleData, MonthStatus, YearStatus} from '../type'; -import Loader from './Loader'; - -import {useFocusEffect} from '@react-navigation/native'; -import {Dropdown} from 'react-native-element-dropdown'; -import {useGetAuthorMonthlyReadReport} from '../hooks/useGetMonthlyReadReport'; -import {useGetAuthorMonthlyWriteReport} from '../hooks/useGetMonthlyWriteReport'; -import {useGetAuthorMostViewedArticles} from '../hooks/useGetMostViewedArticle'; -import {useGetTotalLikeViewStatus} from '../hooks/useGetTotalLikeViewStatus'; -import {useGetTotalReads} from '../hooks/useGetTotalReads'; -import {useGetTotalWrites} from '../hooks/useGetTotalWrites'; -import {useGetAuthorYearlyReadReport} from '../hooks/useGetYearlyReadReport'; -import {useGetAuthorYearlyWriteReport} from '../hooks/useGetYearlyWriteReport'; -import StatisticsCard from './StatisticsCard'; - -const getArticleImageSource = (image?: string): ImageSourcePropType => { - if (!image) { - return require('../../assets/images/article_default.jpg'); - } - - return { - uri: image.startsWith('http') ? image : `${GET_IMAGE}/${image}`, - }; -}; - -const getArticleAuthorId = (authorId: ArticleData['authorId']): string => { - return typeof authorId === 'string' ? authorId : authorId?._id ?? ''; -}; - -type LineDataItem = { - label: string; - value: number; -}; - -interface Props { - onArticleViewed: ({ - articleId, - authorId, - recordId, - }: { - articleId: number; - authorId: string; - recordId: string; - }) => void; - userId?: string; - others: boolean; - articlePosted: number; - user_handle?: string; -} -const ActivityOverview = ({ - onArticleViewed, - userId, - others, - user_handle, -}: Props) => { - const [userState, setUserState] = useState(0); - const {user_token, user_id} = useSelector((state: any) => state.user); - const [, setIsFocus] = useState(false); - // const [selectedDay, setSelectedDay] = useState(new Date().getDay()); - const {isConnected} = useSelector((state: any) => state.network); - const [selectedMonth, setSelectedMonth] = useState( - new Date().getMonth(), - ); - const [selectedYear, setSelectedYear] = useState(-1); - - const monthlyDrops = [ - {label: 'Monthly', value: -1}, - {label: 'January', value: 0}, - {label: 'February', value: 1}, - {label: 'March', value: 2}, - {label: 'April', value: 3}, - {label: 'May', value: 4}, - {label: 'June', value: 5}, - {label: 'July', value: 6}, - {label: 'August', value: 7}, - {label: 'September', value: 8}, - {label: 'October', value: 9}, - {label: 'November', value: 10}, - {label: 'December', value: 11}, - ]; - - const yearlyDrops = [ - {label: 'Yearly', value: -1}, - {label: '2024', value: 2024}, - {label: '2025', value: 2025}, - {label: '2026', value: 2026}, - ]; - - // GET MONTHLY READ REPORT - const {data: monthlyReadReport, refetch: refetchMonthReadReport} = - useGetAuthorMonthlyReadReport({ - user_id: user_id, - selectedMonth: selectedMonth, - userId: userId, - others: others, - isConnected: isConnected, - }); - - const {data: monthlyWriteReport, refetch: refetchMonthWriteReport} = - useGetAuthorMonthlyWriteReport({ - user_id: user_id, - selectedMonth: selectedMonth, - userId: userId, - others: others, - isConnected: isConnected, - }); - - // GET YEARLY READ REPORT - const {data: yearlyReadReport, refetch: refetchYearlyReadReport} = - useGetAuthorYearlyReadReport({ - user_id, - userId, - selectedYear, - others, - isConnected, - }); - - // GET YEARLY WRITE REPORT - - const {data: yearlyWriteReport, refetch: refetchYearlyWriteReport} = - useGetAuthorYearlyWriteReport({ - user_id, - selectedYear, - userId, - others, - isConnected, - }); - - // GET MOST VIEWED ARTICLE - const {data: article, isLoading: isArticleLoading} = - useGetAuthorMostViewedArticles({ - userId: userId, - others: others, - isConnected: isConnected, - }); - const mostViewedArticles = article ?? []; - const hasMostViewedArticles = mostViewedArticles.length > 0; - - // GET USER STATUS FOR LIKE AND VIEW COUNT - - const {data: likeViewStatData, isLoading: likeViewStatDataLoading, refetch: likeViewStatusRefetch} = useGetTotalLikeViewStatus({ - user_id: user_id, - userId: userId, - others: others, - isConnected: isConnected, - }); - - - - - // GET TOTAL READ STATUS - - const {data: readStatData, isLoading: readStatDataLoading} = useGetTotalReads({ - user_id: user_id, - userId: userId, - others: others, - isConnected: isConnected, - }); - - // GET TOTAL WRITE STATUS - - const {data: writeStatData, isLoading: writeStatDataLoading} = useGetTotalWrites({ - user_id: user_id, - userId: userId, - others: others, - isConnected: isConnected, - }); - - useFocusEffect( - useCallback(() => { - if (userState === 0) { - refetchMonthReadReport(); - } else { - refetchMonthWriteReport(); - } - likeViewStatusRefetch(); - }, [userState, likeViewStatusRefetch, refetchMonthReadReport, refetchMonthWriteReport]), - ); - useEffect(() => { - if (userState === 0) { - refetchMonthReadReport(); - } else { - refetchMonthWriteReport(); - } - }, [ - refetchMonthReadReport, - refetchMonthWriteReport, - selectedMonth, - userState, - ]); - - useEffect(() => { - // This will run when selectedMonth changes - if (userState === 0) { - refetchYearlyReadReport(); - } else { - refetchYearlyWriteReport(); - } - }, [ - refetchYearlyReadReport, - refetchYearlyWriteReport, - selectedYear, - userState, - ]); - - if ( - isArticleLoading || - likeViewStatDataLoading || - writeStatDataLoading || - readStatDataLoading - ) { - return ( - - - - ); - } - - const getTrendMessage = () => { - const monthNames = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - - // If month selected - if (selectedMonth !== -1) { - const monthName = monthNames[selectedMonth]; - const year = getCurrentYear(); // current year - return `${user_handle}'s ${ - userState === 0 ? 'Reading' : 'Writing' - } activity for ${monthName} ${year}`; - } - - // If year selected - if (selectedYear !== -1) { - return `${user_handle}'s ${ - userState === 0 ? 'Reading' : 'Writing' - } activity for the year ${selectedYear}`; - } - - return ''; - }; - - const screenWidth = Dimensions.get('window').width; - - const dayToWeekData = (data: MonthStatus[]): LineDataItem[] => { - if (!Array.isArray(data) || data.length === 0) return []; - - // Sort (safe guard) - const sorted = [...data].sort( - (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), - ); - - const weeks: LineDataItem[] = []; - let currentWeek: MonthStatus[] = []; - let weekIndex = 1; - - const getWeekDay = (date: string) => new Date(date).getDay(); // 0 = Sun - - sorted.forEach((item, index) => { - currentWeek.push(item); - - const isSaturday = getWeekDay(item.date) === 6; - const isLastDay = index === sorted.length - 1; - - if (isSaturday || isLastDay) { - const weekSum = currentWeek.reduce( - (sum, d) => sum + Number(d.value || 0), - 0, - ); - - weeks.push({ - label: `W${weekIndex}`, - value: weekSum, - }); - - weekIndex++; - currentWeek = []; - } - }); - - return weeks; - }; - - const MONTH_LABELS = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - - const groupYearlyData = (data: YearStatus[]) => { - const map = new Map(); - - data.forEach(item => { - const monthNumber = Number(item.month.split('-')[1]); // "01" -> 1 - map.set(monthNumber, item.value || 0); - }); - - const normalized = MONTH_LABELS.map((label, index) => ({ - label, - value: map.get(index + 1) ?? 0, - })); - - return [ - normalized.slice(0, 3), // Q1 - normalized.slice(3, 6), // Q2 - normalized.slice(6, 9), // Q3 - normalized.slice(9, 12), // Q4 - ]; - }; - - const YearlyChartSection = () => { - const yearlyData = - userState === 0 ? (yearlyReadReport ?? []) : (yearlyWriteReport ?? []); - console.log('Raw yearly Data:', yearlyData); - const groupedData = groupYearlyData(yearlyData); - console.log('Grouped yearly Data:', groupedData); - - const CHART_HORIZONTAL_PADDING = 32; - const chartWidth = screenWidth - CHART_HORIZONTAL_PADDING - 16; - - const QUARTER_LABELS = ['Jan – Mar', 'Apr – Jun', 'Jul – Sep', 'Oct – Dec']; - - return ( - - - - {getTrendMessage()} - - {groupedData.map((group, index) => { - const isLast = index === groupedData.length - 1; - - return ( - // Fix: use stable key instead of array index - - {/* Quarter Title */} - - {QUARTER_LABELS[index]} - - - {/* Chart */} - - Number(y).toFixed(1), - color: () => PRIMARY_COLOR, - labelColor: () => '#9CA3AF', - barPercentage: 0.5, - propsForBackgroundLines: {strokeWidth: 0}, - }} - data={{ - labels: group.map(i => i.label), - datasets: [{data: group.map(i => i.value)}], - }} - /> - - - {/* Separator only between groups */} - {!isLast && } - - ); - })} - - - ); - }; - - const WeeklyChartSection = () => { - const rawMonthlyData = - userState === 0 ? (monthlyReadReport ?? []) : (monthlyWriteReport ?? []); - - console.log('Raw Monthly Data:', rawMonthlyData); - - const weeklyData = dayToWeekData(rawMonthlyData); - - console.log('Weekly Data:', weeklyData); - - const labels = weeklyData.map(w => w.label); - const values = weeklyData.map(w => w.value); - - return ( - - - {getTrendMessage()} - - - Number(y).toFixed(1), - color: () => PRIMARY_COLOR, - labelColor: () => '#9CA3AF', - barPercentage: 0.45, - propsForBackgroundLines: {strokeWidth: 0}, - }} - /> - - ); - }; - - const BarChartSection = () => { - return ( - - - {userState === 0 ? 'Reading Trend' : 'Writing Trend'} - - {selectedMonth !== -1 ? : } - - ); - }; - - return ( - - - - - {/* Statistics */} - - - - - - - - {/* Toggle */} - - - - - {/* READ */} - setUserState(0)}> - - Read - - - - {/* WRITE */} - setUserState(1)}> - - Write - - - - - - {/* ===== DROPDOWNS ===== */} - - - {/* MONTH */} - - { - setSelectedMonth(item.value); - setSelectedYear(-1); - - if (userState === 0) { - refetchMonthReadReport(); - } else { - refetchMonthWriteReport(); - } - }} - placeholder={'Monthly'} - /> - - - {/* YEAR */} - - { - setSelectedYear(item.value); - setSelectedMonth(-1); - }} - placeholder={'Yearly'} - /> - - - - - - {/* ===== CHART ===== */} - {(selectedMonth !== -1 || selectedYear !== -1) && ( - - - - )} - - {/* ===== MOST VIEWED ===== */} - {others && ( - - - Most Viewed Articles - - - {!hasMostViewedArticles && ( - - No most viewed articles available yet. - - )} - - {mostViewedArticles.map((item: ArticleData) => ( - // Fix: use stable identifier instead of array index - - onArticleViewed({ - articleId: Number(item._id), - authorId: getArticleAuthorId(item.authorId), - recordId: item.pb_recordId, - }) - }> - - - - - - {item.tags?.map(t => t.name).join(' | ')} - - - - {item?.title} - - - - {item?.viewUsers?.length ?? 0} views - - - - Updated: {formatDateShortYear(item.lastUpdated)} - - - - - ))} - - )} - - -); -}; - -const styles = StyleSheet.create({ - rowContainer: { - width: '100%', - flexDirection: 'row', - padding: 2, - justifyContent: 'space-between', - alignItems: 'center', - }, - - colContainer: { - width: '100%', - flexDirection: 'column', - padding: 1, - justifyContent: 'space-between', - alignItems: 'center', - }, - - box: { - width: '95%', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - margin: 4, - padding: 3, - borderColor: 'black', - borderRadius: 8, - }, - - button: { - flex: 1, - height: hp(6), - padding: 8, - borderRadius: 8, - margin: 2, - marginTop: 4, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#c1c1c1', - }, - - dropdown: { - flex: 1, - height: 40, - borderColor: '#c1c1c1', - borderWidth: 0.4, - borderRadius: 5, - paddingHorizontal: 10, - marginVertical: 3, - }, - - cardContainer: { - width: '100%', - maxHeight: 150, - backgroundColor: '#E6E6E6', - flexDirection: 'row', - marginVertical: 14, - overflow: 'hidden', - elevation: 4, - borderRadius: 12, - }, - - image: { - flex: 0.6, - resizeMode: 'cover', - }, - - textContainer: { - flex: 0.9, - backgroundColor: 'white', - paddingHorizontal: 10, - paddingVertical: 13, - }, - - title: { - fontSize: fp(4.5), - fontWeight: 'bold', - color: '#121a26', - marginBottom: 4, - fontFamily: 'Lobster-Regular', - }, - - footerText: { - fontSize: fp(3.3), - fontWeight: '600', - color: '#121a26', - marginBottom: 3, - }, -}); - -export default ActivityOverview; +import {StyleSheet, Dimensions, ImageSourcePropType} from 'react-native'; +import {useCallback, useEffect, useState} from 'react'; + +import { + YStack, + XStack, + Text, + Card, + ScrollView, + Image, + View, + Separator, +} from 'tamagui'; + +import {PRIMARY_COLOR} from '../helper/Theme'; +//import {LineChart} from 'react-native-gifted-charts'; +import {BarChart} from 'react-native-chart-kit'; +import { getCurrentYear, formatDateShortYear } from '../helper/dateUtils'; +import {fp, hp} from '../helper/Metric'; +import {useSelector} from 'react-redux'; +import {GET_IMAGE} from '../helper/APIUtils'; +import {ArticleData, MonthStatus, YearStatus} from '../type'; +import Loader from './Loader'; + +import {useFocusEffect} from '@react-navigation/native'; +import {Dropdown} from 'react-native-element-dropdown'; +import {useGetAuthorMonthlyReadReport} from '../hooks/useGetMonthlyReadReport'; +import {useGetAuthorMonthlyWriteReport} from '../hooks/useGetMonthlyWriteReport'; +import {useGetAuthorMostViewedArticles} from '../hooks/useGetMostViewedArticle'; +import {useGetTotalLikeViewStatus} from '../hooks/useGetTotalLikeViewStatus'; +import {useGetTotalReads} from '../hooks/useGetTotalReads'; +import {useGetTotalWrites} from '../hooks/useGetTotalWrites'; +import {useGetAuthorYearlyReadReport} from '../hooks/useGetYearlyReadReport'; +import {useGetAuthorYearlyWriteReport} from '../hooks/useGetYearlyWriteReport'; +import StatisticsCard from './StatisticsCard'; import { rf } from '../helper/Metric'; + + +const getArticleImageSource = (image?: string): ImageSourcePropType => { + if (!image) { + return require('../../assets/images/article_default.jpg'); + } + + return { + uri: image.startsWith('http') ? image : `${GET_IMAGE}/${image}`, + }; +}; + +const getArticleAuthorId = (authorId: ArticleData['authorId']): string => { + return typeof authorId === 'string' ? authorId : authorId?._id ?? ''; +}; + +type LineDataItem = { + label: string; + value: number; +}; + +interface Props { + onArticleViewed: ({ + articleId, + authorId, + recordId, + }: { + articleId: number; + authorId: string; + recordId: string; + }) => void; + userId?: string; + others: boolean; + articlePosted: number; + user_handle?: string; +} +const ActivityOverview = ({ + onArticleViewed, + userId, + others, + user_handle, +}: Props) => { + const [userState, setUserState] = useState(0); + const {user_token, user_id} = useSelector((state: any) => state.user); + const [, setIsFocus] = useState(false); + // const [selectedDay, setSelectedDay] = useState(new Date().getDay()); + const {isConnected} = useSelector((state: any) => state.network); + const [selectedMonth, setSelectedMonth] = useState( + new Date().getMonth(), + ); + const [selectedYear, setSelectedYear] = useState(-1); + + const monthlyDrops = [ + {label: 'Monthly', value: -1}, + {label: 'January', value: 0}, + {label: 'February', value: 1}, + {label: 'March', value: 2}, + {label: 'April', value: 3}, + {label: 'May', value: 4}, + {label: 'June', value: 5}, + {label: 'July', value: 6}, + {label: 'August', value: 7}, + {label: 'September', value: 8}, + {label: 'October', value: 9}, + {label: 'November', value: 10}, + {label: 'December', value: 11}, + ]; + + const yearlyDrops = [ + {label: 'Yearly', value: -1}, + {label: '2024', value: 2024}, + {label: '2025', value: 2025}, + {label: '2026', value: 2026}, + ]; + + // GET MONTHLY READ REPORT + const {data: monthlyReadReport, refetch: refetchMonthReadReport} = + useGetAuthorMonthlyReadReport({ + user_id: user_id, + selectedMonth: selectedMonth, + userId: userId, + others: others, + isConnected: isConnected, + }); + + const {data: monthlyWriteReport, refetch: refetchMonthWriteReport} = + useGetAuthorMonthlyWriteReport({ + user_id: user_id, + selectedMonth: selectedMonth, + userId: userId, + others: others, + isConnected: isConnected, + }); + + // GET YEARLY READ REPORT + const {data: yearlyReadReport, refetch: refetchYearlyReadReport} = + useGetAuthorYearlyReadReport({ + user_id, + userId, + selectedYear, + others, + isConnected, + }); + + // GET YEARLY WRITE REPORT + + const {data: yearlyWriteReport, refetch: refetchYearlyWriteReport} = + useGetAuthorYearlyWriteReport({ + user_id, + selectedYear, + userId, + others, + isConnected, + }); + + // GET MOST VIEWED ARTICLE + const {data: article, isLoading: isArticleLoading} = + useGetAuthorMostViewedArticles({ + userId: userId, + others: others, + isConnected: isConnected, + }); + const mostViewedArticles = article ?? []; + const hasMostViewedArticles = mostViewedArticles.length > 0; + + // GET USER STATUS FOR LIKE AND VIEW COUNT + + const {data: likeViewStatData, isLoading: likeViewStatDataLoading, refetch: likeViewStatusRefetch} = useGetTotalLikeViewStatus({ + user_id: user_id, + userId: userId, + others: others, + isConnected: isConnected, + }); + + + + + // GET TOTAL READ STATUS + + const {data: readStatData, isLoading: readStatDataLoading} = useGetTotalReads({ + user_id: user_id, + userId: userId, + others: others, + isConnected: isConnected, + }); + + // GET TOTAL WRITE STATUS + + const {data: writeStatData, isLoading: writeStatDataLoading} = useGetTotalWrites({ + user_id: user_id, + userId: userId, + others: others, + isConnected: isConnected, + }); + + useFocusEffect( + useCallback(() => { + if (userState === 0) { + refetchMonthReadReport(); + } else { + refetchMonthWriteReport(); + } + likeViewStatusRefetch(); + }, [userState, likeViewStatusRefetch, refetchMonthReadReport, refetchMonthWriteReport]), + ); + useEffect(() => { + if (userState === 0) { + refetchMonthReadReport(); + } else { + refetchMonthWriteReport(); + } + }, [ + refetchMonthReadReport, + refetchMonthWriteReport, + selectedMonth, + userState, + ]); + + useEffect(() => { + // This will run when selectedMonth changes + if (userState === 0) { + refetchYearlyReadReport(); + } else { + refetchYearlyWriteReport(); + } + }, [ + refetchYearlyReadReport, + refetchYearlyWriteReport, + selectedYear, + userState, + ]); + + if ( + isArticleLoading || + likeViewStatDataLoading || + writeStatDataLoading || + readStatDataLoading + ) { + return ( + + + + ); + } + + const getTrendMessage = () => { + const monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + // If month selected + if (selectedMonth !== -1) { + const monthName = monthNames[selectedMonth]; + const year = getCurrentYear(); // current year + return `${user_handle}'s ${ + userState === 0 ? 'Reading' : 'Writing' + } activity for ${monthName} ${year}`; + } + + // If year selected + if (selectedYear !== -1) { + return `${user_handle}'s ${ + userState === 0 ? 'Reading' : 'Writing' + } activity for the year ${selectedYear}`; + } + + return ''; + }; + + const screenWidth = Dimensions.get('window').width; + + const dayToWeekData = (data: MonthStatus[]): LineDataItem[] => { + if (!Array.isArray(data) || data.length === 0) return []; + + // Sort (safe guard) + const sorted = [...data].sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ); + + const weeks: LineDataItem[] = []; + let currentWeek: MonthStatus[] = []; + let weekIndex = 1; + + const getWeekDay = (date: string) => new Date(date).getDay(); // 0 = Sun + + sorted.forEach((item, index) => { + currentWeek.push(item); + + const isSaturday = getWeekDay(item.date) === 6; + const isLastDay = index === sorted.length - 1; + + if (isSaturday || isLastDay) { + const weekSum = currentWeek.reduce( + (sum, d) => sum + Number(d.value || 0), + 0, + ); + + weeks.push({ + label: `W${weekIndex}`, + value: weekSum, + }); + + weekIndex++; + currentWeek = []; + } + }); + + return weeks; + }; + + const MONTH_LABELS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + const groupYearlyData = (data: YearStatus[]) => { + const map = new Map(); + + data.forEach(item => { + const monthNumber = Number(item.month.split('-')[1]); // "01" -> 1 + map.set(monthNumber, item.value || 0); + }); + + const normalized = MONTH_LABELS.map((label, index) => ({ + label, + value: map.get(index + 1) ?? 0, + })); + + return [ + normalized.slice(0, 3), // Q1 + normalized.slice(3, 6), // Q2 + normalized.slice(6, 9), // Q3 + normalized.slice(9, 12), // Q4 + ]; + }; + + const YearlyChartSection = () => { + const yearlyData = + userState === 0 ? (yearlyReadReport ?? []) : (yearlyWriteReport ?? []); + console.log('Raw yearly Data:', yearlyData); + const groupedData = groupYearlyData(yearlyData); + console.log('Grouped yearly Data:', groupedData); + + const CHART_HORIZONTAL_PADDING = 32; + const chartWidth = screenWidth - CHART_HORIZONTAL_PADDING - 16; + + const QUARTER_LABELS = ['Jan – Mar', 'Apr – Jun', 'Jul – Sep', 'Oct – Dec']; + + return ( + + + + {getTrendMessage()} + + {groupedData.map((group, index) => { + const isLast = index === groupedData.length - 1; + + return ( + // Fix: use stable key instead of array index + + {/* Quarter Title */} + + {QUARTER_LABELS[index]} + + + {/* Chart */} + + Number(y).toFixed(1), + color: () => PRIMARY_COLOR, + labelColor: () => '#9CA3AF', + barPercentage: 0.5, + propsForBackgroundLines: {strokeWidth: 0}, + }} + data={{ + labels: group.map(i => i.label), + datasets: [{data: group.map(i => i.value)}], + }} + /> + + + {/* Separator only between groups */} + {!isLast && } + + ); + })} + + + ); + }; + + const WeeklyChartSection = () => { + const rawMonthlyData = + userState === 0 ? (monthlyReadReport ?? []) : (monthlyWriteReport ?? []); + + console.log('Raw Monthly Data:', rawMonthlyData); + + const weeklyData = dayToWeekData(rawMonthlyData); + + console.log('Weekly Data:', weeklyData); + + const labels = weeklyData.map(w => w.label); + const values = weeklyData.map(w => w.value); + + return ( + + + {getTrendMessage()} + + + Number(y).toFixed(1), + color: () => PRIMARY_COLOR, + labelColor: () => '#9CA3AF', + barPercentage: 0.45, + propsForBackgroundLines: {strokeWidth: 0}, + }} + /> + + ); + }; + + const BarChartSection = () => { + return ( + + + {userState === 0 ? 'Reading Trend' : 'Writing Trend'} + + {selectedMonth !== -1 ? : } + + ); + }; + + return ( + + + + + {/* Statistics */} + + + + + + + + {/* Toggle */} + + + + + {/* READ */} + setUserState(0)}> + + Read + + + + {/* WRITE */} + setUserState(1)}> + + Write + + + + + + {/* ===== DROPDOWNS ===== */} + + + {/* MONTH */} + + { + setSelectedMonth(item.value); + setSelectedYear(-1); + + if (userState === 0) { + refetchMonthReadReport(); + } else { + refetchMonthWriteReport(); + } + }} + placeholder={'Monthly'} + /> + + + {/* YEAR */} + + { + setSelectedYear(item.value); + setSelectedMonth(-1); + }} + placeholder={'Yearly'} + /> + + + + + + {/* ===== CHART ===== */} + {(selectedMonth !== -1 || selectedYear !== -1) && ( + + + + )} + + {/* ===== MOST VIEWED ===== */} + {others && ( + + + Most Viewed Articles + + + {!hasMostViewedArticles && ( + + No most viewed articles available yet. + + )} + + {mostViewedArticles.map((item: ArticleData) => ( + // Fix: use stable identifier instead of array index + + onArticleViewed({ + articleId: Number(item._id), + authorId: getArticleAuthorId(item.authorId), + recordId: item.pb_recordId, + }) + }> + + + + + + {item.tags?.map(t => t.name).join(' | ')} + + + + {item?.title} + + + + {item?.viewUsers?.length ?? 0} views + + + + Updated: {formatDateShortYear(item.lastUpdated)} + + + + + ))} + + )} + + +); +}; + +const styles = StyleSheet.create({ + rowContainer: { + width: '100%', + flexDirection: 'row', + padding: 2, + justifyContent: 'space-between', + alignItems: 'center', + }, + + colContainer: { + width: '100%', + flexDirection: 'column', + padding: 1, + justifyContent: 'space-between', + alignItems: 'center', + }, + + box: { + width: '95%', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + margin: 4, + padding: 3, + borderColor: 'black', + borderRadius: 8, + }, + + button: { + flex: 1, + height: hp(6), + padding: 8, + borderRadius: 8, + margin: 2, + marginTop: 4, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#c1c1c1', + }, + + dropdown: { + flex: 1, + height: 40, + borderColor: '#c1c1c1', + borderWidth: 0.4, + borderRadius: 5, + paddingHorizontal: 10, + marginVertical: 3, + }, + + cardContainer: { + width: '100%', + maxHeight: 150, + backgroundColor: '#E6E6E6', + flexDirection: 'row', + marginVertical: 14, + overflow: 'hidden', + elevation: 4, + borderRadius: 12, + }, + + image: { + flex: 0.6, + resizeMode: 'cover', + }, + + textContainer: { + flex: 0.9, + backgroundColor: 'white', + paddingHorizontal: 10, + paddingVertical: 13, + }, + + title: { + fontSize: fp(4.5), + fontWeight: 'bold', + color: '#121a26', + marginBottom: 4, + fontFamily: 'Lobster-Regular', + }, + + footerText: { + fontSize: fp(3.3), + fontWeight: '600', + color: '#121a26', + marginBottom: 3, + }, +}); + +export default ActivityOverview; diff --git a/frontend/src/components/AddSpecializationModal.tsx b/frontend/src/components/AddSpecializationModal.tsx index 69a18a21..bf2055b0 100644 --- a/frontend/src/components/AddSpecializationModal.tsx +++ b/frontend/src/components/AddSpecializationModal.tsx @@ -1,149 +1,150 @@ -import React from 'react'; -import { - Dimensions, - Modal, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; -import {PRIMARY_COLOR} from '../helper/Theme'; +import React from 'react'; +import { + Dimensions, + Modal, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import {PRIMARY_COLOR} from '../helper/Theme'; import { rf } from '../helper/Metric'; -interface AddSpecializationModalProps { - isModalVisible: boolean; - handleCloseModal: () => void; - handleAddSpecialization: (specialization: string) => void; - setspecialization: (text: string) => void; - specialization: string; -} - -const AddSpecializationModal = ({ - isModalVisible, - handleCloseModal, - handleAddSpecialization, - setspecialization, - specialization, -}: AddSpecializationModalProps) => { - return ( - - - - - Type of specialization - setspecialization(text)} - style={styles.inputControl} - /> - - - - Cancel - - { - handleAddSpecialization(specialization); - }} - style={styles.addBtn}> - Add - - - - - ); -}; - -export default AddSpecializationModal; - -const styles = StyleSheet.create({ - modal: { - position: 'relative', - width: '100%', - height: '100%', - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - overlay: { - position: 'absolute', - height: '100%', - width: '100%', - backgroundColor: 'rgba(0,0,0,0.3)', - }, - modalContent: { - flex: 1, - width: '85%', - alignSelf: 'center', - backgroundColor: 'white', - borderRadius: 10, - position: 'absolute', - top: Dimensions.get('window').height / 2.5, - padding: 10, - paddingHorizontal: 16, - }, - input: { - marginTop: 16, - }, - inputLabel: { - fontSize: 16, - fontWeight: '600', - color: '#222', - marginBottom: 8, - }, - inputControl: { - height: 44, - backgroundColor: '#f1f5f9', - paddingHorizontal: 16, - borderRadius: 12, - fontSize: 15, - fontWeight: '500', - color: '#222', - }, - formAction: { - marginTop: 24, - flexDirection: 'row', - gap: 10, - justifyContent: 'flex-end', - }, - cancelBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 8, - paddingVertical: 8, - paddingHorizontal: 16, - borderWidth: 1, - borderColor: PRIMARY_COLOR, - }, - cancelText: { - fontSize: 17, - lineHeight: 24, - color: PRIMARY_COLOR, - }, - addBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 8, - paddingVertical: 8, - paddingHorizontal: 16, - borderWidth: 1, - backgroundColor: PRIMARY_COLOR, - borderColor: PRIMARY_COLOR, - }, - addText: { - fontSize: 17, - lineHeight: 24, - color: 'white', - }, -}); + +interface AddSpecializationModalProps { + isModalVisible: boolean; + handleCloseModal: () => void; + handleAddSpecialization: (specialization: string) => void; + setspecialization: (text: string) => void; + specialization: string; +} + +const AddSpecializationModal = ({ + isModalVisible, + handleCloseModal, + handleAddSpecialization, + setspecialization, + specialization, +}: AddSpecializationModalProps) => { + return ( + + + + + Type of specialization + setspecialization(text)} + style={styles.inputControl} + /> + + + + Cancel + + { + handleAddSpecialization(specialization); + }} + style={styles.addBtn}> + Add + + + + + ); +}; + +export default AddSpecializationModal; + +const styles = StyleSheet.create({ + modal: { + position: 'relative', + width: '100%', + height: '100%', + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + overlay: { + position: 'absolute', + height: '100%', + width: '100%', + backgroundColor: 'rgba(0,0,0,0.3)', + }, + modalContent: { + flex: 1, + width: '85%', + alignSelf: 'center', + backgroundColor: 'white', + borderRadius: 10, + position: 'absolute', + top: Dimensions.get('window').height / 2.5, + padding: 10, + paddingHorizontal: 16, + }, + input: { + marginTop: 16, + }, + inputLabel: { + fontSize: rf(16), + fontWeight: '600', + color: '#222', + marginBottom: 8, + }, + inputControl: { + height: 44, + backgroundColor: '#f1f5f9', + paddingHorizontal: 16, + borderRadius: 12, + fontSize: rf(15), + fontWeight: '500', + color: '#222', + }, + formAction: { + marginTop: 24, + flexDirection: 'row', + gap: 10, + justifyContent: 'flex-end', + }, + cancelBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + paddingVertical: 8, + paddingHorizontal: 16, + borderWidth: 1, + borderColor: PRIMARY_COLOR, + }, + cancelText: { + fontSize: rf(17), + lineHeight: 24, + color: PRIMARY_COLOR, + }, + addBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + paddingVertical: 8, + paddingHorizontal: 16, + borderWidth: 1, + backgroundColor: PRIMARY_COLOR, + borderColor: PRIMARY_COLOR, + }, + addText: { + fontSize: rf(17), + lineHeight: 24, + color: 'white', + }, +}); diff --git a/frontend/src/components/AnimatedMenu.tsx b/frontend/src/components/AnimatedMenu.tsx index 515359bf..b2345bf0 100644 --- a/frontend/src/components/AnimatedMenu.tsx +++ b/frontend/src/components/AnimatedMenu.tsx @@ -1,91 +1,92 @@ -import { AntDesign } from '@expo/vector-icons'; -import React from 'react'; -import {View, Text, StyleSheet} from 'react-native'; -import AccessibleTouchable from './common/AccessibleTouchable'; +import { AntDesign } from '@expo/vector-icons'; +import React from 'react'; +import {View, Text, StyleSheet} from 'react-native'; +import AccessibleTouchable from './common/AccessibleTouchable'; import { rf } from '../helper/Metric'; -type AntDesignIconName = React.ComponentProps['name']; - - -interface ArticleFloatingMenuProp { - items: Item[]; - top: number; - left: number; - isVisible: boolean; -} - -interface Item { - name: string; - action: () => void; - /** Must be a valid AntDesign icon name. */ - icon: AntDesignIconName; -} - -export default function ArticleFloatingMenu(props: ArticleFloatingMenuProp) { - - if(!props.isVisible) return null; - return ( - - - {props.items.map((item, index) => ( - - - {item.name} - - ))} - - ); -} - -const styles = StyleSheet.create({ - container: { - position: 'absolute', - width: '90%', - flexDirection: 'column', - backgroundColor: 'white', - borderRadius: 8, - padding: 6, - elevation: 5, - shadowColor: 'black', - shadowOpacity: 0.1, - shadowRadius: 4, - shadowOffset: {width: 0, height: 2}, - }, - arrow: { - position: 'absolute', - top: -10, - left: '30%', - marginLeft: -10, - width: 0, - height: 0, - borderLeftWidth: 10, - borderRightWidth: 10, - borderBottomWidth: 10, - borderLeftColor: 'transparent', - borderRightColor: 'transparent', - borderBottomColor: 'white', - }, - box: { - flexDirection: 'row', - justifyContent: 'flex-start', - alignItems: 'center', - //borderWidth: 0.5, - borderRadius: 4, - borderColor: '#c1c1c1', - paddingVertical: 8, - paddingHorizontal: 12, - marginBottom: 8, - }, - text: { - fontSize: 16, - color: 'black', - fontWeight: '500', - marginLeft: 10, - }, + +type AntDesignIconName = React.ComponentProps['name']; + + +interface ArticleFloatingMenuProp { + items: Item[]; + top: number; + left: number; + isVisible: boolean; +} + +interface Item { + name: string; + action: () => void; + /** Must be a valid AntDesign icon name. */ + icon: AntDesignIconName; +} + +export default function ArticleFloatingMenu(props: ArticleFloatingMenuProp) { + + if(!props.isVisible) return null; + return ( + + + {props.items.map((item, index) => ( + + + {item.name} + + ))} + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + width: '90%', + flexDirection: 'column', + backgroundColor: 'white', + borderRadius: 8, + padding: 6, + elevation: 5, + shadowColor: 'black', + shadowOpacity: 0.1, + shadowRadius: 4, + shadowOffset: {width: 0, height: 2}, + }, + arrow: { + position: 'absolute', + top: -10, + left: '30%', + marginLeft: -10, + width: 0, + height: 0, + borderLeftWidth: 10, + borderRightWidth: 10, + borderBottomWidth: 10, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + borderBottomColor: 'white', + }, + box: { + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + //borderWidth: 0.5, + borderRadius: 4, + borderColor: '#c1c1c1', + paddingVertical: 8, + paddingHorizontal: 12, + marginBottom: 8, + }, + text: { + fontSize: rf(16), + color: 'black', + fontWeight: '500', + marginLeft: 10, + }, }); \ No newline at end of file diff --git a/frontend/src/components/ArticleFloatingMenu.tsx b/frontend/src/components/ArticleFloatingMenu.tsx index c3b5e683..a3c1ff58 100644 --- a/frontend/src/components/ArticleFloatingMenu.tsx +++ b/frontend/src/components/ArticleFloatingMenu.tsx @@ -1,74 +1,75 @@ -import React from 'react'; -import {YStack, XStack, Text, Button} from 'tamagui'; -import {Sheet} from '@tamagui/sheet'; -import AntDesign from '@expo/vector-icons/AntDesign'; -import {ScrollView} from 'react-native'; +import React from 'react'; +import {YStack, XStack, Text, Button} from 'tamagui'; +import {Sheet} from '@tamagui/sheet'; +import AntDesign from '@expo/vector-icons/AntDesign'; +import {ScrollView} from 'react-native'; import { rf } from '../helper/Metric'; -interface ArticleFloatingMenuProp { - visible: boolean; - items: Item[]; - onDismiss: () => void; -} - -interface Item { - name: string; - articleId: string; - action: () => void; - icon: string; -} - -export default function ArticleFloatingMenuSheet({ - visible, - items, - onDismiss, -}: ArticleFloatingMenuProp) { - // Drive Sheet open state directly from the prop — single source of truth. - // No internal copy needed; parent (ArticleCard) owns the lifecycle. - const handleOpenChange = (isOpen: boolean) => { - if (!isOpen) onDismiss(); - }; - - return ( - - - - - - {items.map((item, index) => ( - - ))} - - - - - ); -} + +interface ArticleFloatingMenuProp { + visible: boolean; + items: Item[]; + onDismiss: () => void; +} + +interface Item { + name: string; + articleId: string; + action: () => void; + icon: string; +} + +export default function ArticleFloatingMenuSheet({ + visible, + items, + onDismiss, +}: ArticleFloatingMenuProp) { + // Drive Sheet open state directly from the prop — single source of truth. + // No internal copy needed; parent (ArticleCard) owns the lifecycle. + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) onDismiss(); + }; + + return ( + + + + + + {items.map((item, index) => ( + + ))} + + + + + ); +} diff --git a/frontend/src/components/BenefitsModal.tsx b/frontend/src/components/BenefitsModal.tsx index 243481ac..fc402ac9 100644 --- a/frontend/src/components/BenefitsModal.tsx +++ b/frontend/src/components/BenefitsModal.tsx @@ -1,163 +1,164 @@ -import React, { useEffect, useState } from 'react'; -import { YStack, XStack, Text, Button, Circle, ScrollView as TamaguiScrollView, useTheme } from 'tamagui'; -import { Sheet } from '@tamagui/sheet'; -import Feather from '@expo/vector-icons/Feather'; +import React, { useEffect, useState } from 'react'; +import { YStack, XStack, Text, Button, Circle, ScrollView as TamaguiScrollView, useTheme } from 'tamagui'; +import { Sheet } from '@tamagui/sheet'; +import Feather from '@expo/vector-icons/Feather'; import { rf } from '../helper/Metric'; -interface BenefitsModalProps { - visible: boolean; - onDismiss: () => void; - onSignUp: () => void; -} - -const benefits = [ - { - title: 'Engage with Articles', - description: 'Post, Like & Comment on Articles', - icon: 'edit-3', - }, - { - title: 'Podcast Community', - description: 'Join Exclusive Podcast Discussions', - icon: 'headphones', - }, - { - title: 'Offline Reading', - description: 'Save Favorites for Offline Reading', - icon: 'bookmark', - }, - { - title: 'Achievements', - description: 'Earn Contribution Badges & Streaks', - icon: 'award', - }, -]; - -export default function BenefitsModal({ visible, onDismiss, onSignUp }: BenefitsModalProps) { - const [open, setOpen] = useState(visible); - const theme = useTheme(); - - // Use Tamagui theme values for vector icons - const primaryColor = theme.primary?.val || '#3b82f6'; - - useEffect(() => { - setOpen(visible); - }, [visible]); - - const handleOpenChange = (isOpen: boolean) => { - setOpen(isOpen); - if (!isOpen) { - onDismiss(); - } - }; - - return ( - - - - - - - - - - - - - Why Join Us? - - - - Create an account to unlock the full potential of your health journey and interact with our community. - - - - {benefits.map((item, index) => ( - - - - - - {item.title} - {item.description} - - - ))} - - - - - - - - - - ); -} + +interface BenefitsModalProps { + visible: boolean; + onDismiss: () => void; + onSignUp: () => void; +} + +const benefits = [ + { + title: 'Engage with Articles', + description: 'Post, Like & Comment on Articles', + icon: 'edit-3', + }, + { + title: 'Podcast Community', + description: 'Join Exclusive Podcast Discussions', + icon: 'headphones', + }, + { + title: 'Offline Reading', + description: 'Save Favorites for Offline Reading', + icon: 'bookmark', + }, + { + title: 'Achievements', + description: 'Earn Contribution Badges & Streaks', + icon: 'award', + }, +]; + +export default function BenefitsModal({ visible, onDismiss, onSignUp }: BenefitsModalProps) { + const [open, setOpen] = useState(visible); + const theme = useTheme(); + + // Use Tamagui theme values for vector icons + const primaryColor = theme.primary?.val || '#3b82f6'; + + useEffect(() => { + setOpen(visible); + }, [visible]); + + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + onDismiss(); + } + }; + + return ( + + + + + + + + + + + + + Why Join Us? + + + + Create an account to unlock the full potential of your health journey and interact with our community. + + + + {benefits.map((item, index) => ( + + + + + + {item.title} + {item.description} + + + ))} + + + + + + + + + + ); +} diff --git a/frontend/src/components/CategoriesFlatlistModal.tsx b/frontend/src/components/CategoriesFlatlistModal.tsx index 1be9c562..9028b70d 100644 --- a/frontend/src/components/CategoriesFlatlistModal.tsx +++ b/frontend/src/components/CategoriesFlatlistModal.tsx @@ -1,238 +1,239 @@ -import {StyleSheet, Text, View, TextInput} from 'react-native'; -import React, {useCallback, useMemo, useState} from 'react'; -import AccessibleTouchable from './common/AccessibleTouchable'; -import { - BottomSheetModal, - BottomSheetFlatList, - BottomSheetBackdrop, - BottomSheetBackdropProps, -} from '@gorhom/bottom-sheet'; -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import {HomeScreenCategoriesFlatlistProps, Category} from '../type'; +import {StyleSheet, Text, View, TextInput} from 'react-native'; +import React, {useCallback, useMemo, useState} from 'react'; +import AccessibleTouchable from './common/AccessibleTouchable'; +import { + BottomSheetModal, + BottomSheetFlatList, + BottomSheetBackdrop, + BottomSheetBackdropProps, +} from '@gorhom/bottom-sheet'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import {HomeScreenCategoriesFlatlistProps, Category} from '../type'; + +import {PRIMARY_COLOR} from '../helper/Theme'; +import {hp} from '../helper/Metric'; import { rf } from '../helper/Metric'; -import {PRIMARY_COLOR} from '../helper/Theme'; -import {hp} from '../helper/Metric'; - - -const CategoriesFlatlistModal = ({ - bottomSheetModalRef2, - categories, - handleCategorySelection, - selectCategoryList, -}: HomeScreenCategoriesFlatlistProps) => { - const [searchQuery, setSearchQuery] = useState(''); - - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - - // Define snap points for the bottom sheet - const snapPoints = useMemo(() => ['25%', '75%', '95%'], []); - - // Filter categories based on search - const filteredCategories = useMemo(() => { - if (!searchQuery.trim()) return categories; - return categories.filter(cat => - cat && cat.name && typeof cat.name === 'string' && - cat.name.toLowerCase().includes((searchQuery || '').toLowerCase()) - ); - }, [categories, searchQuery]); - - // Function to render each category item - const renderItem = useCallback( - ({item}: {item: Category}) => { - const isSelected = selectCategoryList.some(i => - (i.id !== undefined && item?.id !== undefined && i.id === item.id) || - (i._id !== undefined && item?._id !== undefined && i._id === item._id) || - (i.name === item?.name) - ); - return ( - { - handleCategorySelection(item); - }}> - - {item?.name} - - {isSelected && ( - - )} - - ); - }, - [handleCategorySelection, selectCategoryList], - ); - - // Function to close the bottom sheet modal - const handleDismissModalPress = useCallback(() => { - bottomSheetModalRef2.current?.close(); - }, [bottomSheetModalRef2]); - - return ( - - - - - - All Categories - - - - - - - {searchQuery.length > 0 && ( - setSearchQuery('')}> - - - )} - - - {filteredCategories.length === 0 ? ( - - - No categories found - Try a different search term - - ) : ( - item.id.toString()} - renderItem={renderItem} - contentContainerStyle={styles.contentContainer} - contentInsetAdjustmentBehavior={'always'} - extraData={selectCategoryList} - /> - )} - - ); -}; - -export default CategoriesFlatlistModal; - -// Styles for the component -const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - backgroundColor: 'white', - }, - headerButton: { - padding: 4, - }, - title: { - fontSize: 18, - fontWeight: '700', - color: '#111827', - flex: 1, - textAlign: 'center', - }, - searchContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#F9FAFB', - borderRadius: 12, - paddingHorizontal: 16, - marginHorizontal: 20, - marginVertical: 16, - borderWidth: 1, - borderColor: '#E5E7EB', - height: 48, - }, - searchIcon: { - marginRight: 8, - }, - searchInput: { - flex: 1, - fontSize: 15, - color: '#111827', - paddingVertical: 0, - }, - contentContainer: { - paddingTop: 8, - backgroundColor: 'white', - paddingBottom: 20, - paddingHorizontal: 20, - }, - item: { - borderWidth: 1.5, - borderRadius: 12, - paddingVertical: hp(1.4), - paddingHorizontal: 16, - marginBottom: 10, - borderColor: '#E5E7EB', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - backgroundColor: 'white', - }, - itemText: { - fontSize: 16, - fontWeight: '500', - color: '#374151', - }, - noResults: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingVertical: 60, - }, - noResultsText: { - fontSize: 18, - fontWeight: '600', - color: '#6B7280', - marginTop: 16, - }, - noResultsSubText: { - fontSize: 14, - color: '#9ca3af', - marginTop: 8, - }, -}); + + +const CategoriesFlatlistModal = ({ + bottomSheetModalRef2, + categories, + handleCategorySelection, + selectCategoryList, +}: HomeScreenCategoriesFlatlistProps) => { + const [searchQuery, setSearchQuery] = useState(''); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + // Define snap points for the bottom sheet + const snapPoints = useMemo(() => ['25%', '75%', '95%'], []); + + // Filter categories based on search + const filteredCategories = useMemo(() => { + if (!searchQuery.trim()) return categories; + return categories.filter(cat => + cat && cat.name && typeof cat.name === 'string' && + cat.name.toLowerCase().includes((searchQuery || '').toLowerCase()) + ); + }, [categories, searchQuery]); + + // Function to render each category item + const renderItem = useCallback( + ({item}: {item: Category}) => { + const isSelected = selectCategoryList.some(i => + (i.id !== undefined && item?.id !== undefined && i.id === item.id) || + (i._id !== undefined && item?._id !== undefined && i._id === item._id) || + (i.name === item?.name) + ); + return ( + { + handleCategorySelection(item); + }}> + + {item?.name} + + {isSelected && ( + + )} + + ); + }, + [handleCategorySelection, selectCategoryList], + ); + + // Function to close the bottom sheet modal + const handleDismissModalPress = useCallback(() => { + bottomSheetModalRef2.current?.close(); + }, [bottomSheetModalRef2]); + + return ( + + + + + + All Categories + + + + + + + {searchQuery.length > 0 && ( + setSearchQuery('')}> + + + )} + + + {filteredCategories.length === 0 ? ( + + + No categories found + Try a different search term + + ) : ( + item.id.toString()} + renderItem={renderItem} + contentContainerStyle={styles.contentContainer} + contentInsetAdjustmentBehavior={'always'} + extraData={selectCategoryList} + /> + )} + + ); +}; + +export default CategoriesFlatlistModal; + +// Styles for the component +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + backgroundColor: 'white', + }, + headerButton: { + padding: 4, + }, + title: { + fontSize: rf(18), + fontWeight: '700', + color: '#111827', + flex: 1, + textAlign: 'center', + }, + searchContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#F9FAFB', + borderRadius: 12, + paddingHorizontal: 16, + marginHorizontal: 20, + marginVertical: 16, + borderWidth: 1, + borderColor: '#E5E7EB', + height: 48, + }, + searchIcon: { + marginRight: 8, + }, + searchInput: { + flex: 1, + fontSize: rf(15), + color: '#111827', + paddingVertical: 0, + }, + contentContainer: { + paddingTop: 8, + backgroundColor: 'white', + paddingBottom: 20, + paddingHorizontal: 20, + }, + item: { + borderWidth: 1.5, + borderRadius: 12, + paddingVertical: hp(1.4), + paddingHorizontal: 16, + marginBottom: 10, + borderColor: '#E5E7EB', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: 'white', + }, + itemText: { + fontSize: rf(16), + fontWeight: '500', + color: '#374151', + }, + noResults: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 60, + }, + noResultsText: { + fontSize: rf(18), + fontWeight: '600', + color: '#6B7280', + marginTop: 16, + }, + noResultsSubText: { + fontSize: rf(14), + color: '#9ca3af', + marginTop: 8, + }, +}); diff --git a/frontend/src/components/CommentItem.tsx b/frontend/src/components/CommentItem.tsx index 7c5a2c2d..33ab11cb 100644 --- a/frontend/src/components/CommentItem.tsx +++ b/frontend/src/components/CommentItem.tsx @@ -1,234 +1,235 @@ -import React, {useState} from 'react'; -import {TouchableOpacity} from 'react-native'; -import {YStack, XStack, Text, Avatar, Paragraph} from 'tamagui'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; -import Entypo from '@expo/vector-icons/Entypo'; -import {FontAwesome, Fontisto} from '@expo/vector-icons'; -import { formatWithOrdinalAndDay } from '../helper/dateUtils'; -import ArticleFloatingMenu from './AnimatedMenu'; -import {PRIMARY_COLOR} from '../helper/Theme'; -import {Comment} from '../type'; -import {GET_STORAGE_DATA} from '../helper/APIUtils'; -import LoadingSpinner from './LoadingSpinner'; +import React, {useState} from 'react'; +import {TouchableOpacity} from 'react-native'; +import {YStack, XStack, Text, Avatar, Paragraph} from 'tamagui'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import Entypo from '@expo/vector-icons/Entypo'; +import {FontAwesome, Fontisto} from '@expo/vector-icons'; +import { formatWithOrdinalAndDay } from '../helper/dateUtils'; +import ArticleFloatingMenu from './AnimatedMenu'; +import {PRIMARY_COLOR} from '../helper/Theme'; +import {Comment} from '../type'; +import {GET_STORAGE_DATA} from '../helper/APIUtils'; +import LoadingSpinner from './LoadingSpinner'; import { rf } from '../helper/Metric'; -export default function CommentItem({ - item, - isSelected, - userId, - setSelectedCommentId, - handleEditAction, - deleteAction, - handleLikeAction, - commentLikeLoading, - handleMentionClick, - handleReportAction, - isFromArticle, -}: { - item: Comment; - isSelected: boolean; - userId: string; - setSelectedCommentId: (id: string) => void; - handleEditAction: (comment: Comment) => void; - deleteAction: (comment: Comment) => void; - handleLikeAction: (comment: Comment) => void; - commentLikeLoading: boolean; - handleMentionClick: (user_handle: string) => void; - handleReportAction: (commentId: string, authorId: string) => void; - isFromArticle: boolean | undefined; -}) { - const width = useSharedValue(0); - const yValue = useSharedValue(60); - const [isMenuVisible, setIsMenuVisible] = useState(false); - - const menuStyle = useAnimatedStyle(() => ({ - width: width.value, - transform: [{translateY: yValue.value}], - })); - - //console.log("Item", item); - - const handleAnimation = () => { - if (!isMenuVisible) { - width.value = withTiming(280, {duration: 250}); - yValue.value = withTiming(0, {duration: 250}); - setIsMenuVisible(true); - setSelectedCommentId(item._id); - } else { - width.value = withTiming(0, {duration: 250}); - yValue.value = withTiming(100, {duration: 250}); - setIsMenuVisible(false); - setSelectedCommentId(''); - } - }; - - const formatWithOrdinal = (date: string) => - formatWithOrdinalAndDay(date); - - // Render mentions inline - const renderTextWithMentions = (text: string) => { - const regex = /(@[\w-]+)/g; - const parts = text.split(regex); - return parts.map((part, index) => - /^@[\w-]+$/.test(part) ? ( - handleMentionClick(part)}> - {part} - - ) : ( - {part} - ), - ); - }; - - return ( - - {/* Floating Menu */} - {isMenuVisible && ( - - { - handleReportAction(item._id, item.userId._id); - handleAnimation(); - }, - icon: 'aim' as const, - }, - ...(userId === item.userId._id && isSelected && !isFromArticle - ? [ - { - name: 'Edit', - action: () => { - handleEditAction(item); - handleAnimation(); - }, - // 'edit' is the correct AntDesign equivalent of a pencil/edit icon - icon: 'edit' as const, - }, - { - name: 'Delete', - action: () => { - deleteAction(item); - handleAnimation(); - }, - // 'delete' is the correct AntDesign equivalent of a trash/remove icon - icon: 'delete' as const, - }, - ] - : []), - ]} - top={1} - left={1} - /> - - )} - - {/* Main Comment Layout */} - - {/* Profile Image */} - - - - - - {/* Comment Content */} - - - - - {item.userId.user_handle} - - {item.isEdited && ( - - (edited) - - )} - - - - - - - - - {renderTextWithMentions(item.content)} - - - - Last updated {formatWithOrdinal(item.updatedAt)} - - - {/* Like & Actions */} - - {commentLikeLoading && isSelected ? ( - - ) : ( - { - width.value = withTiming(0, {duration: 250}); - yValue.value = withTiming(100, {duration: 250}); - setSelectedCommentId(item._id); - handleLikeAction(item); - }}> - {item.likedUsers.some(id => id === userId) ? ( - - ) : ( - - )} - - )} - - {item.likedUsers.length} - - - - - - ); -} + +export default function CommentItem({ + item, + isSelected, + userId, + setSelectedCommentId, + handleEditAction, + deleteAction, + handleLikeAction, + commentLikeLoading, + handleMentionClick, + handleReportAction, + isFromArticle, +}: { + item: Comment; + isSelected: boolean; + userId: string; + setSelectedCommentId: (id: string) => void; + handleEditAction: (comment: Comment) => void; + deleteAction: (comment: Comment) => void; + handleLikeAction: (comment: Comment) => void; + commentLikeLoading: boolean; + handleMentionClick: (user_handle: string) => void; + handleReportAction: (commentId: string, authorId: string) => void; + isFromArticle: boolean | undefined; +}) { + const width = useSharedValue(0); + const yValue = useSharedValue(60); + const [isMenuVisible, setIsMenuVisible] = useState(false); + + const menuStyle = useAnimatedStyle(() => ({ + width: width.value, + transform: [{translateY: yValue.value}], + })); + + //console.log("Item", item); + + const handleAnimation = () => { + if (!isMenuVisible) { + width.value = withTiming(280, {duration: 250}); + yValue.value = withTiming(0, {duration: 250}); + setIsMenuVisible(true); + setSelectedCommentId(item._id); + } else { + width.value = withTiming(0, {duration: 250}); + yValue.value = withTiming(100, {duration: 250}); + setIsMenuVisible(false); + setSelectedCommentId(''); + } + }; + + const formatWithOrdinal = (date: string) => + formatWithOrdinalAndDay(date); + + // Render mentions inline + const renderTextWithMentions = (text: string) => { + const regex = /(@[\w-]+)/g; + const parts = text.split(regex); + return parts.map((part, index) => + /^@[\w-]+$/.test(part) ? ( + handleMentionClick(part)}> + {part} + + ) : ( + {part} + ), + ); + }; + + return ( + + {/* Floating Menu */} + {isMenuVisible && ( + + { + handleReportAction(item._id, item.userId._id); + handleAnimation(); + }, + icon: 'aim' as const, + }, + ...(userId === item.userId._id && isSelected && !isFromArticle + ? [ + { + name: 'Edit', + action: () => { + handleEditAction(item); + handleAnimation(); + }, + // 'edit' is the correct AntDesign equivalent of a pencil/edit icon + icon: 'edit' as const, + }, + { + name: 'Delete', + action: () => { + deleteAction(item); + handleAnimation(); + }, + // 'delete' is the correct AntDesign equivalent of a trash/remove icon + icon: 'delete' as const, + }, + ] + : []), + ]} + top={1} + left={1} + /> + + )} + + {/* Main Comment Layout */} + + {/* Profile Image */} + + + + + + {/* Comment Content */} + + + + + {item.userId.user_handle} + + {item.isEdited && ( + + (edited) + + )} + + + + + + + + + {renderTextWithMentions(item.content)} + + + + Last updated {formatWithOrdinal(item.updatedAt)} + + + {/* Like & Actions */} + + {commentLikeLoading && isSelected ? ( + + ) : ( + { + width.value = withTiming(0, {duration: 250}); + yValue.value = withTiming(100, {duration: 250}); + setSelectedCommentId(item._id); + handleLikeAction(item); + }}> + {item.likedUsers.some(id => id === userId) ? ( + + ) : ( + + )} + + )} + + {item.likedUsers.length} + + + + + + ); +} diff --git a/frontend/src/components/ContactTab.tsx b/frontend/src/components/ContactTab.tsx index 6e57c2e5..e4c8bc48 100644 --- a/frontend/src/components/ContactTab.tsx +++ b/frontend/src/components/ContactTab.tsx @@ -1,162 +1,163 @@ -import { - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; -import React, {useEffect} from 'react'; -import { useForm, Controller } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import {PRIMARY_COLOR} from '../helper/Theme'; +import { + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import React, {useEffect} from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import {PRIMARY_COLOR} from '../helper/Theme'; import { rf } from '../helper/Metric'; -const contactSchema = z.object({ - phone_number: z.string().min(10, 'Please enter a valid phone number'), - contact_email: z.string().email('Please enter a valid email'), -}); -export type ContactFormData = z.infer; - -export interface ProfileEditContactTab { - user: any; - handleSubmitContactDetails: (data: ContactFormData) => void; -} - -const ContactTab = ({ - user, - handleSubmitContactDetails, -}: ProfileEditContactTab) => { - const { control, handleSubmit, reset } = useForm({ - resolver: zodResolver(contactSchema), - mode: 'onChange', - defaultValues: { - phone_number: user?.contact_detail?.phone_no || '', - contact_email: user?.contact_detail?.email_id || '', - } - }); - - useEffect(() => { - reset({ - phone_number: user?.contact_detail?.phone_no || '', - contact_email: user?.contact_detail?.email_id || '', - }); - }, [user, reset]); - return ( - - {/* Content Container */} - - {/* Phone Number Input */} - - Phone Number - ( - <> - - {error && {error.message}} - - )} - /> - - - {/* Contact Email Input */} - - Contact Email - ( - <> - - {error && {error.message}} - - )} - /> - - - - {/* Save Button */} - - Save - - - ); -}; - -export default ContactTab; - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'space-between', - height: '100%', - flexDirection: 'column', - }, - content: { - width: '100%', - flexDirection: 'column', - gap: 15, - alignItems: 'center', - }, - input: { - width: '100%', - }, - inputLabel: { - fontSize: 17, - fontWeight: '600', - color: '#222', - marginBottom: 8, - }, - inputControl: { - height: 50, - backgroundColor: '#fff', - paddingHorizontal: 16, - borderRadius: 12, - fontSize: 15, - fontWeight: '500', - color: '#222', - borderWidth: 1, - borderColor: '#C9D3DB', - borderStyle: 'solid', - }, - btn: { - width: '100%', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 8, - paddingVertical: 10, - paddingHorizontal: 16, - backgroundColor: PRIMARY_COLOR, - marginTop: 20, - }, - btnText: { - fontSize: 18, - fontWeight: 'bold', - color: 'white', - }, - errorText: { - color: 'red', - fontSize: 12, - marginTop: 4, - }, -}); + +const contactSchema = z.object({ + phone_number: z.string().min(10, 'Please enter a valid phone number'), + contact_email: z.string().email('Please enter a valid email'), +}); +export type ContactFormData = z.infer; + +export interface ProfileEditContactTab { + user: any; + handleSubmitContactDetails: (data: ContactFormData) => void; +} + +const ContactTab = ({ + user, + handleSubmitContactDetails, +}: ProfileEditContactTab) => { + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(contactSchema), + mode: 'onChange', + defaultValues: { + phone_number: user?.contact_detail?.phone_no || '', + contact_email: user?.contact_detail?.email_id || '', + } + }); + + useEffect(() => { + reset({ + phone_number: user?.contact_detail?.phone_no || '', + contact_email: user?.contact_detail?.email_id || '', + }); + }, [user, reset]); + return ( + + {/* Content Container */} + + {/* Phone Number Input */} + + Phone Number + ( + <> + + {error && {error.message}} + + )} + /> + + + {/* Contact Email Input */} + + Contact Email + ( + <> + + {error && {error.message}} + + )} + /> + + + + {/* Save Button */} + + Save + + + ); +}; + +export default ContactTab; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'space-between', + height: '100%', + flexDirection: 'column', + }, + content: { + width: '100%', + flexDirection: 'column', + gap: 15, + alignItems: 'center', + }, + input: { + width: '100%', + }, + inputLabel: { + fontSize: rf(17), + fontWeight: '600', + color: '#222', + marginBottom: 8, + }, + inputControl: { + height: 50, + backgroundColor: '#fff', + paddingHorizontal: 16, + borderRadius: 12, + fontSize: rf(15), + fontWeight: '500', + color: '#222', + borderWidth: 1, + borderColor: '#C9D3DB', + borderStyle: 'solid', + }, + btn: { + width: '100%', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 16, + backgroundColor: PRIMARY_COLOR, + marginTop: 20, + }, + btnText: { + fontSize: rf(18), + fontWeight: 'bold', + color: 'white', + }, + errorText: { + color: 'red', + fontSize: rf(12), + marginTop: 4, + }, +}); diff --git a/frontend/src/components/CreatePlaylist.tsx b/frontend/src/components/CreatePlaylist.tsx index ae10dfca..7a3ed490 100644 --- a/frontend/src/components/CreatePlaylist.tsx +++ b/frontend/src/components/CreatePlaylist.tsx @@ -1,428 +1,429 @@ -import React, {useState} from 'react'; -import { - Modal, - View, - Text, - StyleSheet, - Dimensions, - TouchableOpacity, - TextInput, -} from 'react-native'; -import {useSelector} from 'react-redux'; -import {PRIMARY_COLOR} from '../helper/Theme'; -import Entypo from '@expo/vector-icons/Entypo'; -import FontAwesome from '@expo/vector-icons/FontAwesome'; -import Feather from '@expo/vector-icons/Feather'; -import {PlayList} from '../type'; -import Snackbar from 'react-native-snackbar'; -import NoInternet from './NoInternet'; -import {useGetPlaylists} from '../hooks/useGetPlaylists'; -import {useUpdatePodcastPlaylist} from '../hooks/useUpdatePodcastPlaylist'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import LoadingSpinner from './LoadingSpinner'; +import React, {useState} from 'react'; +import { + Modal, + View, + Text, + StyleSheet, + Dimensions, + TouchableOpacity, + TextInput, +} from 'react-native'; +import {useSelector} from 'react-redux'; +import {PRIMARY_COLOR} from '../helper/Theme'; +import Entypo from '@expo/vector-icons/Entypo'; +import FontAwesome from '@expo/vector-icons/FontAwesome'; +import Feather from '@expo/vector-icons/Feather'; +import {PlayList} from '../type'; +import Snackbar from 'react-native-snackbar'; +import NoInternet from './NoInternet'; +import {useGetPlaylists} from '../hooks/useGetPlaylists'; +import {useUpdatePodcastPlaylist} from '../hooks/useUpdatePodcastPlaylist'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import LoadingSpinner from './LoadingSpinner'; import { rf } from '../helper/Metric'; -interface Props { - //podcast_ids: string[]; - visible: boolean; - dismiss: () => void; -} -export default function CreatePlaylist({visible, dismiss}: Props) { - const {user_token} = useSelector((state: any) => state.user); - // const [selectedPlaylistId, setSelectedPlaylistId] = useState(''); - const [inputValue, setInputValue] = useState(''); - const {addedPodcastId} = useSelector((state: any) => state.data); - const {isConnected} = useSelector((state: any) => state.network); - const [addedPlaylistIds, setAddedPlaylistIds] = useState([]); - const [removePlaylistIds, setRemovePlaylistIds] = useState([]); - - const {data: playlists} = useGetPlaylists(); - const {mutate: updatePlaylist, isPending: updatePlaylistPending} = - useUpdatePodcastPlaylist(); - - const onCheck = (id: string) => { - // add the playlist id addedPlaylist - console.log('on check'); - if (!addedPlaylistIds.includes(id)) { - setAddedPlaylistIds(prev => [...prev, id]); - } - // Remove the playlist id from remove playlist - if (removePlaylistIds.includes(id)) { - setRemovePlaylistIds(prev => prev.filter(it => it !== id)); - } - }; - const onClear = (id: string) => { - // Add the playlist id to remove playlist id - console.log('on clear'); - if (!removePlaylistIds.includes(id)) { - setRemovePlaylistIds(prev => [...prev, id]); - } - // Remove it from added playlist id - if (addedPlaylistIds.includes(id)) { - setAddedPlaylistIds(prev => prev.filter(it => it !== id)); - } - }; - - /* - // Add Playlist Mutation - const addPlaylistMutation = useMutation({ - mutationKey: ['add-playlist-mutation'], - mutationFn: async () => { - const res = await axios.post( - ADD_TO_PLAYLIST, - { - podcast_id: addedPodcastId, - playlist_id: selectedPlaylistId, - }, - { - headers: { - Authorization: `Bearer ${user_token}`, - }, - }, - ); - - return res.data.data as PlayList; - }, - onSuccess: async () => { - Alert.alert('Podcast id added to playlist'); - Snackbar.show({ - text: 'Podcast id added to playlist', - duration: Snackbar.LENGTH_SHORT, - }); - }, - onError: err => { - console.log(err); - Alert.alert(err.message); - //setInputValue(''); - setSelectedPlaylistId(''); - }, - }); - // Create Playlist Mutation - const createPlaylistMutation = useMutation({ - mutationKey: ['create-playlist'], - mutationFn: async () => { - const res = await axios.post( - CREATE_PLAYLIST, - { - name: inputValue, - podcast_ids: [addedPodcastId], - }, - { - headers: { - Authorization: `Bearer ${user_token}`, - }, - }, - ); - - return res.data.data as PlayList; - }, - onSuccess: async _data => { - Alert.alert('Podcast id added to playlist'); - Snackbar.show({ - text: 'Podcast added', - duration: Snackbar.LENGTH_SHORT, - }); - dismiss(); - }, - onError: err => { - console.log(err); - Alert.alert(err.message); - //setInputValue(''); - setSelectedPlaylistId(''); - dismiss(); - }, - }); - - const createPlaylist = () => { - console.log('podcast id', podcast_ids); - if (!inputValue || inputValue === '') { - Alert.alert('Playlist name cannot be empty'); - return; - } - createPlaylistMutation.mutate(); - }; - - const addToPlaylist = () => { - console.log('podcast id', podcast_ids); - if (!selectedPlaylistId || selectedPlaylistId === '') { - Alert.alert('No playlist selected yet'); - return; - } - addPlaylistMutation.mutate(); - }; - */ - - const clear = () => { - setAddedPlaylistIds([]); - setRemovePlaylistIds([]); - setInputValue(''); - }; - - const RenderItem = ({item}: {item: PlayList}) => { - return ( - - {!removePlaylistIds.includes(item._id) && - (addedPlaylistIds.includes(item._id) || - item.podcasts.includes(addedPodcastId)) ? ( - onClear(item._id)}> - - - ) : ( - onCheck(item._id)}> - - - )} - - - {item.title} - - - - - ); - }; - - return ( - { - clear(); - dismiss(); - }}> - - - {isConnected ? ( - - - Save to Playlist - { - clear(); - dismiss(); - }}> - - - - {playlists && playlists.length > 0 ? ( - <> - Your Playlists - {playlists.map(item => ( - - ))} - - ) : ( - - - No playlists yet - - )} - - Create New Playlist - - {/** - * - Add - - */} - {(addedPlaylistIds.length > 0 || - removePlaylistIds.length > 0 || - inputValue !== '') && ( - <> - {updatePlaylistPending ? ( - - ) : ( - { - updatePlaylist( - { - addPlaylistIds: addedPlaylistIds, - removePlaylistIds: removePlaylistIds, - playlist_name: inputValue, - podcast_id: addedPodcastId, - }, - { - onSuccess: () => { - Snackbar.show({ - text: 'Podcast added', - duration: Snackbar.LENGTH_SHORT, - }); - dismiss(); - }, - onError: err => { - Snackbar.show({ - text: err.message, - duration: Snackbar.LENGTH_SHORT, - }); - clear(); - }, - }, - ); - }}> - Save - - )} - - )} - - ) : ( - - { - dismiss(); - }} - /> - - )} - - ); -} -const styles = StyleSheet.create({ - modal: { - position: 'relative', - width: '100%', - height: '100%', - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 5, - }, - overlay: { - position: 'absolute', - height: '100%', - width: '100%', - backgroundColor: 'rgba(0,0,0,0.5)', - }, - modalContent: { - flex: 1, - width: '92%', - maxHeight: '70%', - alignSelf: 'center', - backgroundColor: '#ffffff', - borderRadius: 16, - position: 'absolute', - top: Dimensions.get('window').height / 4, - padding: 20, - shadowColor: '#000', - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.2, - shadowRadius: 12, - elevation: 8, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 20, - }, - headerSubTitle: { - fontSize: 20, - color: '#1a1a1a', - fontWeight: '700', - }, - sectionLabel: { - fontSize: 14, - color: '#6b7280', - fontWeight: '600', - marginTop: 16, - marginBottom: 12, - textTransform: 'uppercase', - letterSpacing: 0.5, - }, - noPlaylistsContainer: { - alignItems: 'center', - paddingVertical: 24, - paddingHorizontal: 16, - }, - noPlaylistsText: { - fontSize: 14, - color: '#9ca3af', - marginTop: 8, - fontWeight: '500', - }, - headerCloseText: { - fontSize: 16, - color: '#313131', - fontWeight: '400', - }, - createNewBtnStyle: { - marginTop: 20, - alignItems: 'center', - }, - - createNewInputContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginTop: 20, - }, - - textInput: { - borderColor: '#e5e7eb', - borderWidth: 2, - borderRadius: 10, - paddingHorizontal: 16, - paddingVertical: 12, - fontSize: 15, - color: '#1a1a1a', - backgroundColor: '#f9fafb', - }, - - addButton: { - backgroundColor: PRIMARY_COLOR, - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 10, - shadowColor: PRIMARY_COLOR, - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.3, - shadowRadius: 4, - elevation: 3, - }, - - addButtonText: { - color: '#ffffff', - fontWeight: '700', - fontSize: 15, - }, - itemContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 14, - paddingHorizontal: 16, - backgroundColor: '#f9fafb', - borderRadius: 10, - marginVertical: 6, - borderWidth: 1, - borderColor: '#e5e7eb', - }, - - itemTextContainer: { - flex: 1, - paddingHorizontal: 12, - }, - - itemTitle: { - fontSize: 15, - fontWeight: '600', - color: '#1a1a1a', - }, -}); + +interface Props { + //podcast_ids: string[]; + visible: boolean; + dismiss: () => void; +} +export default function CreatePlaylist({visible, dismiss}: Props) { + const {user_token} = useSelector((state: any) => state.user); + // const [selectedPlaylistId, setSelectedPlaylistId] = useState(''); + const [inputValue, setInputValue] = useState(''); + const {addedPodcastId} = useSelector((state: any) => state.data); + const {isConnected} = useSelector((state: any) => state.network); + const [addedPlaylistIds, setAddedPlaylistIds] = useState([]); + const [removePlaylistIds, setRemovePlaylistIds] = useState([]); + + const {data: playlists} = useGetPlaylists(); + const {mutate: updatePlaylist, isPending: updatePlaylistPending} = + useUpdatePodcastPlaylist(); + + const onCheck = (id: string) => { + // add the playlist id addedPlaylist + console.log('on check'); + if (!addedPlaylistIds.includes(id)) { + setAddedPlaylistIds(prev => [...prev, id]); + } + // Remove the playlist id from remove playlist + if (removePlaylistIds.includes(id)) { + setRemovePlaylistIds(prev => prev.filter(it => it !== id)); + } + }; + const onClear = (id: string) => { + // Add the playlist id to remove playlist id + console.log('on clear'); + if (!removePlaylistIds.includes(id)) { + setRemovePlaylistIds(prev => [...prev, id]); + } + // Remove it from added playlist id + if (addedPlaylistIds.includes(id)) { + setAddedPlaylistIds(prev => prev.filter(it => it !== id)); + } + }; + + /* + // Add Playlist Mutation + const addPlaylistMutation = useMutation({ + mutationKey: ['add-playlist-mutation'], + mutationFn: async () => { + const res = await axios.post( + ADD_TO_PLAYLIST, + { + podcast_id: addedPodcastId, + playlist_id: selectedPlaylistId, + }, + { + headers: { + Authorization: `Bearer ${user_token}`, + }, + }, + ); + + return res.data.data as PlayList; + }, + onSuccess: async () => { + Alert.alert('Podcast id added to playlist'); + Snackbar.show({ + text: 'Podcast id added to playlist', + duration: Snackbar.LENGTH_SHORT, + }); + }, + onError: err => { + console.log(err); + Alert.alert(err.message); + //setInputValue(''); + setSelectedPlaylistId(''); + }, + }); + // Create Playlist Mutation + const createPlaylistMutation = useMutation({ + mutationKey: ['create-playlist'], + mutationFn: async () => { + const res = await axios.post( + CREATE_PLAYLIST, + { + name: inputValue, + podcast_ids: [addedPodcastId], + }, + { + headers: { + Authorization: `Bearer ${user_token}`, + }, + }, + ); + + return res.data.data as PlayList; + }, + onSuccess: async _data => { + Alert.alert('Podcast id added to playlist'); + Snackbar.show({ + text: 'Podcast added', + duration: Snackbar.LENGTH_SHORT, + }); + dismiss(); + }, + onError: err => { + console.log(err); + Alert.alert(err.message); + //setInputValue(''); + setSelectedPlaylistId(''); + dismiss(); + }, + }); + + const createPlaylist = () => { + console.log('podcast id', podcast_ids); + if (!inputValue || inputValue === '') { + Alert.alert('Playlist name cannot be empty'); + return; + } + createPlaylistMutation.mutate(); + }; + + const addToPlaylist = () => { + console.log('podcast id', podcast_ids); + if (!selectedPlaylistId || selectedPlaylistId === '') { + Alert.alert('No playlist selected yet'); + return; + } + addPlaylistMutation.mutate(); + }; + */ + + const clear = () => { + setAddedPlaylistIds([]); + setRemovePlaylistIds([]); + setInputValue(''); + }; + + const RenderItem = ({item}: {item: PlayList}) => { + return ( + + {!removePlaylistIds.includes(item._id) && + (addedPlaylistIds.includes(item._id) || + item.podcasts.includes(addedPodcastId)) ? ( + onClear(item._id)}> + + + ) : ( + onCheck(item._id)}> + + + )} + + + {item.title} + + + + + ); + }; + + return ( + { + clear(); + dismiss(); + }}> + + + {isConnected ? ( + + + Save to Playlist + { + clear(); + dismiss(); + }}> + + + + {playlists && playlists.length > 0 ? ( + <> + Your Playlists + {playlists.map(item => ( + + ))} + + ) : ( + + + No playlists yet + + )} + + Create New Playlist + + {/** + * + Add + + */} + {(addedPlaylistIds.length > 0 || + removePlaylistIds.length > 0 || + inputValue !== '') && ( + <> + {updatePlaylistPending ? ( + + ) : ( + { + updatePlaylist( + { + addPlaylistIds: addedPlaylistIds, + removePlaylistIds: removePlaylistIds, + playlist_name: inputValue, + podcast_id: addedPodcastId, + }, + { + onSuccess: () => { + Snackbar.show({ + text: 'Podcast added', + duration: Snackbar.LENGTH_SHORT, + }); + dismiss(); + }, + onError: err => { + Snackbar.show({ + text: err.message, + duration: Snackbar.LENGTH_SHORT, + }); + clear(); + }, + }, + ); + }}> + Save + + )} + + )} + + ) : ( + + { + dismiss(); + }} + /> + + )} + + ); +} +const styles = StyleSheet.create({ + modal: { + position: 'relative', + width: '100%', + height: '100%', + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 5, + }, + overlay: { + position: 'absolute', + height: '100%', + width: '100%', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + modalContent: { + flex: 1, + width: '92%', + maxHeight: '70%', + alignSelf: 'center', + backgroundColor: '#ffffff', + borderRadius: 16, + position: 'absolute', + top: Dimensions.get('window').height / 4, + padding: 20, + shadowColor: '#000', + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.2, + shadowRadius: 12, + elevation: 8, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + headerSubTitle: { + fontSize: rf(20), + color: '#1a1a1a', + fontWeight: '700', + }, + sectionLabel: { + fontSize: rf(14), + color: '#6b7280', + fontWeight: '600', + marginTop: 16, + marginBottom: 12, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + noPlaylistsContainer: { + alignItems: 'center', + paddingVertical: 24, + paddingHorizontal: 16, + }, + noPlaylistsText: { + fontSize: rf(14), + color: '#9ca3af', + marginTop: 8, + fontWeight: '500', + }, + headerCloseText: { + fontSize: rf(16), + color: '#313131', + fontWeight: '400', + }, + createNewBtnStyle: { + marginTop: 20, + alignItems: 'center', + }, + + createNewInputContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginTop: 20, + }, + + textInput: { + borderColor: '#e5e7eb', + borderWidth: 2, + borderRadius: 10, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: rf(15), + color: '#1a1a1a', + backgroundColor: '#f9fafb', + }, + + addButton: { + backgroundColor: PRIMARY_COLOR, + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 10, + shadowColor: PRIMARY_COLOR, + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 3, + }, + + addButtonText: { + color: '#ffffff', + fontWeight: '700', + fontSize: rf(15), + }, + itemContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + paddingHorizontal: 16, + backgroundColor: '#f9fafb', + borderRadius: 10, + marginVertical: 6, + borderWidth: 1, + borderColor: '#e5e7eb', + }, + + itemTextContainer: { + flex: 1, + paddingHorizontal: 12, + }, + + itemTitle: { + fontSize: rf(15), + fontWeight: '600', + color: '#1a1a1a', + }, +}); diff --git a/frontend/src/components/CustomAlert.tsx b/frontend/src/components/CustomAlert.tsx index 5417c8dd..6e928f9c 100644 --- a/frontend/src/components/CustomAlert.tsx +++ b/frontend/src/components/CustomAlert.tsx @@ -1,72 +1,73 @@ -import { AlertDialog, Button, XStack, YStack } from "tamagui"; -import { useDispatch, useSelector } from "react-redux"; -import { hideAlert } from "../store/alertSlice"; +import { AlertDialog, Button, XStack, YStack } from "tamagui"; +import { useDispatch, useSelector } from "react-redux"; +import { hideAlert } from "../store/alertSlice"; import { rf } from '../helper/Metric'; -export function CustomAlertDialog() { - const dispatch = useDispatch(); - const { visible, title, message, onConfirm, onCancel } = useSelector( - (state: any) => state.alert - ); - - const close = () => dispatch(hideAlert()); - - return ( - - - - - - - - {title} - - - - {message} - - - - {onCancel && ( - - - - )} - - - - - - - - - - ); -} + +export function CustomAlertDialog() { + const dispatch = useDispatch(); + const { visible, title, message, onConfirm, onCancel } = useSelector( + (state: any) => state.alert + ); + + const close = () => dispatch(hideAlert()); + + return ( + + + + + + + + {title} + + + + {message} + + + + {onCancel && ( + + + + )} + + + + + + + + + + ); +} diff --git a/frontend/src/components/EditRequestModal.tsx b/frontend/src/components/EditRequestModal.tsx index f86c3478..4e8385dc 100644 --- a/frontend/src/components/EditRequestModal.tsx +++ b/frontend/src/components/EditRequestModal.tsx @@ -1,98 +1,99 @@ -import {View, Text, StyleSheet, Modal, TouchableOpacity} from 'react-native'; -import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; -import {hp, wp} from '../helper/Metric'; -import Ionicon from '@expo/vector-icons/Ionicons'; -import Editor from './Editor'; +import {View, Text, StyleSheet, Modal, TouchableOpacity} from 'react-native'; +import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; +import {hp, wp} from '../helper/Metric'; +import Ionicon from '@expo/vector-icons/Ionicons'; +import Editor from './Editor'; import { rf } from '../helper/Metric'; -export default function EditRequestModal({ - visible, - callback, - dismiss, -}: { - visible: boolean; - callback: (reason: string) => void; - dismiss: () => void; -}) { - - return ( - - - - - Improvement Reason - - - - - - { - callback(reason); - }} - /> - - - - ); -} - -const styles = StyleSheet.create({ - overlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - justifyContent: 'center', - alignItems: 'center', - }, - modalContainer: { - backgroundColor: 'white', - // padding: 14, - borderRadius: 10, - marginHorizontal: 4, - width: '95%', - height: hp(50), - justifyContent: 'flex-start', - // alignItems:"center" - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - backgroundColor: PRIMARY_COLOR, - padding: wp(3), - borderTopLeftRadius: 10, - borderTopRightRadius: 10, - }, - container: { - flex: 0, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: ON_PRIMARY_COLOR, - }, - text: { - fontSize: 20, - //color: 'black', - }, - modalTitle: { - fontSize: 18, - //fontWeight: 'bold', - fontWeight: '500', - marginVertical: 3, - color: 'white', - }, - modalInput: { - minHeight: hp(27), - borderColor: PRIMARY_COLOR, - borderWidth: 1, - // backgroundColor:'#B6D0E2', - alignItems: 'flex-start', - justifyContent: 'flex-start', - marginVertical: 10, - paddingHorizontal: 10, - borderRadius: 5, - textAlignVertical: 'top', - fontSize: 20, - }, -}); + +export default function EditRequestModal({ + visible, + callback, + dismiss, +}: { + visible: boolean; + callback: (reason: string) => void; + dismiss: () => void; +}) { + + return ( + + + + + Improvement Reason + + + + + + { + callback(reason); + }} + /> + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + modalContainer: { + backgroundColor: 'white', + // padding: 14, + borderRadius: 10, + marginHorizontal: 4, + width: '95%', + height: hp(50), + justifyContent: 'flex-start', + // alignItems:"center" + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + backgroundColor: PRIMARY_COLOR, + padding: wp(3), + borderTopLeftRadius: 10, + borderTopRightRadius: 10, + }, + container: { + flex: 0, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: ON_PRIMARY_COLOR, + }, + text: { + fontSize: rf(20), + //color: 'black', + }, + modalTitle: { + fontSize: rf(18), + //fontWeight: 'bold', + fontWeight: '500', + marginVertical: 3, + color: 'white', + }, + modalInput: { + minHeight: hp(27), + borderColor: PRIMARY_COLOR, + borderWidth: 1, + // backgroundColor:'#B6D0E2', + alignItems: 'flex-start', + justifyContent: 'flex-start', + marginVertical: 10, + paddingHorizontal: 10, + borderRadius: 5, + textAlignVertical: 'top', + fontSize: rf(20), + }, +}); diff --git a/frontend/src/components/Editor.tsx b/frontend/src/components/Editor.tsx index 7a5f78a2..758a2873 100644 --- a/frontend/src/components/Editor.tsx +++ b/frontend/src/components/Editor.tsx @@ -1,258 +1,259 @@ -import {View, Text, TouchableOpacity, StyleSheet} from 'react-native'; -import {actions, RichEditor, RichToolbar} from 'react-native-pell-rich-editor'; -import Entypo from '@expo/vector-icons/Entypo'; -import Feather from '@expo/vector-icons/Feather'; -import FontAwesome from '@expo/vector-icons/FontAwesome'; -import Ionicons from '@expo/vector-icons/Ionicons'; -import {wp, hp} from '../helper/Metric'; -import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; -import {useRef, useState} from 'react'; +import {View, Text, TouchableOpacity, StyleSheet} from 'react-native'; +import {actions, RichEditor, RichToolbar} from 'react-native-pell-rich-editor'; +import Entypo from '@expo/vector-icons/Entypo'; +import Feather from '@expo/vector-icons/Feather'; +import FontAwesome from '@expo/vector-icons/FontAwesome'; +import Ionicons from '@expo/vector-icons/Ionicons'; +import {wp, hp} from '../helper/Metric'; +import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; +import {useRef, useState} from 'react'; import { rf } from '../helper/Metric'; -export default function Editor({ - callback, -}: { - callback: (reason: string) => void; -}) { - const RichText = useRef(null); - const [feedback, setFeedback] = useState(''); - const RichTextTool = useRef(''); - function handleHeightChange(_height: number) {} - - function editorInitializedCallback() { - RichText.current?.registerToolbar(function (_items: any) {}); - } - - const createFeebackHTMLStructure = (feedback: string) => { - return ` - - - - - - ${feedback} -
- - `; - }; - return ( - - ( - - ), - [actions.alignLeft]: ({tintColor}: {tintColor: string}) => ( - - ), - [actions.alignCenter]: ({tintColor}: {tintColor: string}) => ( - - ), - [actions.alignRight]: ({tintColor}: {tintColor: string}) => ( - - ), - [actions.undo]: ({tintColor}: {tintColor: string}) => ( - - ), - [actions.redo]: ({tintColor}: {tintColor: string}) => ( - - ), - [actions.heading1]: ({tintColor}: {tintColor: string}) => ( - H1 - ), - [actions.heading2]: ({tintColor}: {tintColor: string}) => ( - H2 - ), - [actions.heading3]: ({tintColor}: {tintColor: string}) => ( - H3 - ), - [actions.heading4]: ({tintColor}: {tintColor: string}) => ( - H4 - ), - [actions.heading5]: ({tintColor}: {tintColor: string}) => ( - H5 - ), - [actions.heading6]: ({tintColor}: {tintColor: string}) => ( - H6 - ), - [actions.blockquote]: ({tintColor}: {tintColor: string}) => ( - - ), - }} - /> - setFeedback(text)} - editorInitializedCallback={editorInitializedCallback} - onHeightChange={handleHeightChange} - initialHeight={300} - /> - - { - // emit socket event for feedback - const ans = createFeebackHTMLStructure(feedback); - callback(ans); - }}> - Submit - - - ); -} - -const styles = StyleSheet.create({ - inputContainer: { - height: hp(40), - overflow: 'hidden', - borderColor: '#000', - borderWidth: 0.2, - marginHorizontal: wp(0), - marginTop: hp(0), - }, - editor: { - backgroundColor: 'blue', - borderColor: 'black', - marginHorizontal: 1, - }, - rich: { - flex: 1, - backgroundColor: ON_PRIMARY_COLOR, - }, - richBar: { - height: 40, - backgroundColor: ON_PRIMARY_COLOR, - marginTop: 0, - marginBottom: hp(0.8), - }, - tib: { - textAlign: 'center', - fontSize: 22, - fontWeight: '600', - color: '#515156', - }, - - submitButton: { - backgroundColor: PRIMARY_COLOR, - //padding: 5, - paddingVertical: 12, - marginHorizontal: 10, - alignItems: 'center', - borderRadius: 7, - }, - submitButtonText: { - fontSize: 18, - color: '#fff', - fontWeight: 'bold', - }, -}); + +export default function Editor({ + callback, +}: { + callback: (reason: string) => void; +}) { + const RichText = useRef(null); + const [feedback, setFeedback] = useState(''); + const RichTextTool = useRef(''); + function handleHeightChange(_height: number) {} + + function editorInitializedCallback() { + RichText.current?.registerToolbar(function (_items: any) {}); + } + + const createFeebackHTMLStructure = (feedback: string) => { + return ` + + + + + + ${feedback} +
+ + `; + }; + return ( + + ( + + ), + [actions.alignLeft]: ({tintColor}: {tintColor: string}) => ( + + ), + [actions.alignCenter]: ({tintColor}: {tintColor: string}) => ( + + ), + [actions.alignRight]: ({tintColor}: {tintColor: string}) => ( + + ), + [actions.undo]: ({tintColor}: {tintColor: string}) => ( + + ), + [actions.redo]: ({tintColor}: {tintColor: string}) => ( + + ), + [actions.heading1]: ({tintColor}: {tintColor: string}) => ( + H1 + ), + [actions.heading2]: ({tintColor}: {tintColor: string}) => ( + H2 + ), + [actions.heading3]: ({tintColor}: {tintColor: string}) => ( + H3 + ), + [actions.heading4]: ({tintColor}: {tintColor: string}) => ( + H4 + ), + [actions.heading5]: ({tintColor}: {tintColor: string}) => ( + H5 + ), + [actions.heading6]: ({tintColor}: {tintColor: string}) => ( + H6 + ), + [actions.blockquote]: ({tintColor}: {tintColor: string}) => ( + + ), + }} + /> + setFeedback(text)} + editorInitializedCallback={editorInitializedCallback} + onHeightChange={handleHeightChange} + initialHeight={300} + /> + + { + // emit socket event for feedback + const ans = createFeebackHTMLStructure(feedback); + callback(ans); + }}> + Submit + + + ); +} + +const styles = StyleSheet.create({ + inputContainer: { + height: hp(40), + overflow: 'hidden', + borderColor: '#000', + borderWidth: 0.2, + marginHorizontal: wp(0), + marginTop: hp(0), + }, + editor: { + backgroundColor: 'blue', + borderColor: 'black', + marginHorizontal: 1, + }, + rich: { + flex: 1, + backgroundColor: ON_PRIMARY_COLOR, + }, + richBar: { + height: 40, + backgroundColor: ON_PRIMARY_COLOR, + marginTop: 0, + marginBottom: hp(0.8), + }, + tib: { + textAlign: 'center', + fontSize: rf(22), + fontWeight: '600', + color: '#515156', + }, + + submitButton: { + backgroundColor: PRIMARY_COLOR, + //padding: 5, + paddingVertical: 12, + marginHorizontal: 10, + alignItems: 'center', + borderRadius: 7, + }, + submitButtonText: { + fontSize: rf(18), + color: '#fff', + fontWeight: 'bold', + }, +}); diff --git a/frontend/src/components/EmailInputModal.tsx b/frontend/src/components/EmailInputModal.tsx index 19845073..527003c3 100644 --- a/frontend/src/components/EmailInputModal.tsx +++ b/frontend/src/components/EmailInputModal.tsx @@ -1,273 +1,274 @@ -import React, { useEffect, useState } from 'react'; -import { YStack, Button, Input, Spacer, Text, XStack, Card, Circle, Paragraph, ScrollView as TamaguiScrollView } from 'tamagui'; -import { Sheet } from '@tamagui/sheet'; -import { KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; -import { useForm, Controller } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import { EmailInputModalProp } from '../type'; -import Icon from '@expo/vector-icons/MaterialCommunityIcons'; -import Feather from '@expo/vector-icons/Feather'; +import React, { useEffect, useState } from 'react'; +import { YStack, Button, Input, Spacer, Text, XStack, Card, Circle, Paragraph, ScrollView as TamaguiScrollView } from 'tamagui'; +import { Sheet } from '@tamagui/sheet'; +import { KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { EmailInputModalProp } from '../type'; +import Icon from '@expo/vector-icons/MaterialCommunityIcons'; +import Feather from '@expo/vector-icons/Feather'; import { rf } from '../helper/Metric'; -const emailInputSchema = z.object({ - email: z.string().min(1, "Email is required").email("Please enter a valid email address"), -}); -type EmailInputFormData = z.infer; - -export default function EmailInputBottomSheet({ - visible, - callback, - backButtonClick, - onDismiss, - isRequestVerification, -}: EmailInputModalProp) { - const [open, setOpen] = useState(visible); - const [isFocused, setIsFocused] = useState(false); - - const { - control, - handleSubmit, - reset, - watch, - formState: { isValid, errors }, - } = useForm({ - resolver: zodResolver(emailInputSchema), - mode: 'onChange', - defaultValues: { - email: '', - }, - }); - - const emailValue = watch('email'); - - useEffect(() => { - setOpen(visible); - }, [visible]); - - const verifyEmail = (data: EmailInputFormData) => { - callback(data.email); - reset(); - }; - - const handleOpenChange = (isOpen: boolean) => { - setOpen(isOpen); - if (!isOpen) { - handleBackClick(); - } - }; - - const handleBackClick = () => { - reset(); - setIsFocused(false); - backButtonClick(); - setOpen(false); - onDismiss(); - }; - - return ( - - - - - - - - - {/* Icon Circle */} - - {isRequestVerification ? ( - - ) : ( - - )} - - - {/* Title */} - - {!errors.email - ? isRequestVerification - ? 'Email Verification' - : 'Forgot Password?' - : 'Invalid Email'} - - - {/* Subheading */} - - {!errors.email - ? isRequestVerification - ? 'Please enter your registered email to receive the verification link.' - : 'Enter your email address and we\'ll send you a code to reset your password.' - : 'Please enter a valid email address to continue.'} - - - {/* Email Input Field with Icon */} - - - Email Address - - ( - <> - - - setIsFocused(true)} - onBlur={() => { - setIsFocused(false); - onBlur(); - }} - keyboardType="email-address" - autoCapitalize="none" - autoCorrect={false} - borderWidth={2} - borderColor={ - error - ? '$red8' - : isFocused - ? '$blue8' - : '$gray6' - } - backgroundColor={error ? '$red1' : '$gray1'} - focusStyle={{ - borderColor: !error ? '$blue9' : '$red8', - backgroundColor: 'white', - }} - pointerEvents="auto" - /> - - {error && ( - - - - {error.message} - - - )} - - )} - /> - - - - - {/* Action Buttons */} - - - - - - - - - - - - ); -} + +const emailInputSchema = z.object({ + email: z.string().min(1, "Email is required").email("Please enter a valid email address"), +}); +type EmailInputFormData = z.infer; + +export default function EmailInputBottomSheet({ + visible, + callback, + backButtonClick, + onDismiss, + isRequestVerification, +}: EmailInputModalProp) { + const [open, setOpen] = useState(visible); + const [isFocused, setIsFocused] = useState(false); + + const { + control, + handleSubmit, + reset, + watch, + formState: { isValid, errors }, + } = useForm({ + resolver: zodResolver(emailInputSchema), + mode: 'onChange', + defaultValues: { + email: '', + }, + }); + + const emailValue = watch('email'); + + useEffect(() => { + setOpen(visible); + }, [visible]); + + const verifyEmail = (data: EmailInputFormData) => { + callback(data.email); + reset(); + }; + + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + handleBackClick(); + } + }; + + const handleBackClick = () => { + reset(); + setIsFocused(false); + backButtonClick(); + setOpen(false); + onDismiss(); + }; + + return ( + + + + + + + + + {/* Icon Circle */} + + {isRequestVerification ? ( + + ) : ( + + )} + + + {/* Title */} + + {!errors.email + ? isRequestVerification + ? 'Email Verification' + : 'Forgot Password?' + : 'Invalid Email'} + + + {/* Subheading */} + + {!errors.email + ? isRequestVerification + ? 'Please enter your registered email to receive the verification link.' + : 'Enter your email address and we\'ll send you a code to reset your password.' + : 'Please enter a valid email address to continue.'} + + + {/* Email Input Field with Icon */} + + + Email Address + + ( + <> + + + setIsFocused(true)} + onBlur={() => { + setIsFocused(false); + onBlur(); + }} + keyboardType="email-address" + autoCapitalize="none" + autoCorrect={false} + borderWidth={2} + borderColor={ + error + ? '$red8' + : isFocused + ? '$blue8' + : '$gray6' + } + backgroundColor={error ? '$red1' : '$gray1'} + focusStyle={{ + borderColor: !error ? '$blue9' : '$red8', + backgroundColor: 'white', + }} + pointerEvents="auto" + /> + + {error && ( + + + + {error.message} + + + )} + + )} + /> + + + + + {/* Action Buttons */} + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/EmptyStates.tsx b/frontend/src/components/EmptyStates.tsx index b37c5271..35d4e463 100644 --- a/frontend/src/components/EmptyStates.tsx +++ b/frontend/src/components/EmptyStates.tsx @@ -1,368 +1,369 @@ -import React, { useEffect, useRef } from 'react'; -import { - View, - Text, - StyleSheet, - Animated, - Easing, - useColorScheme, - Platform, -} from 'react-native'; -import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; -import { GlassButton } from './GlassButton'; -import { ProfessionalColors, Typography, Spacing, BorderRadius } from '../styles/GlassStyles'; +import React, { useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + Animated, + Easing, + useColorScheme, + Platform, +} from 'react-native'; +import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; +import { GlassButton } from './GlassButton'; +import { ProfessionalColors, Typography, Spacing, BorderRadius } from '../styles/GlassStyles'; import { rf } from '../helper/Metric'; -interface BaseEmptyStateProps { - iconEmoji?: string; - iconComponent?: React.ReactNode; - title: string; - description: string; - infoText?: string; - actionText?: string; - onAction?: () => void; - loading?: boolean; -} - -export const BaseEmptyState: React.FC = ({ - iconEmoji, - iconComponent, - title, - description, - infoText, - actionText, - onAction, - loading = false, -}) => { - const colorScheme = useColorScheme(); - const isDarkMode = colorScheme === 'dark'; - - const bounceAnim = useRef(new Animated.Value(0)).current; - const fadeAnim = useRef(new Animated.Value(0)).current; - const pulseAnim = useRef(new Animated.Value(1)).current; - - useEffect(() => { - // Fade in - Animated.timing(fadeAnim, { - toValue: 1, - duration: 600, - useNativeDriver: true, - }).start(); - - // Floating / Bouncing icon - Animated.loop( - Animated.sequence([ - Animated.timing(bounceAnim, { - toValue: -12, - duration: 1800, - easing: Easing.inOut(Easing.ease), - useNativeDriver: true, - }), - Animated.timing(bounceAnim, { - toValue: 0, - duration: 1800, - easing: Easing.inOut(Easing.ease), - useNativeDriver: true, - }), - ]) - ).start(); - - if (loading) { - // Pulse animation for loading states - Animated.loop( - Animated.sequence([ - Animated.timing(pulseAnim, { - toValue: 1.1, - duration: 1000, - easing: Easing.inOut(Easing.ease), - useNativeDriver: true, - }), - Animated.timing(pulseAnim, { - toValue: 1.0, - duration: 1000, - easing: Easing.inOut(Easing.ease), - useNativeDriver: true, - }), - ]) - ).start(); - } - }, [loading]); - - const containerBg = isDarkMode ? 'transparent' : 'transparent'; - const cardBg = isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(15, 82, 186, 0.03)'; - const borderColor = isDarkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)'; - const circleBg = isDarkMode ? 'rgba(255, 255, 255, 0.08)' : '#E3F2FD'; - const titleColor = isDarkMode ? ProfessionalColors.white : ProfessionalColors.gray900; - const descColor = isDarkMode ? ProfessionalColors.gray400 : ProfessionalColors.gray600; - - const animatedStyle = { - opacity: fadeAnim, - transform: [{ translateY: bounceAnim }], - }; - - const scaleStyle = loading ? { transform: [{ scale: pulseAnim }] } : {}; - - return ( - - - - {iconComponent ? ( - iconComponent - ) : ( - {iconEmoji} - )} - - - - {title} - - - - {description} - - - {infoText && ( - - - {infoText} - - - )} - - {actionText && onAction && ( - - )} - - - ); -}; - -// Specialized Empty State Exports -export const OfflineArticleState = () => ( - -); - -export const OfflinePodcastState = () => ( - -); - -export const NoArticleState = ({ onRefresh }: { onRefresh?: () => void }) => ( - -); - -export const NoPodcastState = ({ onRefresh }: { onRefresh?: () => void }) => ( - -); - -export const NoNotificationState = ({ onRefresh }: { onRefresh?: () => void }) => { - const isDarkMode = useColorScheme() === 'dark'; - return ( - - } - title="No Notifications Yet" - description="You're all caught up. New notifications will appear here when available." - actionText={onRefresh ? "Refresh" : undefined} - onAction={onRefresh} - /> - ); -}; - -// Loading State Component with audio waves -export const PodcastLoadingState = () => { - const waveAnim = useRef(new Animated.Value(0)).current; - - useEffect(() => { - Animated.loop( - Animated.sequence([ - Animated.timing(waveAnim, { - toValue: 1, - duration: 1200, - easing: Easing.inOut(Easing.ease), - useNativeDriver: true, - }), - Animated.timing(waveAnim, { - toValue: 0, - duration: 1200, - easing: Easing.inOut(Easing.ease), - useNativeDriver: true, - }), - ]) - ).start(); - }, []); - - const wave1Scale = waveAnim.interpolate({ - inputRange: [0, 1], - outputRange: [0.5, 1.2], - }); - - const wave2Scale = waveAnim.interpolate({ - inputRange: [0, 1], - outputRange: [1.0, 0.5], - }); - - const wave3Scale = waveAnim.interpolate({ - inputRange: [0, 1], - outputRange: [0.7, 1.0], - }); - - return ( - - - - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - stateContainer: { - width: '100%', - justifyContent: 'center', - alignItems: 'center', - paddingVertical: Spacing.xl, - paddingHorizontal: Spacing.lg, - minHeight: 380, - }, - innerCard: { - width: '100%', - maxWidth: 450, - borderRadius: BorderRadius.xl, - borderWidth: 1, - paddingVertical: Spacing.xxl, - paddingHorizontal: Spacing.xl, - alignItems: 'center', - justifyContent: 'center', - ...Platform.select({ - ios: { - shadowColor: '#000', - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.05, - shadowRadius: 10, - }, - android: { - elevation: 3, - }, - }), - }, - iconCircle: { - width: 100, - height: 100, - borderRadius: 50, - justifyContent: 'center', - alignItems: 'center', - marginBottom: Spacing.lg, - ...Platform.select({ - ios: { - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.06, - shadowRadius: 6, - }, - android: { - elevation: 2, - }, - }), - }, - iconEmoji: { - fontSize: 44, - }, - stateTitle: { - fontWeight: '700', - textAlign: 'center', - marginBottom: Spacing.xs, - paddingHorizontal: Spacing.sm, - }, - stateDescription: { - textAlign: 'center', - lineHeight: 22, - marginBottom: Spacing.xl, - paddingHorizontal: Spacing.md, - }, - infoBox: { - paddingHorizontal: Spacing.lg, - paddingVertical: Spacing.sm, - borderRadius: BorderRadius.round, - borderWidth: 1, - marginTop: Spacing.sm, - alignSelf: 'center', - }, - infoText: { - fontSize: 13, - fontWeight: '600', - textAlign: 'center', - }, - actionButton: { - marginTop: Spacing.sm, - paddingHorizontal: Spacing.xxl, - }, - loadingContainer: { - width: '100%', - alignItems: 'center', - justifyContent: 'center', - }, - waveContainer: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - height: 30, - marginTop: -Spacing.xl, - marginBottom: Spacing.lg, - }, - waveLine: { - width: 5, - height: 30, - backgroundColor: '#00BFFF', - marginHorizontal: 3, - borderRadius: 2.5, - }, -}); + +interface BaseEmptyStateProps { + iconEmoji?: string; + iconComponent?: React.ReactNode; + title: string; + description: string; + infoText?: string; + actionText?: string; + onAction?: () => void; + loading?: boolean; +} + +export const BaseEmptyState: React.FC = ({ + iconEmoji, + iconComponent, + title, + description, + infoText, + actionText, + onAction, + loading = false, +}) => { + const colorScheme = useColorScheme(); + const isDarkMode = colorScheme === 'dark'; + + const bounceAnim = useRef(new Animated.Value(0)).current; + const fadeAnim = useRef(new Animated.Value(0)).current; + const pulseAnim = useRef(new Animated.Value(1)).current; + + useEffect(() => { + // Fade in + Animated.timing(fadeAnim, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }).start(); + + // Floating / Bouncing icon + Animated.loop( + Animated.sequence([ + Animated.timing(bounceAnim, { + toValue: -12, + duration: 1800, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + Animated.timing(bounceAnim, { + toValue: 0, + duration: 1800, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + ]) + ).start(); + + if (loading) { + // Pulse animation for loading states + Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1.1, + duration: 1000, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1.0, + duration: 1000, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + ]) + ).start(); + } + }, [loading]); + + const containerBg = isDarkMode ? 'transparent' : 'transparent'; + const cardBg = isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(15, 82, 186, 0.03)'; + const borderColor = isDarkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)'; + const circleBg = isDarkMode ? 'rgba(255, 255, 255, 0.08)' : '#E3F2FD'; + const titleColor = isDarkMode ? ProfessionalColors.white : ProfessionalColors.gray900; + const descColor = isDarkMode ? ProfessionalColors.gray400 : ProfessionalColors.gray600; + + const animatedStyle = { + opacity: fadeAnim, + transform: [{ translateY: bounceAnim }], + }; + + const scaleStyle = loading ? { transform: [{ scale: pulseAnim }] } : {}; + + return ( + + + + {iconComponent ? ( + iconComponent + ) : ( + {iconEmoji} + )} + + + + {title} + + + + {description} + + + {infoText && ( + + + {infoText} + + + )} + + {actionText && onAction && ( + + )} + + + ); +}; + +// Specialized Empty State Exports +export const OfflineArticleState = () => ( + +); + +export const OfflinePodcastState = () => ( + +); + +export const NoArticleState = ({ onRefresh }: { onRefresh?: () => void }) => ( + +); + +export const NoPodcastState = ({ onRefresh }: { onRefresh?: () => void }) => ( + +); + +export const NoNotificationState = ({ onRefresh }: { onRefresh?: () => void }) => { + const isDarkMode = useColorScheme() === 'dark'; + return ( + + } + title="No Notifications Yet" + description="You're all caught up. New notifications will appear here when available." + actionText={onRefresh ? "Refresh" : undefined} + onAction={onRefresh} + /> + ); +}; + +// Loading State Component with audio waves +export const PodcastLoadingState = () => { + const waveAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.loop( + Animated.sequence([ + Animated.timing(waveAnim, { + toValue: 1, + duration: 1200, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + Animated.timing(waveAnim, { + toValue: 0, + duration: 1200, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + ]) + ).start(); + }, []); + + const wave1Scale = waveAnim.interpolate({ + inputRange: [0, 1], + outputRange: [0.5, 1.2], + }); + + const wave2Scale = waveAnim.interpolate({ + inputRange: [0, 1], + outputRange: [1.0, 0.5], + }); + + const wave3Scale = waveAnim.interpolate({ + inputRange: [0, 1], + outputRange: [0.7, 1.0], + }); + + return ( + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + stateContainer: { + width: '100%', + justifyContent: 'center', + alignItems: 'center', + paddingVertical: Spacing.xl, + paddingHorizontal: Spacing.lg, + minHeight: 380, + }, + innerCard: { + width: '100%', + maxWidth: 450, + borderRadius: BorderRadius.xl, + borderWidth: 1, + paddingVertical: Spacing.xxl, + paddingHorizontal: Spacing.xl, + alignItems: 'center', + justifyContent: 'center', + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.05, + shadowRadius: 10, + }, + android: { + elevation: 3, + }, + }), + }, + iconCircle: { + width: 100, + height: 100, + borderRadius: 50, + justifyContent: 'center', + alignItems: 'center', + marginBottom: Spacing.lg, + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.06, + shadowRadius: 6, + }, + android: { + elevation: 2, + }, + }), + }, + iconEmoji: { + fontSize: rf(44), + }, + stateTitle: { + fontWeight: '700', + textAlign: 'center', + marginBottom: Spacing.xs, + paddingHorizontal: Spacing.sm, + }, + stateDescription: { + textAlign: 'center', + lineHeight: 22, + marginBottom: Spacing.xl, + paddingHorizontal: Spacing.md, + }, + infoBox: { + paddingHorizontal: Spacing.lg, + paddingVertical: Spacing.sm, + borderRadius: BorderRadius.round, + borderWidth: 1, + marginTop: Spacing.sm, + alignSelf: 'center', + }, + infoText: { + fontSize: rf(13), + fontWeight: '600', + textAlign: 'center', + }, + actionButton: { + marginTop: Spacing.sm, + paddingHorizontal: Spacing.xxl, + }, + loadingContainer: { + width: '100%', + alignItems: 'center', + justifyContent: 'center', + }, + waveContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + height: 30, + marginTop: -Spacing.xl, + marginBottom: Spacing.lg, + }, + waveLine: { + width: 5, + height: 30, + backgroundColor: '#00BFFF', + marginHorizontal: 3, + borderRadius: 2.5, + }, +}); diff --git a/frontend/src/components/FilterModal.tsx b/frontend/src/components/FilterModal.tsx index 6bfbaa21..f708b5c8 100644 --- a/frontend/src/components/FilterModal.tsx +++ b/frontend/src/components/FilterModal.tsx @@ -1,593 +1,594 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {Text, StyleSheet, View, ScrollView, TextInput, TouchableOpacity} from 'react-native'; -import { - BottomSheetModal, - BottomSheetView, - BottomSheetBackdrop, -} from '@gorhom/bottom-sheet'; -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {Text, StyleSheet, View, ScrollView, TextInput, TouchableOpacity} from 'react-native'; +import { + BottomSheetModal, + BottomSheetView, + BottomSheetBackdrop, +} from '@gorhom/bottom-sheet'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; + +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import CategoriesFlatlistModal from './CategoriesFlatlistModal'; +import {PRIMARY_COLOR} from '../helper/Theme'; +import {HomeScreenFilterModalProps} from '../type'; +import {INDIAN_LANGUAGES} from '../constants/languages'; import { rf } from '../helper/Metric'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import CategoriesFlatlistModal from './CategoriesFlatlistModal'; -import {PRIMARY_COLOR} from '../helper/Theme'; -import {HomeScreenFilterModalProps} from '../type'; -import {INDIAN_LANGUAGES} from '../constants/languages'; - -// Helper function to format date as DD/MM/YYY - -// Main component -const FilterModal = ({ - bottomSheetModalRef, - categories, - handleCategorySelection, - selectCategoryList, - handleFilterReset, - handleFilterApply, - setSortingType, - sortingType, - selectedLanguages: externalSelectedLanguages, - setSelectedLanguages: externalSetSelectedLanguages, -}: HomeScreenFilterModalProps) => { - // Ref for second bottom sheet modal (category selection) - const bottomSheetModalRef2 = useRef(null); - - const sortBy = ['recent', 'popular', 'oldest']; - const [selectedCategory, setSelectedCategory] = useState(sortingType || ''); - const [searchQuery, setSearchQuery] = useState(''); - const [internalSelectedLanguages, setInternalSelectedLanguages] = useState([]); - - // Sync local selectedCategory with prop sortingType when sortingType changes - useEffect(() => { - setSelectedCategory(sortingType || ''); - }, [sortingType]); - - // Use external state if provided, otherwise use internal state - const selectedLanguages = externalSelectedLanguages ?? internalSelectedLanguages; - const setSelectedLanguages = externalSetSelectedLanguages ?? setInternalSelectedLanguages; - // Function to present the second bottom sheet modal - const handlePresentModalPress = useCallback(() => { - bottomSheetModalRef2.current?.present(); - }, []); - - // Get safe area insets for top margin adjustment - const insets = useSafeAreaInsets(); - - // Define snap points for the bottom sheet - const snapPoints = useMemo(() => ['20%', '65%', '95%'], []); - - // Close the bottom sheet modal - const handleDismissModalPress = useCallback(() => { - bottomSheetModalRef.current?.close(); - }, [bottomSheetModalRef]); - - // Render backdrop for bottom sheet - const renderBackdrop = useCallback( - (props: any) => ( - - ), - [], - ); - - // Shuffle array helper function - const shuffleArray = (array: T[]): T[] => { - const shuffled = [...array]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - return shuffled; - }; - - // Filter and sort categories stably, showing only 2 - const filteredCategories = useMemo(() => { - const filtered = !searchQuery.trim() - ? categories - : categories.filter(cat => - cat && cat.name && typeof cat.name === 'string' && - cat.name.toLowerCase().includes((searchQuery || '').toLowerCase()) - ); - - // Sort stably to prevent random reshuffling on state updates/re-renders - const sorted = [...filtered].sort((a, b) => { - if (a.id !== undefined && b.id !== undefined) { - return a.id - b.id; - } - return a.name.localeCompare(b.name); - }); - return sorted.slice(0, 2); - }, [categories, searchQuery]); - - // Handle language selection - const toggleLanguage = (code: string) => { - const next = selectedLanguages.includes(code) - ? selectedLanguages.filter((lang: string) => lang !== code) - : [...selectedLanguages, code]; - (setSelectedLanguages as any)(next); - }; - - // Reset all filters - const handleReset = () => { - setSelectedCategory(''); - setSearchQuery(''); - setSelectedLanguages([]); - handleFilterReset(); - handleDismissModalPress(); - }; - - return ( - - - - Filters & Search - - - - - - - - {/* Search Input */} - - - Search Categories - - - - - {searchQuery.length > 0 && ( - setSearchQuery('')}> - - - )} - - - {/* Sort By */} - - - Sort By - - - {sortBy.map((item, index) => ( - { - setSelectedCategory(item); - setSortingType(item); - }}> - - {item.charAt(0).toUpperCase() + item.slice(1)} - - {selectedCategory === item && ( - - )} - - ))} - - - - {/* Language Filter */} - - - Language - - - {INDIAN_LANGUAGES.map((lang, index) => ( - toggleLanguage(lang.code)}> - - {lang.name} - - {selectedLanguages.includes(lang.code) && ( - - )} - - ))} - - {selectedLanguages.length > 0 && ( - - - {selectedLanguages.length} language{selectedLanguages.length > 1 ? 's' : ''} selected - - - )} - - - {/* Categories */} - - - - Categories - - - See all - - - - - {selectCategoryList.length > 0 && ( - - Selected: - - - {selectCategoryList.map((item, index) => ( - handleCategorySelection(item)}> - - {item.name} - - - - ))} - - - - )} - - - {filteredCategories.map((item, index) => { - const isSelected = selectCategoryList.some(i => - (i.id !== undefined && item?.id !== undefined && i.id === item.id) || - (i._id !== undefined && item?._id !== undefined && i._id === item._id) || - (i.name === item?.name) - ); - return ( - handleCategorySelection(item)}> - - {item?.name} - - {isSelected && ( - - - - )} - - ); - })} - - - {searchQuery && filteredCategories.length === 0 && ( - - - No categories found - - )} - - - - - {/* Footer Buttons - Fixed at bottom */} - - - - Reset - - { - handleFilterApply(); - handleDismissModalPress(); - }}> - - Apply Filters - - - - - - - - - ); -}; - -export default FilterModal; - -// Styles for the component -const styles = StyleSheet.create({ - contentContainer: { - flex: 1, - width: '100%', - backgroundColor: '#F9FAFB', - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 16, - backgroundColor: 'white', - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - }, - title: { - fontSize: 20, - fontWeight: '700', - color: '#111827', - }, - closeButton: { - padding: 4, - }, - scrollContainer: { - flex: 1, - }, - filterContainer: { - paddingHorizontal: 20, - paddingTop: 16, - paddingBottom: 20, - }, - section: { - marginBottom: 24, - }, - sectionLabel: { - fontSize: 16, - fontWeight: '600', - color: '#374151', - marginBottom: 12, - flexDirection: 'row', - alignItems: 'center', - }, - searchContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'white', - borderRadius: 12, - paddingHorizontal: 12, - borderWidth: 1, - borderColor: '#E5E7EB', - height: 48, - }, - searchIcon: { - marginRight: 8, - }, - searchInput: { - flex: 1, - fontSize: 15, - color: '#111827', - paddingVertical: 0, - }, - chipContainer: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - }, - chip: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 20, - backgroundColor: 'white', - borderWidth: 1.5, - borderColor: '#E5E7EB', - gap: 6, - }, - chipSelected: { - backgroundColor: PRIMARY_COLOR, - borderColor: PRIMARY_COLOR, - }, - chipText: { - fontSize: 14, - fontWeight: '500', - color: '#6B7280', - }, - chipTextSelected: { - color: 'white', - }, - checkIcon: { - marginLeft: 2, - }, - selectedCount: { - marginTop: 8, - paddingHorizontal: 12, - paddingVertical: 6, - backgroundColor: '#EEF2FF', - borderRadius: 8, - alignSelf: 'flex-start', - }, - selectedCountText: { - fontSize: 13, - color: PRIMARY_COLOR, - fontWeight: '600', - }, - categoryHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 12, - }, - seeAllButton: { - flexDirection: 'row', - alignItems: 'center', - }, - seeAllText: { - fontSize: 14, - color: PRIMARY_COLOR, - fontWeight: '600', - }, - selectedCategoriesContainer: { - marginBottom: 12, - }, - selectedCategoriesLabel: { - fontSize: 13, - color: '#6B7280', - marginBottom: 8, - fontWeight: '500', - }, - selectedCategoriesChips: { - flexDirection: 'row', - gap: 8, - }, - selectedCategoryChip: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 16, - backgroundColor: PRIMARY_COLOR, - gap: 6, - }, - selectedCategoryChipText: { - fontSize: 13, - color: 'white', - fontWeight: '500', - }, - categoryGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 10, - }, - categoryCard: { - position: 'relative', - paddingHorizontal: 16, - paddingVertical: 12, - borderRadius: 12, - backgroundColor: 'white', - borderWidth: 1.5, - borderColor: '#E5E7EB', - minWidth: '47%', - flexGrow: 1, - }, - categoryCardSelected: { - backgroundColor: '#EEF2FF', - borderColor: PRIMARY_COLOR, - }, - categoryCardText: { - fontSize: 15, - fontWeight: '500', - color: '#374151', - }, - categoryCardTextSelected: { - color: PRIMARY_COLOR, - fontWeight: '600', - }, - checkBadge: { - position: 'absolute', - top: 6, - right: 6, - backgroundColor: PRIMARY_COLOR, - borderRadius: 10, - width: 20, - height: 20, - justifyContent: 'center', - alignItems: 'center', - }, - noResults: { - alignItems: 'center', - paddingVertical: 32, - }, - noResultsText: { - fontSize: 14, - color: '#9ca3af', - marginTop: 8, - }, - footer: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - flexDirection: 'row', - paddingHorizontal: 20, - paddingVertical: 16, - backgroundColor: 'white', - borderTopWidth: 1, - borderTopColor: '#E5E7EB', - gap: 12, - }, - resetButton: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 14, - borderRadius: 12, - backgroundColor: 'white', - borderWidth: 1.5, - borderColor: PRIMARY_COLOR, - gap: 6, - }, - resetButtonText: { - color: PRIMARY_COLOR, - fontSize: 15, - fontWeight: '600', - }, - applyButton: { - flex: 2, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 14, - borderRadius: 12, - backgroundColor: PRIMARY_COLOR, - gap: 6, - }, - applyButtonText: { - color: 'white', - fontSize: 15, - fontWeight: '600', - }, -}); + +// Helper function to format date as DD/MM/YYY + +// Main component +const FilterModal = ({ + bottomSheetModalRef, + categories, + handleCategorySelection, + selectCategoryList, + handleFilterReset, + handleFilterApply, + setSortingType, + sortingType, + selectedLanguages: externalSelectedLanguages, + setSelectedLanguages: externalSetSelectedLanguages, +}: HomeScreenFilterModalProps) => { + // Ref for second bottom sheet modal (category selection) + const bottomSheetModalRef2 = useRef(null); + + const sortBy = ['recent', 'popular', 'oldest']; + const [selectedCategory, setSelectedCategory] = useState(sortingType || ''); + const [searchQuery, setSearchQuery] = useState(''); + const [internalSelectedLanguages, setInternalSelectedLanguages] = useState([]); + + // Sync local selectedCategory with prop sortingType when sortingType changes + useEffect(() => { + setSelectedCategory(sortingType || ''); + }, [sortingType]); + + // Use external state if provided, otherwise use internal state + const selectedLanguages = externalSelectedLanguages ?? internalSelectedLanguages; + const setSelectedLanguages = externalSetSelectedLanguages ?? setInternalSelectedLanguages; + // Function to present the second bottom sheet modal + const handlePresentModalPress = useCallback(() => { + bottomSheetModalRef2.current?.present(); + }, []); + + // Get safe area insets for top margin adjustment + const insets = useSafeAreaInsets(); + + // Define snap points for the bottom sheet + const snapPoints = useMemo(() => ['20%', '65%', '95%'], []); + + // Close the bottom sheet modal + const handleDismissModalPress = useCallback(() => { + bottomSheetModalRef.current?.close(); + }, [bottomSheetModalRef]); + + // Render backdrop for bottom sheet + const renderBackdrop = useCallback( + (props: any) => ( + + ), + [], + ); + + // Shuffle array helper function + const shuffleArray = (array: T[]): T[] => { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; + }; + + // Filter and sort categories stably, showing only 2 + const filteredCategories = useMemo(() => { + const filtered = !searchQuery.trim() + ? categories + : categories.filter(cat => + cat && cat.name && typeof cat.name === 'string' && + cat.name.toLowerCase().includes((searchQuery || '').toLowerCase()) + ); + + // Sort stably to prevent random reshuffling on state updates/re-renders + const sorted = [...filtered].sort((a, b) => { + if (a.id !== undefined && b.id !== undefined) { + return a.id - b.id; + } + return a.name.localeCompare(b.name); + }); + return sorted.slice(0, 2); + }, [categories, searchQuery]); + + // Handle language selection + const toggleLanguage = (code: string) => { + const next = selectedLanguages.includes(code) + ? selectedLanguages.filter((lang: string) => lang !== code) + : [...selectedLanguages, code]; + (setSelectedLanguages as any)(next); + }; + + // Reset all filters + const handleReset = () => { + setSelectedCategory(''); + setSearchQuery(''); + setSelectedLanguages([]); + handleFilterReset(); + handleDismissModalPress(); + }; + + return ( + + + + Filters & Search + + + + + + + + {/* Search Input */} + + + Search Categories + + + + + {searchQuery.length > 0 && ( + setSearchQuery('')}> + + + )} + + + {/* Sort By */} + + + Sort By + + + {sortBy.map((item, index) => ( + { + setSelectedCategory(item); + setSortingType(item); + }}> + + {item.charAt(0).toUpperCase() + item.slice(1)} + + {selectedCategory === item && ( + + )} + + ))} + + + + {/* Language Filter */} + + + Language + + + {INDIAN_LANGUAGES.map((lang, index) => ( + toggleLanguage(lang.code)}> + + {lang.name} + + {selectedLanguages.includes(lang.code) && ( + + )} + + ))} + + {selectedLanguages.length > 0 && ( + + + {selectedLanguages.length} language{selectedLanguages.length > 1 ? 's' : ''} selected + + + )} + + + {/* Categories */} + + + + Categories + + + See all + + + + + {selectCategoryList.length > 0 && ( + + Selected: + + + {selectCategoryList.map((item, index) => ( + handleCategorySelection(item)}> + + {item.name} + + + + ))} + + + + )} + + + {filteredCategories.map((item, index) => { + const isSelected = selectCategoryList.some(i => + (i.id !== undefined && item?.id !== undefined && i.id === item.id) || + (i._id !== undefined && item?._id !== undefined && i._id === item._id) || + (i.name === item?.name) + ); + return ( + handleCategorySelection(item)}> + + {item?.name} + + {isSelected && ( + + + + )} + + ); + })} + + + {searchQuery && filteredCategories.length === 0 && ( + + + No categories found + + )} + + + + + {/* Footer Buttons - Fixed at bottom */} + + + + Reset + + { + handleFilterApply(); + handleDismissModalPress(); + }}> + + Apply Filters + + + + + + + + + ); +}; + +export default FilterModal; + +// Styles for the component +const styles = StyleSheet.create({ + contentContainer: { + flex: 1, + width: '100%', + backgroundColor: '#F9FAFB', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 16, + backgroundColor: 'white', + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + }, + title: { + fontSize: rf(20), + fontWeight: '700', + color: '#111827', + }, + closeButton: { + padding: 4, + }, + scrollContainer: { + flex: 1, + }, + filterContainer: { + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: 20, + }, + section: { + marginBottom: 24, + }, + sectionLabel: { + fontSize: rf(16), + fontWeight: '600', + color: '#374151', + marginBottom: 12, + flexDirection: 'row', + alignItems: 'center', + }, + searchContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'white', + borderRadius: 12, + paddingHorizontal: 12, + borderWidth: 1, + borderColor: '#E5E7EB', + height: 48, + }, + searchIcon: { + marginRight: 8, + }, + searchInput: { + flex: 1, + fontSize: rf(15), + color: '#111827', + paddingVertical: 0, + }, + chipContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + chip: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 20, + backgroundColor: 'white', + borderWidth: 1.5, + borderColor: '#E5E7EB', + gap: 6, + }, + chipSelected: { + backgroundColor: PRIMARY_COLOR, + borderColor: PRIMARY_COLOR, + }, + chipText: { + fontSize: rf(14), + fontWeight: '500', + color: '#6B7280', + }, + chipTextSelected: { + color: 'white', + }, + checkIcon: { + marginLeft: 2, + }, + selectedCount: { + marginTop: 8, + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: '#EEF2FF', + borderRadius: 8, + alignSelf: 'flex-start', + }, + selectedCountText: { + fontSize: rf(13), + color: PRIMARY_COLOR, + fontWeight: '600', + }, + categoryHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + seeAllButton: { + flexDirection: 'row', + alignItems: 'center', + }, + seeAllText: { + fontSize: rf(14), + color: PRIMARY_COLOR, + fontWeight: '600', + }, + selectedCategoriesContainer: { + marginBottom: 12, + }, + selectedCategoriesLabel: { + fontSize: rf(13), + color: '#6B7280', + marginBottom: 8, + fontWeight: '500', + }, + selectedCategoriesChips: { + flexDirection: 'row', + gap: 8, + }, + selectedCategoryChip: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + backgroundColor: PRIMARY_COLOR, + gap: 6, + }, + selectedCategoryChipText: { + fontSize: rf(13), + color: 'white', + fontWeight: '500', + }, + categoryGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + categoryCard: { + position: 'relative', + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 12, + backgroundColor: 'white', + borderWidth: 1.5, + borderColor: '#E5E7EB', + minWidth: '47%', + flexGrow: 1, + }, + categoryCardSelected: { + backgroundColor: '#EEF2FF', + borderColor: PRIMARY_COLOR, + }, + categoryCardText: { + fontSize: rf(15), + fontWeight: '500', + color: '#374151', + }, + categoryCardTextSelected: { + color: PRIMARY_COLOR, + fontWeight: '600', + }, + checkBadge: { + position: 'absolute', + top: 6, + right: 6, + backgroundColor: PRIMARY_COLOR, + borderRadius: 10, + width: 20, + height: 20, + justifyContent: 'center', + alignItems: 'center', + }, + noResults: { + alignItems: 'center', + paddingVertical: 32, + }, + noResultsText: { + fontSize: rf(14), + color: '#9ca3af', + marginTop: 8, + }, + footer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + flexDirection: 'row', + paddingHorizontal: 20, + paddingVertical: 16, + backgroundColor: 'white', + borderTopWidth: 1, + borderTopColor: '#E5E7EB', + gap: 12, + }, + resetButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + borderRadius: 12, + backgroundColor: 'white', + borderWidth: 1.5, + borderColor: PRIMARY_COLOR, + gap: 6, + }, + resetButtonText: { + color: PRIMARY_COLOR, + fontSize: rf(15), + fontWeight: '600', + }, + applyButton: { + flex: 2, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + borderRadius: 12, + backgroundColor: PRIMARY_COLOR, + gap: 6, + }, + applyButtonText: { + color: 'white', + fontSize: rf(15), + fontWeight: '600', + }, +}); diff --git a/frontend/src/components/GeneralTab.tsx b/frontend/src/components/GeneralTab.tsx index f50ff24b..dcd23c57 100644 --- a/frontend/src/components/GeneralTab.tsx +++ b/frontend/src/components/GeneralTab.tsx @@ -1,265 +1,266 @@ -import { - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, - Image, -} from 'react-native'; -import React, {memo, useEffect} from 'react'; -import Feather from '@expo/vector-icons/Feather'; -import { useForm, Controller } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import {PRIMARY_COLOR} from '../helper/Theme'; +import { + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, + Image, +} from 'react-native'; +import React, {memo, useEffect} from 'react'; +import Feather from '@expo/vector-icons/Feather'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import {PRIMARY_COLOR} from '../helper/Theme'; import { rf } from '../helper/Metric'; -const generalSchema = z.object({ - username: z.string().min(1, 'Username is required'), - userHandle: z.string().min(1, 'User handle is required'), - email: z.string().email('Please enter a valid email'), - about: z.string().min(1, 'About is required'), -}); -export type GeneralFormData = z.infer; - -interface ProfileEditGeneralTab { - user: any; - imgUrl: string; - handleSubmitGeneralDetails: (data: GeneralFormData) => void; - selectImage: () => void; -} -//import fallback_profile from '../assets/avatar.jpg'; - -const GeneralTab = ({ - user, - imgUrl, - handleSubmitGeneralDetails, - selectImage, -}: ProfileEditGeneralTab) => { - const { control, handleSubmit, reset } = useForm({ - resolver: zodResolver(generalSchema), - mode: 'onChange', - defaultValues: { - username: user?.user_name || '', - userHandle: user?.user_handle || '', - email: user?.email || '', - about: user?.about || '', - } - }); - - useEffect(() => { - reset({ - username: user?.user_name || '', - userHandle: user?.user_handle || '', - email: user?.email || '', - about: user?.about || '', - }); - }, [user, reset]); - - return ( - - - {/* Profile Image Section */} - - - - - - - - - - {/* Username Input */} - - Username - ( - <> - - {error && {error.message}} - - )} - /> - - - {/* User Handle Input */} - - User handle - ( - - )} - /> - - - {/* Email Input */} - - Email - ( - - )} - /> - - - {/* About Input */} - - About - ( - <> - - {error && {error.message}} - - )} - /> - - - - {/* Save Button */} - - Save - - - ); -}; - -export default memo(GeneralTab); - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'space-between', - height: '100%', - flexDirection: 'column', - }, - content: { - width: '100%', - flexDirection: 'column', - gap: 15, - alignItems: 'center', - }, - profileImageContainer: { - position: 'relative', - }, - profileImage: { - height: 130, - width: 130, - borderRadius: 100, - }, - editIconContainer: { - position: 'absolute', - backgroundColor: 'white', - padding: 5, - borderRadius: 100, - bottom: 0, - right: 0, - overflow: 'hidden', - }, - editIcon: { - backgroundColor: '#F2F2F2', - borderRadius: 100, - padding: 5, - }, - input: { - width: '100%', - marginBottom: 10, - }, - inputLabel: { - fontSize: 17, - fontWeight: '600', - color: '#222', - marginBottom: 8, - }, - inputControl: { - height: 50, - backgroundColor: '#fff', - paddingHorizontal: 16, - borderRadius: 12, - fontSize: 15, - fontWeight: '500', - color: '#222', - borderWidth: 1, - borderColor: '#C9D3DB', - borderStyle: 'solid', - }, - aboutInput: { - height: 150, - backgroundColor: '#fff', - paddingHorizontal: 16, - paddingVertical: 16, - borderRadius: 12, - fontSize: 15, - fontWeight: '500', - color: '#222', - borderWidth: 1, - borderColor: '#C9D3DB', - borderStyle: 'solid', - }, - btn: { - width: '100%', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 8, - paddingVertical: 10, - paddingHorizontal: 16, - backgroundColor: PRIMARY_COLOR, - marginVertical: 20, - }, - btnText: { - fontSize: 18, - fontWeight: 'bold', - color: 'white', - }, - errorText: { - color: 'red', - fontSize: 12, - marginTop: 4, - }, -}); + +const generalSchema = z.object({ + username: z.string().min(1, 'Username is required'), + userHandle: z.string().min(1, 'User handle is required'), + email: z.string().email('Please enter a valid email'), + about: z.string().min(1, 'About is required'), +}); +export type GeneralFormData = z.infer; + +interface ProfileEditGeneralTab { + user: any; + imgUrl: string; + handleSubmitGeneralDetails: (data: GeneralFormData) => void; + selectImage: () => void; +} +//import fallback_profile from '../assets/avatar.jpg'; + +const GeneralTab = ({ + user, + imgUrl, + handleSubmitGeneralDetails, + selectImage, +}: ProfileEditGeneralTab) => { + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(generalSchema), + mode: 'onChange', + defaultValues: { + username: user?.user_name || '', + userHandle: user?.user_handle || '', + email: user?.email || '', + about: user?.about || '', + } + }); + + useEffect(() => { + reset({ + username: user?.user_name || '', + userHandle: user?.user_handle || '', + email: user?.email || '', + about: user?.about || '', + }); + }, [user, reset]); + + return ( + + + {/* Profile Image Section */} + + + + + + + + + + {/* Username Input */} + + Username + ( + <> + + {error && {error.message}} + + )} + /> + + + {/* User Handle Input */} + + User handle + ( + + )} + /> + + + {/* Email Input */} + + Email + ( + + )} + /> + + + {/* About Input */} + + About + ( + <> + + {error && {error.message}} + + )} + /> + + + + {/* Save Button */} + + Save + + + ); +}; + +export default memo(GeneralTab); + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'space-between', + height: '100%', + flexDirection: 'column', + }, + content: { + width: '100%', + flexDirection: 'column', + gap: 15, + alignItems: 'center', + }, + profileImageContainer: { + position: 'relative', + }, + profileImage: { + height: 130, + width: 130, + borderRadius: 100, + }, + editIconContainer: { + position: 'absolute', + backgroundColor: 'white', + padding: 5, + borderRadius: 100, + bottom: 0, + right: 0, + overflow: 'hidden', + }, + editIcon: { + backgroundColor: '#F2F2F2', + borderRadius: 100, + padding: 5, + }, + input: { + width: '100%', + marginBottom: 10, + }, + inputLabel: { + fontSize: rf(17), + fontWeight: '600', + color: '#222', + marginBottom: 8, + }, + inputControl: { + height: 50, + backgroundColor: '#fff', + paddingHorizontal: 16, + borderRadius: 12, + fontSize: rf(15), + fontWeight: '500', + color: '#222', + borderWidth: 1, + borderColor: '#C9D3DB', + borderStyle: 'solid', + }, + aboutInput: { + height: 150, + backgroundColor: '#fff', + paddingHorizontal: 16, + paddingVertical: 16, + borderRadius: 12, + fontSize: rf(15), + fontWeight: '500', + color: '#222', + borderWidth: 1, + borderColor: '#C9D3DB', + borderStyle: 'solid', + }, + btn: { + width: '100%', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 16, + backgroundColor: PRIMARY_COLOR, + marginVertical: 20, + }, + btnText: { + fontSize: rf(18), + fontWeight: 'bold', + color: 'white', + }, + errorText: { + color: 'red', + fontSize: rf(12), + marginTop: 4, + }, +}); diff --git a/frontend/src/components/GlossaryBottomSheet.tsx b/frontend/src/components/GlossaryBottomSheet.tsx index c95554b9..dea3b648 100644 --- a/frontend/src/components/GlossaryBottomSheet.tsx +++ b/frontend/src/components/GlossaryBottomSheet.tsx @@ -1,155 +1,156 @@ -import React, { useEffect, useState } from 'react'; -import { BackHandler } from 'react-native'; -import { Sheet } from '@tamagui/sheet'; -import { - Button, - Card, - Paragraph, - ScrollView, - Text, - XStack, - YStack, -} from 'tamagui'; -import type { GlossaryTerm } from '../constants/glossary'; +import React, { useEffect, useState } from 'react'; +import { BackHandler } from 'react-native'; +import { Sheet } from '@tamagui/sheet'; +import { + Button, + Card, + Paragraph, + ScrollView, + Text, + XStack, + YStack, +} from 'tamagui'; +import type { GlossaryTerm } from '../constants/glossary'; import { rf } from '../helper/Metric'; -export type GlossaryBottomSheetProps = GlossaryTerm & { - visible: boolean; - onClose: () => void; -}; - -export default function GlossaryBottomSheet({ - visible, - term, - definition, - category, - relatedTerms = [], - onClose, -}: GlossaryBottomSheetProps) { - const [open, setOpen] = useState(visible); - - useEffect(() => { - setOpen(visible); - }, [visible]); - - useEffect(() => { - if (!open) return undefined; - - const subscription = BackHandler.addEventListener('hardwareBackPress', () => { - handleClose(); - return true; - }); - - return () => subscription.remove(); - }, [open]); - - const handleClose = () => { - setOpen(false); - onClose(); - }; - - const handleOpenChange = (isOpen: boolean) => { - setOpen(isOpen); - if (!isOpen) { - onClose(); - } - }; - - return ( - - - - - - - - {!!category && ( - - {category} - - )} - - {term} - - - - - - - - - - {definition} - - - - {relatedTerms.length > 0 && ( - - - Related terms - - - {relatedTerms.map((relatedTerm) => ( - - {relatedTerm} - - ))} - - - )} - - - - - ); -} + +export type GlossaryBottomSheetProps = GlossaryTerm & { + visible: boolean; + onClose: () => void; +}; + +export default function GlossaryBottomSheet({ + visible, + term, + definition, + category, + relatedTerms = [], + onClose, +}: GlossaryBottomSheetProps) { + const [open, setOpen] = useState(visible); + + useEffect(() => { + setOpen(visible); + }, [visible]); + + useEffect(() => { + if (!open) return undefined; + + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + handleClose(); + return true; + }); + + return () => subscription.remove(); + }, [open]); + + const handleClose = () => { + setOpen(false); + onClose(); + }; + + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + onClose(); + } + }; + + return ( + + + + + + + + {!!category && ( + + {category} + + )} + + {term} + + + + + + + + + + {definition} + + + + {relatedTerms.length > 0 && ( + + + Related terms + + + {relatedTerms.map((relatedTerm) => ( + + {relatedTerm} + + ))} + + + )} + + + + + ); +} diff --git a/frontend/src/components/GuestPlaceholderScreen.tsx b/frontend/src/components/GuestPlaceholderScreen.tsx index 2f7f626b..7d6e9f3b 100644 --- a/frontend/src/components/GuestPlaceholderScreen.tsx +++ b/frontend/src/components/GuestPlaceholderScreen.tsx @@ -1,141 +1,142 @@ -import React, { useState } from 'react'; -import { YStack, Text, Button } from 'tamagui'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation, NavigationProp } from '@react-navigation/native'; -import LottieView from 'lottie-react-native'; - -import { RootStackParamList } from '../type'; -import BenefitsModal from './BenefitsModal'; - -interface GuestPlaceholderScreenProps { - title?: string; - description?: string; -} - -const GuestPlaceholderScreen: React.FC = ({ - title = 'Join the Community', - description = 'Sign up or sign in to access personalized features, interact with the community, and manage your profile.', -}) => { - const inset = useSafeAreaInsets(); - const navigation = useNavigation>(); - const [isBenefitsModalVisible, setIsBenefitsModalVisible] = useState(false); - - return ( - - - - - - - {title} - - - - {description} - - - - - - - - - - - setIsBenefitsModalVisible(false)} - onSignUp={() => { - setIsBenefitsModalVisible(false); - navigation.navigate('SignUpScreenFirst'); - }} - /> - - ); -}; +import React, { useState } from 'react'; +import { YStack, Text, Button } from 'tamagui'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import LottieView from 'lottie-react-native'; + +import { RootStackParamList } from '../type'; +import BenefitsModal from './BenefitsModal'; import { rf } from '../helper/Metric'; + +interface GuestPlaceholderScreenProps { + title?: string; + description?: string; +} + +const GuestPlaceholderScreen: React.FC = ({ + title = 'Join the Community', + description = 'Sign up or sign in to access personalized features, interact with the community, and manage your profile.', +}) => { + const inset = useSafeAreaInsets(); + const navigation = useNavigation>(); + const [isBenefitsModalVisible, setIsBenefitsModalVisible] = useState(false); + + return ( + + + + + + + {title} + + + + {description} + + + + + + + + + + + setIsBenefitsModalVisible(false)} + onSignUp={() => { + setIsBenefitsModalVisible(false); + navigation.navigate('SignUpScreenFirst'); + }} + /> + + ); +}; + export default GuestPlaceholderScreen; \ No newline at end of file diff --git a/frontend/src/components/HeaderRightMenu.tsx b/frontend/src/components/HeaderRightMenu.tsx index 65e09a30..bd1bc3d3 100644 --- a/frontend/src/components/HeaderRightMenu.tsx +++ b/frontend/src/components/HeaderRightMenu.tsx @@ -1,235 +1,236 @@ -import {useNavigation} from '@react-navigation/native'; -import {useCallback, useRef, useState} from 'react'; -import {StackNavigationProp} from '@react-navigation/stack'; -import {useDispatch, useSelector} from 'react-redux'; -import Snackbar from 'react-native-snackbar'; -import {BottomSheetModal} from '@gorhom/bottom-sheet'; -import {setPodcasts} from '../store/dataSlice'; -import {RootStackParamList, Category, CategoryType} from '../type'; -import FilterModal from './FilterModal'; -import { - XStack, - YStack, - Button, - Popover, - Separator, - Text, -} from 'tamagui'; -import {Feather, FontAwesome, MaterialCommunityIcons} from '@expo/vector-icons'; -import {ON_PRIMARY_COLOR} from '../helper/Theme'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import {useFilterPodcasts} from '../hooks/useFilterPodcasts'; - -interface Props { - onClick: () => void; -} - -const HeaderRightMenu = ({onClick}: Props) => { - const navigation = useNavigation>(); - const [menuOpen, setMenuOpen] = useState(false); - const [sortingType, setSortingType] = useState(''); - const [selectCategoryList, setSelectedCategoryList] = useState( - [], - ); - - const {categories} = useSelector((state: any) => state.data); - const {user_token, isGuest} = useSelector((state: any) => state.user); - const {isConnected} = useSelector((state: any) => state.network); - const dispatch = useDispatch(); - - const {mutate: filterPodcast, isPending: filterPodcastPending} = - useFilterPodcasts(); - - const bottomSheetModalRef = useRef(null); - - const handlePresentModalPress = useCallback(() => { - bottomSheetModalRef.current?.present(); - setMenuOpen(false); - }, []); - - - const handleCategorySelection = (category: any) => { - setSelectedCategoryList(prevList => { - const isAlreadySelected = prevList.some((p: Category) => - (p.id !== undefined && category.id !== undefined && p.id === category.id) || - (p._id !== undefined && category._id !== undefined && p._id === category._id) || - (p.name === category.name) - ); - return isAlreadySelected - ? prevList.filter(item => !( - (item.id !== undefined && category.id !== undefined && item.id === category.id) || - (item._id !== undefined && category._id !== undefined && item._id === category._id) || - (item.name === category.name) - )) - : [...prevList, category as Category]; - }); - }; - - const handleFilterReset = () => { - setSelectedCategoryList([]); - setSortingType(''); - }; - - return ( - - - - ) : null} - - - - {/* Notification */} - - - - ); -}; - - - - -export default HomeScreenHeader; + + const HomeScreenHeader = ({ + handlePresentModalPress, + onTextInputChange, + onNotificationClick, + unreadCount, + hasActiveFilters = false, + onFilterReset, +}: HomeScreenHeaderProps) => { + return ( + + + + {/* Search Bar */} + + + + {hasActiveFilters && onFilterReset ? ( + + ) : null} + + + + {/* Notification */} + + + + ); +}; + + + + +export default HomeScreenHeader; diff --git a/frontend/src/components/ImprovementCard.tsx b/frontend/src/components/ImprovementCard.tsx index 74fa3c02..353fae4d 100644 --- a/frontend/src/components/ImprovementCard.tsx +++ b/frontend/src/components/ImprovementCard.tsx @@ -1,243 +1,244 @@ -import { - StyleSheet, - Text, - View, - Pressable, - TouchableOpacity, - Dimensions, -} from 'react-native'; -import React from 'react'; -import {fp, hp} from '../helper/Metric'; -import {ImprovementCardProps} from '../type'; -import { formatDateShortYear } from '../helper/dateUtils'; -import {BUTTON_COLOR, PRIMARY_COLOR} from '../helper/Theme'; -import {handleExternalClick, StatusEnum} from '../helper/Utils'; -//import io from 'socket.io-client'; -import AntDesign from '@expo/vector-icons/AntDesign'; -import AutoHeightWebView from '@brown-bear/react-native-autoheight-webview'; - - -const ImprovementCard = ({item, onNavigate}: ImprovementCardProps) => { - const backgroundColor = - item?.status === StatusEnum.PUBLISHED - ? 'green' - : item?.status === StatusEnum.DISCARDED - ? 'red' - : BUTTON_COLOR; - - // console.log('ImprovementCard item:', item); - - const extractBody = (html: string) => { - if (!html) return '

No reason provided.

'; - - const match = html.match(/]*>([\s\S]*?)<\/body>/i); - return match ? match[1] : html; -}; - - return ( - { - onNavigate(item); - }}> - - {/* Image Section */} - - - {/* Share Icon */} - - {/* Title & Footer Text */} - {item && item.article && item?.article.tags && ( - - {item?.article.tags.map(tag => tag.name).join(' | ')} - - )} - {`Article Title: ${item?.article?.title}`} - - {'Request Reason: '} - - - {/* */} - - console.log(size.height)} - files={[ - { - href: 'cssfileaddress', - type: 'text/css', - rel: 'stylesheet', - }, - ]} - originWhitelist={['*']} - source={{html: extractBody(item?.edit_reason) || '

No reason provided.

'}} - scalesPageToFit={true} - viewportContent={'width=device-width, user-scalable=no'} - onShouldStartLoadWithRequest={handleExternalClick} - /> -
- - - Last updated: {''} - {formatDateShortYear(item?.last_updated)} - - - - - {item?.status ? item.status.toLocaleUpperCase() : 'Not found'} - - - { - // onclick(item); - onNavigate(item); - }}> - View - - - -
-
-
- ); -}; - -export default ImprovementCard; - -const styles = StyleSheet.create({ - cardContainer: { - flex: 0, - width: '100%', - backgroundColor: '#ffffff', - flexDirection: 'row', - marginVertical: hp(1.5), - overflow: 'hidden', - borderRadius: 16, - shadowColor: '#000', - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.08, - shadowRadius: 12, - elevation: 5, - borderWidth: 1, - borderColor: '#F0F0F0', - }, - image: { - flex: 0.8, - resizeMode: 'cover', - }, - - likeSaveContainer: { - flexDirection: 'row', - width: '100%', - marginTop: 6, - justifyContent: 'space-between', - }, - - likeSaveChildContainer: { - flexDirection: 'row', - justifyContent: 'flex-start', - marginHorizontal: hp(0), - marginVertical: hp(1), - }, - textContainer: { - flex: 1, - backgroundColor: 'white', - paddingHorizontal: 16, - paddingVertical: 16, - }, - title: { - fontSize: fp(4.8), - fontWeight: '800', - color: '#1A1A1A', - marginBottom: 8, - lineHeight: fp(5.8), - letterSpacing: 0.3, - }, - description: { - fontSize: fp(3.4), - fontWeight: '500', - lineHeight: 20, - color: '#666666', - marginBottom: 12, - }, - footerText: { - fontSize: fp(3.5), - fontWeight: '700', - color: BUTTON_COLOR, - marginBottom: 8, - textTransform: 'uppercase', - letterSpacing: 0.5, - }, - - footerText1: { - fontSize: fp(3.3), - fontWeight: '500', - color: '#5A5A5A', - marginBottom: 4, - }, - - footerContainer: { - flex: 0, - width: '100%', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginTop: 4, - }, - shareIconContainer: { - position: 'absolute', - top: 8, - right: 8, - zIndex: 1, - backgroundColor: '#F8F8F8', - borderRadius: 20, - padding: 8, - }, - viewContainer: { - justifyContent: 'space-between', - flexDirection: 'row', - paddingHorizontal: 2, - marginTop: 8, - paddingTop: 12, - borderTopWidth: 1, - borderTopColor: '#F0F0F0', - }, - viewInnnerContainer: { - justifyContent: 'flex-start', - flexDirection: 'row', - padding: 4, - backgroundColor: '#F0F8FF', - borderRadius: 8, - paddingHorizontal: 12, - }, - viewText: { - fontSize: fp(3.8), - color: PRIMARY_COLOR, - fontWeight: '700', - marginRight: 4, - }, -}); +import { + StyleSheet, + Text, + View, + Pressable, + TouchableOpacity, + Dimensions, +} from 'react-native'; +import React from 'react'; +import {fp, hp} from '../helper/Metric'; +import {ImprovementCardProps} from '../type'; +import { formatDateShortYear } from '../helper/dateUtils'; +import {BUTTON_COLOR, PRIMARY_COLOR} from '../helper/Theme'; +import {handleExternalClick, StatusEnum} from '../helper/Utils'; +//import io from 'socket.io-client'; +import AntDesign from '@expo/vector-icons/AntDesign'; +import AutoHeightWebView from '@brown-bear/react-native-autoheight-webview'; import { rf } from '../helper/Metric'; + + + +const ImprovementCard = ({item, onNavigate}: ImprovementCardProps) => { + const backgroundColor = + item?.status === StatusEnum.PUBLISHED + ? 'green' + : item?.status === StatusEnum.DISCARDED + ? 'red' + : BUTTON_COLOR; + + // console.log('ImprovementCard item:', item); + + const extractBody = (html: string) => { + if (!html) return '

No reason provided.

'; + + const match = html.match(/]*>([\s\S]*?)<\/body>/i); + return match ? match[1] : html; +}; + + return ( + { + onNavigate(item); + }}> + + {/* Image Section */} + + + {/* Share Icon */} + + {/* Title & Footer Text */} + {item && item.article && item?.article.tags && ( + + {item?.article.tags.map(tag => tag.name).join(' | ')} + + )} + {`Article Title: ${item?.article?.title}`} + + {'Request Reason: '} + + + {/* */} + + console.log(size.height)} + files={[ + { + href: 'cssfileaddress', + type: 'text/css', + rel: 'stylesheet', + }, + ]} + originWhitelist={['*']} + source={{html: extractBody(item?.edit_reason) || '

No reason provided.

'}} + scalesPageToFit={true} + viewportContent={'width=device-width, user-scalable=no'} + onShouldStartLoadWithRequest={handleExternalClick} + /> +
+ + + Last updated: {''} + {formatDateShortYear(item?.last_updated)} + + + + + {item?.status ? item.status.toLocaleUpperCase() : 'Not found'} + + + { + // onclick(item); + onNavigate(item); + }}> + View + + + +
+
+
+ ); +}; + +export default ImprovementCard; + +const styles = StyleSheet.create({ + cardContainer: { + flex: 0, + width: '100%', + backgroundColor: '#ffffff', + flexDirection: 'row', + marginVertical: hp(1.5), + overflow: 'hidden', + borderRadius: 16, + shadowColor: '#000', + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.08, + shadowRadius: 12, + elevation: 5, + borderWidth: 1, + borderColor: '#F0F0F0', + }, + image: { + flex: 0.8, + resizeMode: 'cover', + }, + + likeSaveContainer: { + flexDirection: 'row', + width: '100%', + marginTop: 6, + justifyContent: 'space-between', + }, + + likeSaveChildContainer: { + flexDirection: 'row', + justifyContent: 'flex-start', + marginHorizontal: hp(0), + marginVertical: hp(1), + }, + textContainer: { + flex: 1, + backgroundColor: 'white', + paddingHorizontal: 16, + paddingVertical: 16, + }, + title: { + fontSize: fp(4.8), + fontWeight: '800', + color: '#1A1A1A', + marginBottom: 8, + lineHeight: fp(5.8), + letterSpacing: 0.3, + }, + description: { + fontSize: fp(3.4), + fontWeight: '500', + lineHeight: 20, + color: '#666666', + marginBottom: 12, + }, + footerText: { + fontSize: fp(3.5), + fontWeight: '700', + color: BUTTON_COLOR, + marginBottom: 8, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + + footerText1: { + fontSize: fp(3.3), + fontWeight: '500', + color: '#5A5A5A', + marginBottom: 4, + }, + + footerContainer: { + flex: 0, + width: '100%', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 4, + }, + shareIconContainer: { + position: 'absolute', + top: 8, + right: 8, + zIndex: 1, + backgroundColor: '#F8F8F8', + borderRadius: 20, + padding: 8, + }, + viewContainer: { + justifyContent: 'space-between', + flexDirection: 'row', + paddingHorizontal: 2, + marginTop: 8, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: '#F0F0F0', + }, + viewInnnerContainer: { + justifyContent: 'flex-start', + flexDirection: 'row', + padding: 4, + backgroundColor: '#F0F8FF', + borderRadius: 8, + paddingHorizontal: 12, + }, + viewText: { + fontSize: fp(3.8), + color: PRIMARY_COLOR, + fontWeight: '700', + marginRight: 4, + }, +}); diff --git a/frontend/src/components/LanguagePreferenceSelector.tsx b/frontend/src/components/LanguagePreferenceSelector.tsx index 6733fdd2..fb54a214 100644 --- a/frontend/src/components/LanguagePreferenceSelector.tsx +++ b/frontend/src/components/LanguagePreferenceSelector.tsx @@ -1,342 +1,343 @@ -import React, {useCallback} from 'react'; -import {View, Text, StyleSheet, TouchableOpacity, ScrollView, ActivityIndicator} from 'react-native'; -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import {INDIAN_LANGUAGES, LanguageCode} from '../constants/languages'; -import {usePreferences} from '../contexts/PreferencesContext'; -import {PRIMARY_COLOR} from '../helper/Theme'; - -interface LanguagePreferenceSelectorProps { - /** Optional title for the selector */ - title?: string; - /** Optional description/help text */ - description?: string; - /** Whether to show the component in a modal/dialog style (with header) */ - showHeader?: boolean; - /** Callback when preferences are saved */ - onSave?: () => void; - /** Whether to show only a subset of languages */ - maxLanguagesToSelect?: number; -} - -/** - * LanguagePreferenceSelector - Component for users to select preferred languages - * Saves preferences to PreferencesContext which persists to secure storage - */ -export const LanguagePreferenceSelector: React.FC = ({ - title = 'Select Preferred Languages', - description = 'Choose the languages you want to see articles and podcasts in', - showHeader = true, - onSave, - maxLanguagesToSelect, -}) => { - const {preferredLanguages, setPreferredLanguages, isLoading} = usePreferences(); - - const toggleLanguage = useCallback( - (languageCode: LanguageCode) => { - const isSelected = preferredLanguages.includes(languageCode); - let updated: LanguageCode[]; - - if (isSelected) { - updated = preferredLanguages.filter(lang => lang !== languageCode); - } else { - // Check max limit if specified - if (maxLanguagesToSelect && preferredLanguages.length >= maxLanguagesToSelect) { - return; // Don't allow selecting more than max - } - updated = [...preferredLanguages, languageCode]; - } - - setPreferredLanguages(updated); - }, - [preferredLanguages, setPreferredLanguages, maxLanguagesToSelect] - ); - - const selectAll = useCallback(() => { - const allCodes: LanguageCode[] = INDIAN_LANGUAGES.map(lang => lang.code); - setPreferredLanguages(allCodes); - }, [setPreferredLanguages]); - - const clearAll = useCallback(() => { - setPreferredLanguages([]); - }, [setPreferredLanguages]); - - if (isLoading) { - return ( - - - Loading preferences... - - ); - } - - return ( - - {showHeader && ( - - {title} - {description} - - )} - - {/* Quick Actions */} - - - - Select All - - - - Clear All - - - - {/* Language Selection Grid */} - - {INDIAN_LANGUAGES.map((language, index) => { - const isSelected = preferredLanguages.includes(language.code); - const isDisabled = Boolean( - maxLanguagesToSelect && - !isSelected && - preferredLanguages.length >= maxLanguagesToSelect - ); - - return ( - toggleLanguage(language.code)} - disabled={isDisabled} - activeOpacity={isDisabled ? 1 : 0.7}> - - {isSelected && ( - - )} - - - {language.name} - - - {language.code} - - - ); - })} - - - {/* Selection Summary */} - {preferredLanguages.length > 0 && ( - - - - {preferredLanguages.length} language{preferredLanguages.length > 1 ? 's' : ''}{' '} - selected for personalized content - - - )} - - {/* Save Button (if callback provided) */} - {onSave && ( - - Save Preferences - - )} - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#f8f9fa', - }, - header: { - paddingHorizontal: 16, - paddingVertical: 20, - backgroundColor: 'white', - borderBottomWidth: 1, - borderBottomColor: '#e5e7eb', - }, - title: { - fontSize: 18, - fontWeight: '700', - color: '#1f2937', - marginBottom: 8, - }, - description: { - fontSize: 14, - color: '#6b7280', - lineHeight: 20, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'white', - }, - loadingText: { - marginTop: 12, - fontSize: 14, - color: '#6b7280', - }, - quickActionsContainer: { - flexDirection: 'row', - gap: 12, - paddingHorizontal: 16, - paddingVertical: 12, - backgroundColor: 'white', - borderBottomWidth: 1, - borderBottomColor: '#e5e7eb', - }, - quickActionButton: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - paddingVertical: 10, - paddingHorizontal: 12, - borderRadius: 8, - borderWidth: 1.5, - backgroundColor: '#f8f9fa', - }, - quickActionText: { - fontSize: 13, - fontWeight: '600', - }, - languagesContainer: { - flex: 1, - backgroundColor: 'white', - }, - languagesContentContainer: { - paddingHorizontal: 16, - paddingVertical: 12, - gap: 8, - }, - languageChip: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - paddingVertical: 12, - borderRadius: 8, - backgroundColor: '#f3f4f6', - borderWidth: 1, - borderColor: '#e5e7eb', - }, - languageChipSelected: { - backgroundColor: '#eff6ff', - borderColor: PRIMARY_COLOR, - }, - languageChipDisabled: { - opacity: 0.5, - }, - checkbox: { - width: 20, - height: 20, - borderRadius: 4, - borderWidth: 2, - borderColor: '#d1d5db', - marginRight: 12, - justifyContent: 'center', - alignItems: 'center', - }, - checkboxSelected: { - backgroundColor: PRIMARY_COLOR, - borderColor: PRIMARY_COLOR, - }, - checkboxDisabled: { - borderColor: '#d1d5db', - }, - languageName: { - fontSize: 15, - fontWeight: '600', - color: '#1f2937', - flex: 1, - }, - languageNameSelected: { - color: PRIMARY_COLOR, - }, - languageNameDisabled: { - color: '#9ca3af', - }, - languageCode: { - fontSize: 12, - color: '#9ca3af', - fontWeight: '500', - }, - languageCodeSelected: { - color: PRIMARY_COLOR, - }, - languageCodeDisabled: { - color: '#d1d5db', - }, - summaryContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - paddingHorizontal: 16, - paddingVertical: 12, - marginHorizontal: 16, - marginVertical: 12, - borderRadius: 8, - backgroundColor: '#eff6ff', - borderLeftWidth: 4, - borderLeftColor: PRIMARY_COLOR, - }, - summaryText: { - fontSize: 13, - color: PRIMARY_COLOR, - fontWeight: '500', - flex: 1, - }, - saveButton: { - marginHorizontal: 16, - marginBottom: 16, - paddingVertical: 14, - borderRadius: 8, - backgroundColor: PRIMARY_COLOR, - alignItems: 'center', - justifyContent: 'center', - }, - saveButtonDisabled: { - backgroundColor: '#d1d5db', - }, - saveButtonText: { - fontSize: 16, - fontWeight: '700', - color: 'white', - }, -}); +import React, {useCallback} from 'react'; +import {View, Text, StyleSheet, TouchableOpacity, ScrollView, ActivityIndicator} from 'react-native'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import {INDIAN_LANGUAGES, LanguageCode} from '../constants/languages'; +import {usePreferences} from '../contexts/PreferencesContext'; +import {PRIMARY_COLOR} from '../helper/Theme'; import { rf } from '../helper/Metric'; + +interface LanguagePreferenceSelectorProps { + /** Optional title for the selector */ + title?: string; + /** Optional description/help text */ + description?: string; + /** Whether to show the component in a modal/dialog style (with header) */ + showHeader?: boolean; + /** Callback when preferences are saved */ + onSave?: () => void; + /** Whether to show only a subset of languages */ + maxLanguagesToSelect?: number; +} + +/** + * LanguagePreferenceSelector - Component for users to select preferred languages + * Saves preferences to PreferencesContext which persists to secure storage + */ +export const LanguagePreferenceSelector: React.FC = ({ + title = 'Select Preferred Languages', + description = 'Choose the languages you want to see articles and podcasts in', + showHeader = true, + onSave, + maxLanguagesToSelect, +}) => { + const {preferredLanguages, setPreferredLanguages, isLoading} = usePreferences(); + + const toggleLanguage = useCallback( + (languageCode: LanguageCode) => { + const isSelected = preferredLanguages.includes(languageCode); + let updated: LanguageCode[]; + + if (isSelected) { + updated = preferredLanguages.filter(lang => lang !== languageCode); + } else { + // Check max limit if specified + if (maxLanguagesToSelect && preferredLanguages.length >= maxLanguagesToSelect) { + return; // Don't allow selecting more than max + } + updated = [...preferredLanguages, languageCode]; + } + + setPreferredLanguages(updated); + }, + [preferredLanguages, setPreferredLanguages, maxLanguagesToSelect] + ); + + const selectAll = useCallback(() => { + const allCodes: LanguageCode[] = INDIAN_LANGUAGES.map(lang => lang.code); + setPreferredLanguages(allCodes); + }, [setPreferredLanguages]); + + const clearAll = useCallback(() => { + setPreferredLanguages([]); + }, [setPreferredLanguages]); + + if (isLoading) { + return ( + + + Loading preferences... + + ); + } + + return ( + + {showHeader && ( + + {title} + {description} + + )} + + {/* Quick Actions */} + + + + Select All + + + + Clear All + + + + {/* Language Selection Grid */} + + {INDIAN_LANGUAGES.map((language, index) => { + const isSelected = preferredLanguages.includes(language.code); + const isDisabled = Boolean( + maxLanguagesToSelect && + !isSelected && + preferredLanguages.length >= maxLanguagesToSelect + ); + + return ( + toggleLanguage(language.code)} + disabled={isDisabled} + activeOpacity={isDisabled ? 1 : 0.7}> + + {isSelected && ( + + )} + + + {language.name} + + + {language.code} + + + ); + })} + + + {/* Selection Summary */} + {preferredLanguages.length > 0 && ( + + + + {preferredLanguages.length} language{preferredLanguages.length > 1 ? 's' : ''}{' '} + selected for personalized content + + + )} + + {/* Save Button (if callback provided) */} + {onSave && ( + + Save Preferences + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f8f9fa', + }, + header: { + paddingHorizontal: 16, + paddingVertical: 20, + backgroundColor: 'white', + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', + }, + title: { + fontSize: rf(18), + fontWeight: '700', + color: '#1f2937', + marginBottom: 8, + }, + description: { + fontSize: rf(14), + color: '#6b7280', + lineHeight: 20, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'white', + }, + loadingText: { + marginTop: 12, + fontSize: rf(14), + color: '#6b7280', + }, + quickActionsContainer: { + flexDirection: 'row', + gap: 12, + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: 'white', + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', + }, + quickActionButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingVertical: 10, + paddingHorizontal: 12, + borderRadius: 8, + borderWidth: 1.5, + backgroundColor: '#f8f9fa', + }, + quickActionText: { + fontSize: rf(13), + fontWeight: '600', + }, + languagesContainer: { + flex: 1, + backgroundColor: 'white', + }, + languagesContentContainer: { + paddingHorizontal: 16, + paddingVertical: 12, + gap: 8, + }, + languageChip: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 12, + borderRadius: 8, + backgroundColor: '#f3f4f6', + borderWidth: 1, + borderColor: '#e5e7eb', + }, + languageChipSelected: { + backgroundColor: '#eff6ff', + borderColor: PRIMARY_COLOR, + }, + languageChipDisabled: { + opacity: 0.5, + }, + checkbox: { + width: 20, + height: 20, + borderRadius: 4, + borderWidth: 2, + borderColor: '#d1d5db', + marginRight: 12, + justifyContent: 'center', + alignItems: 'center', + }, + checkboxSelected: { + backgroundColor: PRIMARY_COLOR, + borderColor: PRIMARY_COLOR, + }, + checkboxDisabled: { + borderColor: '#d1d5db', + }, + languageName: { + fontSize: rf(15), + fontWeight: '600', + color: '#1f2937', + flex: 1, + }, + languageNameSelected: { + color: PRIMARY_COLOR, + }, + languageNameDisabled: { + color: '#9ca3af', + }, + languageCode: { + fontSize: rf(12), + color: '#9ca3af', + fontWeight: '500', + }, + languageCodeSelected: { + color: PRIMARY_COLOR, + }, + languageCodeDisabled: { + color: '#d1d5db', + }, + summaryContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingHorizontal: 16, + paddingVertical: 12, + marginHorizontal: 16, + marginVertical: 12, + borderRadius: 8, + backgroundColor: '#eff6ff', + borderLeftWidth: 4, + borderLeftColor: PRIMARY_COLOR, + }, + summaryText: { + fontSize: rf(13), + color: PRIMARY_COLOR, + fontWeight: '500', + flex: 1, + }, + saveButton: { + marginHorizontal: 16, + marginBottom: 16, + paddingVertical: 14, + borderRadius: 8, + backgroundColor: PRIMARY_COLOR, + alignItems: 'center', + justifyContent: 'center', + }, + saveButtonDisabled: { + backgroundColor: '#d1d5db', + }, + saveButtonText: { + fontSize: rf(16), + fontWeight: '700', + color: 'white', + }, +}); + export default LanguagePreferenceSelector; \ No newline at end of file diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx index 6bc90cef..68bf5378 100644 --- a/frontend/src/components/LoadingSpinner.tsx +++ b/frontend/src/components/LoadingSpinner.tsx @@ -1,82 +1,83 @@ -import React from 'react'; -import { - ActivityIndicator, - StyleProp, - StyleSheet, - Text, - TextStyle, - View, - ViewStyle, -} from 'react-native'; -import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; +import React from 'react'; +import { + ActivityIndicator, + StyleProp, + StyleSheet, + Text, + TextStyle, + View, + ViewStyle, +} from 'react-native'; +import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; import { rf } from '../helper/Metric'; -type LoadingSpinnerProps = { - size?: 'small' | 'large' | number; - color?: string; - text?: string; - subText?: string; - fullScreen?: boolean; - overlay?: boolean; - containerStyle?: StyleProp; - textStyle?: StyleProp; - testID?: string; -}; - -const LoadingSpinner = ({ - size = 'large', - color = PRIMARY_COLOR, - text, - subText, - fullScreen = false, - overlay = false, - containerStyle, - textStyle, - testID = 'loading-spinner', -}: LoadingSpinnerProps) => ( - - - {text ? {text} : null} - {subText ? {subText} : null} - -); - -export default LoadingSpinner; - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - justifyContent: 'center', - }, - fullScreen: { - flex: 1, - padding: 24, - }, - overlay: { - position: 'absolute', - height: '100%', - width: '100%', - zIndex: 10, - backgroundColor: ON_PRIMARY_COLOR, - }, - text: { - marginTop: 12, - fontSize: 16, - fontWeight: '600', - textAlign: 'center', - }, - subText: { - marginTop: 6, - fontSize: 14, - color: '#7f8c8d', - textAlign: 'center', - }, -}); + +type LoadingSpinnerProps = { + size?: 'small' | 'large' | number; + color?: string; + text?: string; + subText?: string; + fullScreen?: boolean; + overlay?: boolean; + containerStyle?: StyleProp; + textStyle?: StyleProp; + testID?: string; +}; + +const LoadingSpinner = ({ + size = 'large', + color = PRIMARY_COLOR, + text, + subText, + fullScreen = false, + overlay = false, + containerStyle, + textStyle, + testID = 'loading-spinner', +}: LoadingSpinnerProps) => ( + + + {text ? {text} : null} + {subText ? {subText} : null} + +); + +export default LoadingSpinner; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, + fullScreen: { + flex: 1, + padding: 24, + }, + overlay: { + position: 'absolute', + height: '100%', + width: '100%', + zIndex: 10, + backgroundColor: ON_PRIMARY_COLOR, + }, + text: { + marginTop: 12, + fontSize: rf(16), + fontWeight: '600', + textAlign: 'center', + }, + subText: { + marginTop: 6, + fontSize: rf(14), + color: '#7f8c8d', + textAlign: 'center', + }, +}); diff --git a/frontend/src/components/Message.tsx b/frontend/src/components/Message.tsx index 30a85086..d572231a 100644 --- a/frontend/src/components/Message.tsx +++ b/frontend/src/components/Message.tsx @@ -1,70 +1,71 @@ + +import React from 'react'; +import {View, Text, StyleSheet} from 'react-native'; +import {PRIMARY_COLOR} from '../helper/Theme'; import { rf } from '../helper/Metric'; -import React from 'react'; -import {View, Text, StyleSheet} from 'react-native'; -import {PRIMARY_COLOR} from '../helper/Theme'; - -interface MessageProps { - message: string; - isUserMessage: boolean; -} - -const Message = ({message, isUserMessage}: MessageProps) => { - return ( - - - - {isUserMessage ? 'You' : 'Chatbot'} - - - {message} - - - - ); -}; - -const styles = StyleSheet.create({ - messageContainer: { - width: '100%', - marginBottom: 15, - }, - messageBubble: { - maxWidth: '90%', - padding: 10, - borderRadius: 15, - marginBottom: 2, - }, - senderText: { - fontSize: 14, - }, - messageText: { - marginTop: 5, - fontSize: 15, - }, -}); - -export default Message; + +interface MessageProps { + message: string; + isUserMessage: boolean; +} + +const Message = ({message, isUserMessage}: MessageProps) => { + return ( + + + + {isUserMessage ? 'You' : 'Chatbot'} + + + {message} + + + + ); +}; + +const styles = StyleSheet.create({ + messageContainer: { + width: '100%', + marginBottom: 15, + }, + messageBubble: { + maxWidth: '90%', + padding: 10, + borderRadius: 15, + marginBottom: 2, + }, + senderText: { + fontSize: rf(14), + }, + messageText: { + marginTop: 5, + fontSize: rf(15), + }, +}); + +export default Message; diff --git a/frontend/src/components/NetworkCheck.tsx b/frontend/src/components/NetworkCheck.tsx index 9b58d806..37e31e9a 100644 --- a/frontend/src/components/NetworkCheck.tsx +++ b/frontend/src/components/NetworkCheck.tsx @@ -1,84 +1,85 @@ -import React from 'react'; -import {View, StyleSheet} from 'react-native'; -import LottieView from 'lottie-react-native'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - withDelay, -} from 'react-native-reanimated'; -import {PRIMARY_COLOR} from '../helper/Theme'; -const NetworkCheck = () => { - // Shared values for text opacity - const fadeText1 = useSharedValue(0); - const fadeText2 = useSharedValue(0); - const fadeText3 = useSharedValue(0); - // Animated styles for text - const textStyle1 = useAnimatedStyle(() => ({ - opacity: fadeText1.value, - })); +import React from 'react'; +import {View, StyleSheet} from 'react-native'; +import LottieView from 'lottie-react-native'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + withDelay, +} from 'react-native-reanimated'; +import {PRIMARY_COLOR} from '../helper/Theme'; import { rf } from '../helper/Metric'; - const textStyle2 = useAnimatedStyle(() => ({ - opacity: fadeText2.value, - })); - - const textStyle3 = useAnimatedStyle(() => ({ - opacity: fadeText3.value, - })); - // Start the animation loop - React.useEffect(() => { - fadeText1.value = withTiming(1, {duration: 800}); - fadeText2.value = withDelay(800, withTiming(1, {duration: 800})); - fadeText3.value = withDelay(1600, withTiming(1, {duration: 800})); - }, [fadeText1, fadeText2, fadeText3]); - return ( - - - - - - Please wait... - - - We're checking your network connection - - - This may take a few moments - - - ); -}; - -export default NetworkCheck; - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#f4f4f4', - }, - lottieContainer: { - flexDirection: 'row', - marginBottom: 20, - }, - lottieView: { - width: '100%', - aspectRatio: 1, - }, - loadingText: { - fontSize: 18, - color: PRIMARY_COLOR, - fontWeight: '600', - }, - subText: { - fontSize: 14, - color: '#7f8c8d', - marginTop: 5, - }, -}); +const NetworkCheck = () => { + // Shared values for text opacity + const fadeText1 = useSharedValue(0); + const fadeText2 = useSharedValue(0); + const fadeText3 = useSharedValue(0); + // Animated styles for text + const textStyle1 = useAnimatedStyle(() => ({ + opacity: fadeText1.value, + })); + + const textStyle2 = useAnimatedStyle(() => ({ + opacity: fadeText2.value, + })); + + const textStyle3 = useAnimatedStyle(() => ({ + opacity: fadeText3.value, + })); + // Start the animation loop + React.useEffect(() => { + fadeText1.value = withTiming(1, {duration: 800}); + fadeText2.value = withDelay(800, withTiming(1, {duration: 800})); + fadeText3.value = withDelay(1600, withTiming(1, {duration: 800})); + }, [fadeText1, fadeText2, fadeText3]); + return ( + + + + + + Please wait... + + + We're checking your network connection + + + This may take a few moments + + + ); +}; + +export default NetworkCheck; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#f4f4f4', + }, + lottieContainer: { + flexDirection: 'row', + marginBottom: 20, + }, + lottieView: { + width: '100%', + aspectRatio: 1, + }, + loadingText: { + fontSize: rf(18), + color: PRIMARY_COLOR, + fontWeight: '600', + }, + subText: { + fontSize: rf(14), + color: '#7f8c8d', + marginTop: 5, + }, +}); diff --git a/frontend/src/components/NoInternet.tsx b/frontend/src/components/NoInternet.tsx index 6b268efb..08b3ffda 100644 --- a/frontend/src/components/NoInternet.tsx +++ b/frontend/src/components/NoInternet.tsx @@ -1,90 +1,91 @@ -import React from 'react'; -import {StyleSheet, Text, View, Image, TouchableOpacity} from 'react-native'; -import {hp, wp} from '../helper/Metric'; -import {PRIMARY_COLOR} from '../helper/Theme'; +import React from 'react'; +import {StyleSheet, Text, View, Image, TouchableOpacity} from 'react-native'; +import {hp, wp} from '../helper/Metric'; +import {PRIMARY_COLOR} from '../helper/Theme'; import { rf } from '../helper/Metric'; -interface NoInternetProps { - onRetry: () => void; -} - -const NoInternet = ({onRetry}: NoInternetProps) => { - return ( - - - - - OOOPS ! - - It seems there is something wrong with your internet connection! - - - Try again - - - - ); -}; - -const styles = StyleSheet.create({ - wrapper: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - container: { - maxWidth: wp(90), - width: '100%', - padding: 20, - backgroundColor: '#fff', - borderRadius: 8, - alignItems: 'center', - justifyContent: 'center', - }, - image_wifi: { - width: wp(15), - height: hp(7), - alignSelf: 'flex-start', - marginBottom: -30, - }, - image_cloud: { - width: wp(32), - height: hp(15), - marginBottom: 40, - }, - title: { - fontSize: 24, - fontWeight: 'bold', - marginBottom: 15, - textAlign: 'center', - }, - subtitle: { - fontSize: 16, - color: '#555', - textAlign: 'center', - marginBottom: 40, - marginHorizontal: 10, - }, - button: { - backgroundColor: PRIMARY_COLOR, - paddingVertical: 10, - paddingHorizontal: 20, - width: '60%', - alignItems: 'center', - borderRadius: 6, - }, - buttonText: { - color: '#fff', - fontSize: 16, - fontWeight: 'bold', - }, -}); - -export default NoInternet; + +interface NoInternetProps { + onRetry: () => void; +} + +const NoInternet = ({onRetry}: NoInternetProps) => { + return ( + + + + + OOOPS ! + + It seems there is something wrong with your internet connection! + + + Try again + + + + ); +}; + +const styles = StyleSheet.create({ + wrapper: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + container: { + maxWidth: wp(90), + width: '100%', + padding: 20, + backgroundColor: '#fff', + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + image_wifi: { + width: wp(15), + height: hp(7), + alignSelf: 'flex-start', + marginBottom: -30, + }, + image_cloud: { + width: wp(32), + height: hp(15), + marginBottom: 40, + }, + title: { + fontSize: rf(24), + fontWeight: 'bold', + marginBottom: 15, + textAlign: 'center', + }, + subtitle: { + fontSize: rf(16), + color: '#555', + textAlign: 'center', + marginBottom: 40, + marginHorizontal: 10, + }, + button: { + backgroundColor: PRIMARY_COLOR, + paddingVertical: 10, + paddingHorizontal: 20, + width: '60%', + alignItems: 'center', + borderRadius: 6, + }, + buttonText: { + color: '#fff', + fontSize: rf(16), + fontWeight: 'bold', + }, +}); + +export default NoInternet; diff --git a/frontend/src/components/PasswordTab.tsx b/frontend/src/components/PasswordTab.tsx index 06d340fd..95939f6c 100644 --- a/frontend/src/components/PasswordTab.tsx +++ b/frontend/src/components/PasswordTab.tsx @@ -1,244 +1,245 @@ -import { - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; -import React, {useState} from 'react'; -import Feather from '@expo/vector-icons/Feather'; -import { useForm, Controller } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import {PRIMARY_COLOR} from '../helper/Theme'; +import { + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import React, {useState} from 'react'; +import Feather from '@expo/vector-icons/Feather'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import {PRIMARY_COLOR} from '../helper/Theme'; import { rf } from '../helper/Metric'; -const passwordSchema = z.object({ - old_password: z.string().min(1, 'Old password is required'), - new_password: z.string() - .min(6, 'At least 6 characters') - .regex(/(?=.*[a-z]).{6,}/, 'At least 6 characters with lowercase letter'), - confirm_password: z.string().min(1, 'Please confirm your new password'), -}).refine((data) => data.new_password === data.confirm_password, { - message: "Passwords don't match", - path: ['confirm_password'], -}).refine((data) => data.new_password !== data.old_password, { - message: "New password must be different from old password", - path: ['new_password'], -}); - -export type PasswordFormData = z.infer; - -interface PasswordTabProps { - handleSubmitPassword: (data: PasswordFormData) => void; -} - -const PasswordTab = ({ - handleSubmitPassword, -}: PasswordTabProps) => { - const { control, handleSubmit } = useForm({ - resolver: zodResolver(passwordSchema), - mode: 'onChange', - defaultValues: { - old_password: '', - new_password: '', - confirm_password: '', - } - }); - // State hooks to manage visibility of passwords - const [isVisibleOldPassword, setisVisibleOldPassword] = useState(false); - const [isVisibleNewPassword, setisVisibleNewPassword] = useState(false); - const [isVisibleConfirmPassword, setisVisibleConfirmPassword] = - useState(false); - // Toggle functions to change password visibility - const toggleOldPassword = () => { - setisVisibleOldPassword(!isVisibleOldPassword); - }; - const toggleNewPassword = () => { - setisVisibleNewPassword(!isVisibleNewPassword); - }; - const toggleConfirmPassword = () => { - setisVisibleConfirmPassword(!isVisibleConfirmPassword); - }; - - return ( - - - {/* Old Password Input */} - - Old Password - ( - <> - - - - {isVisibleOldPassword ? ( - - ) : ( - - )} - - - {error && {error.message}} - - )} - /> - - - {/* New Password Input */} - - New Password - ( - <> - - - - {isVisibleNewPassword ? ( - - ) : ( - - )} - - - {error && {error.message}} - - )} - /> - - - {/* Confirm Password Input */} - - Confirm Password - ( - <> - - - - {isVisibleConfirmPassword ? ( - - ) : ( - - )} - - - {error && {error.message}} - - )} - /> - - - - {/* Save Button */} - - Save - - - ); -}; - -export default PasswordTab; - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'space-between', - height: '100%', - flexDirection: 'column', - }, - content: { - width: '100%', - flexDirection: 'column', - gap: 15, - alignItems: 'center', - }, - input: { - width: '100%', - }, - inputLabel: { - fontSize: 17, - fontWeight: '600', - color: '#222', - marginBottom: 8, - }, - inputControl: { - height: 50, - backgroundColor: '#fff', - paddingHorizontal: 16, - paddingLeft: 16, - paddingRight: 40, - borderRadius: 12, - fontSize: 15, - fontWeight: '500', - color: '#222', - borderWidth: 1, - borderColor: '#C9D3DB', - borderStyle: 'solid', - }, - passwordContainer: { - position: 'relative', - justifyContent: 'center', - }, - eyeIcon: { - position: 'absolute', - right: 15, - alignItems: 'center', - }, - btn: { - width: '100%', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 8, - paddingVertical: 10, - paddingHorizontal: 16, - backgroundColor: PRIMARY_COLOR, - marginTop: 20, - }, - btnText: { - fontSize: 18, - fontWeight: 'bold', - color: 'white', - }, - errorText: { - color: 'red', - fontSize: 12, - marginTop: 4, - }, -}); + +const passwordSchema = z.object({ + old_password: z.string().min(1, 'Old password is required'), + new_password: z.string() + .min(6, 'At least 6 characters') + .regex(/(?=.*[a-z]).{6,}/, 'At least 6 characters with lowercase letter'), + confirm_password: z.string().min(1, 'Please confirm your new password'), +}).refine((data) => data.new_password === data.confirm_password, { + message: "Passwords don't match", + path: ['confirm_password'], +}).refine((data) => data.new_password !== data.old_password, { + message: "New password must be different from old password", + path: ['new_password'], +}); + +export type PasswordFormData = z.infer; + +interface PasswordTabProps { + handleSubmitPassword: (data: PasswordFormData) => void; +} + +const PasswordTab = ({ + handleSubmitPassword, +}: PasswordTabProps) => { + const { control, handleSubmit } = useForm({ + resolver: zodResolver(passwordSchema), + mode: 'onChange', + defaultValues: { + old_password: '', + new_password: '', + confirm_password: '', + } + }); + // State hooks to manage visibility of passwords + const [isVisibleOldPassword, setisVisibleOldPassword] = useState(false); + const [isVisibleNewPassword, setisVisibleNewPassword] = useState(false); + const [isVisibleConfirmPassword, setisVisibleConfirmPassword] = + useState(false); + // Toggle functions to change password visibility + const toggleOldPassword = () => { + setisVisibleOldPassword(!isVisibleOldPassword); + }; + const toggleNewPassword = () => { + setisVisibleNewPassword(!isVisibleNewPassword); + }; + const toggleConfirmPassword = () => { + setisVisibleConfirmPassword(!isVisibleConfirmPassword); + }; + + return ( + + + {/* Old Password Input */} + + Old Password + ( + <> + + + + {isVisibleOldPassword ? ( + + ) : ( + + )} + + + {error && {error.message}} + + )} + /> + + + {/* New Password Input */} + + New Password + ( + <> + + + + {isVisibleNewPassword ? ( + + ) : ( + + )} + + + {error && {error.message}} + + )} + /> + + + {/* Confirm Password Input */} + + Confirm Password + ( + <> + + + + {isVisibleConfirmPassword ? ( + + ) : ( + + )} + + + {error && {error.message}} + + )} + /> + + + + {/* Save Button */} + + Save + + + ); +}; + +export default PasswordTab; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'space-between', + height: '100%', + flexDirection: 'column', + }, + content: { + width: '100%', + flexDirection: 'column', + gap: 15, + alignItems: 'center', + }, + input: { + width: '100%', + }, + inputLabel: { + fontSize: rf(17), + fontWeight: '600', + color: '#222', + marginBottom: 8, + }, + inputControl: { + height: 50, + backgroundColor: '#fff', + paddingHorizontal: 16, + paddingLeft: 16, + paddingRight: 40, + borderRadius: 12, + fontSize: rf(15), + fontWeight: '500', + color: '#222', + borderWidth: 1, + borderColor: '#C9D3DB', + borderStyle: 'solid', + }, + passwordContainer: { + position: 'relative', + justifyContent: 'center', + }, + eyeIcon: { + position: 'absolute', + right: 15, + alignItems: 'center', + }, + btn: { + width: '100%', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 16, + backgroundColor: PRIMARY_COLOR, + marginTop: 20, + }, + btnText: { + fontSize: rf(18), + fontWeight: 'bold', + color: 'white', + }, + errorText: { + color: 'red', + fontSize: rf(12), + marginTop: 4, + }, +}); diff --git a/frontend/src/components/PodcastActions.tsx b/frontend/src/components/PodcastActions.tsx index 079d0ace..dd266de6 100644 --- a/frontend/src/components/PodcastActions.tsx +++ b/frontend/src/components/PodcastActions.tsx @@ -1,150 +1,151 @@ -import React, { useCallback, useMemo } from 'react'; -import { - Text, - TouchableOpacity, - StyleSheet, - View, -} from 'react-native'; -import { - BottomSheetBackdrop, - BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetView, -} from '@gorhom/bottom-sheet'; -import Icon from '@expo/vector-icons/MaterialIcons'; -import { PRIMARY_COLOR } from '../helper/Theme'; +import React, { useCallback, useMemo } from 'react'; +import { + Text, + TouchableOpacity, + StyleSheet, + View, +} from 'react-native'; +import { + BottomSheetBackdrop, + BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from '@gorhom/bottom-sheet'; +import Icon from '@expo/vector-icons/MaterialIcons'; +import { PRIMARY_COLOR } from '../helper/Theme'; import { rf } from '../helper/Metric'; -const ActionItem = ({ - icon, - label, - onPress, - iconColor, -}: { - icon: string; - label: string; - onPress: () => void; - iconColor?: string; -}) => ( - - - - - {label} - -); - -interface Props { - onShare: ()=> void; - onReport: ()=> void; - onDownload: ()=> void; - onSave: ()=> void; - downloaded: boolean; -} -// eslint-disable-next-line react/display-name -const PodcastActions = React.forwardRef( - ({ onShare, onReport, onDownload, onSave, downloaded}: Props, ref: any) => { - const snapPoints = useMemo(() => ['40%'], []); - - const handleAction = (action: () => void) => { - console.log('click'); - action(); - //onClear(); - ref?.current?.dismiss(); - }; - - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - return ( - - - Podcast Actions - handleAction(onShare)} - iconColor="#3b82f6" - /> - handleAction(onSave)} - iconColor={PRIMARY_COLOR} - /> - handleAction(onDownload)} - iconColor="#10b981" - /> - handleAction(onReport)} - iconColor="#ef4444" - /> - - - ); - } -); - -export default PodcastActions; - -const styles = StyleSheet.create({ - sheetContent: { - paddingHorizontal: 20, - paddingTop: 24, - paddingBottom: 20, - flex: 1, - }, - sheetTitle: { - fontSize: 18, - fontWeight: '700', - color: '#1a1a1a', - marginBottom: 20, - paddingBottom: 12, - borderBottomWidth: 1, - borderBottomColor: '#e5e7eb', - }, - actionItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 14, - paddingHorizontal: 12, - borderRadius: 10, - marginBottom: 8, - backgroundColor: '#f9fafb', - }, - iconContainer: { - width: 40, - height: 40, - borderRadius: 10, - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }, - label: { - fontSize: 16, - fontWeight: '500', - color: '#1a1a1a', - }, -}); + +const ActionItem = ({ + icon, + label, + onPress, + iconColor, +}: { + icon: string; + label: string; + onPress: () => void; + iconColor?: string; +}) => ( + + + + + {label} + +); + +interface Props { + onShare: ()=> void; + onReport: ()=> void; + onDownload: ()=> void; + onSave: ()=> void; + downloaded: boolean; +} +// eslint-disable-next-line react/display-name +const PodcastActions = React.forwardRef( + ({ onShare, onReport, onDownload, onSave, downloaded}: Props, ref: any) => { + const snapPoints = useMemo(() => ['40%'], []); + + const handleAction = (action: () => void) => { + console.log('click'); + action(); + //onClear(); + ref?.current?.dismiss(); + }; + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + return ( + + + Podcast Actions + handleAction(onShare)} + iconColor="#3b82f6" + /> + handleAction(onSave)} + iconColor={PRIMARY_COLOR} + /> + handleAction(onDownload)} + iconColor="#10b981" + /> + handleAction(onReport)} + iconColor="#ef4444" + /> + + + ); + } +); + +export default PodcastActions; + +const styles = StyleSheet.create({ + sheetContent: { + paddingHorizontal: 20, + paddingTop: 24, + paddingBottom: 20, + flex: 1, + }, + sheetTitle: { + fontSize: rf(18), + fontWeight: '700', + color: '#1a1a1a', + marginBottom: 20, + paddingBottom: 12, + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', + }, + actionItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 12, + borderRadius: 10, + marginBottom: 8, + backgroundColor: '#f9fafb', + }, + iconContainer: { + width: 40, + height: 40, + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + label: { + fontSize: rf(16), + fontWeight: '500', + color: '#1a1a1a', + }, +}); diff --git a/frontend/src/components/PodcastCard.tsx b/frontend/src/components/PodcastCard.tsx index 518843f7..e888cbbd 100644 --- a/frontend/src/components/PodcastCard.tsx +++ b/frontend/src/components/PodcastCard.tsx @@ -1,262 +1,263 @@ -import React, {useRef} from 'react'; -import {TouchableOpacity, Alert, StyleSheet, View} from 'react-native'; -import {YStack, XStack, Image, Text} from 'tamagui'; -import {Entypo, Ionicons} from '@expo/vector-icons'; -import {formatCount} from '../helper/Utils'; -import {Category} from '../type'; -import {BottomSheetModal} from '@gorhom/bottom-sheet'; -import PodcastActions from './PodcastActions'; -import Share from 'react-native-share'; -import {GET_STORAGE_DATA} from '../helper/APIUtils'; -import {GlassStyles, ProfessionalColors, BorderRadius} from '../styles/GlassStyles'; -import {useSelector} from 'react-redux'; -import {useNavigation, useFocusEffect} from '@react-navigation/native'; -import { PODCAST_CARD } from '@/constants/podcastCard'; -import {getPlaybackPosition, PlaybackPosition} from '../helper/PlaybackManager'; - -interface PodcastProps { - id: string; - downloaded: boolean; - display: boolean; - title: string; - host: string; - audioUrl: string; - imageUri: string; - views: number; - tags: Category[]; - duration: string; - handleClick: () => void; - downLoadAudio: () => void; - handleReport: () => void; - playlistAct: (id: string) => void; -} - -const PodcastCard = ({ - id, - title, - host, - imageUri, - views, - duration, - tags, - audioUrl, - handleClick, - downLoadAudio, - handleReport, - downloaded, - display, - playlistAct, -}: PodcastProps) => { - const sheetRef = useRef(null); - const {isGuest} = useSelector((state: any) => state.user); - const navigation = useNavigation(); - const [progress, setProgress] = React.useState(null); - - useFocusEffect( - React.useCallback(() => { - let isMounted = true; - getPlaybackPosition(id).then(pos => { - if (isMounted) { - setProgress(pos); - } - }); - return () => { - isMounted = false; - }; - }, [id]) - ); - - const handleOpenSheet = () => { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Sign In Required', - description: 'Please sign in or sign up for more actions.', - iconName: 'ellipsis-v', - }); - return; - } - sheetRef.current?.present(); - }; - - const handleShare = async () => { - try { - const url = `https://uhsocial.in/api/share/podcast?trackId=${id}&audioUrl=${audioUrl}`; - const result = await Share.open({ - title: title, - message: `${title} : Check out this awesome podcast on UltimateHealth app!`, - url: url, - subject: 'Podcast Sharing', - }); - console.log(result); - } catch (error) { - console.log('Error sharing:', error); - Alert.alert('Error', 'Something went wrong while sharing.'); - } - }; - - const uri = - imageUri && imageUri !== '' - ? imageUri.startsWith('https') - ? imageUri - : `${GET_STORAGE_DATA}/${imageUri}` - : 'https://t3.ftcdn.net/jpg/05/10/75/30/360_F_510753092_f4AOmCJAczuGgRLCmHxmowga2tC9VYQP.jpg'; - - return ( - - - - - - - - - - {progress && progress.duration > 0 && ( - - - - )} - - - - - {title} - - - - - - {host} - - - - - {tags?.slice(0, 3).map((tag, i) => ( - - #{tag.name} - - ))} - - - - - - - {views <= 1 ? `${views} view` : `${formatCount(views)} views`} - - - - - - - {duration} - - - - - - {display && !isGuest && ( - - - - )} - - playlistAct(id)} - /> - - - ); -}; - -const styles = StyleSheet.create({ - cardWrapper: { - marginVertical: 8, - width: '100%', - }, - cardContainer: { - overflow: 'hidden', - position: 'relative', - }, - imageContainer: { - width: '100%', - height: PODCAST_CARD.imageHeight, - position: 'relative', - overflow: 'hidden', - }, - coverImage: { - width: '100%', - height: '100%', - resizeMode: 'cover', - }, - imageOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.2)', - justifyContent: 'center', - alignItems: 'center', - }, - playButton: { - width: 56, - height: 56, - borderRadius: 28, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0, 191, 255, 0.9)', - }, - tag: { - backgroundColor: ProfessionalColors.primaryGlass, - paddingHorizontal: 10, - paddingVertical: 5, - borderRadius: BorderRadius.lg, - borderWidth: 1, - borderColor: ProfessionalColors.primary + '30', - }, - tagText: { - fontSize: 11, - fontWeight: '600', - color: ProfessionalColors.primary, - textTransform: 'capitalize', - }, - menuButton: { - position: 'absolute', - top: 12, - right: 12, - width: 36, - height: 36, - borderRadius: 18, - justifyContent: 'center', - alignItems: 'center', - zIndex: 10, - }, - progressBarContainer: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - height: 4, - backgroundColor: 'rgba(255, 255, 255, 0.3)', - }, - progressBar: { - height: '100%', - backgroundColor: ProfessionalColors.primary, - }, -}); +import React, {useRef} from 'react'; +import {TouchableOpacity, Alert, StyleSheet, View} from 'react-native'; +import {YStack, XStack, Image, Text} from 'tamagui'; +import {Entypo, Ionicons} from '@expo/vector-icons'; +import {formatCount} from '../helper/Utils'; +import {Category} from '../type'; +import {BottomSheetModal} from '@gorhom/bottom-sheet'; +import PodcastActions from './PodcastActions'; +import Share from 'react-native-share'; +import {GET_STORAGE_DATA} from '../helper/APIUtils'; +import {GlassStyles, ProfessionalColors, BorderRadius} from '../styles/GlassStyles'; +import {useSelector} from 'react-redux'; +import {useNavigation, useFocusEffect} from '@react-navigation/native'; +import { PODCAST_CARD } from '@/constants/podcastCard'; +import {getPlaybackPosition, PlaybackPosition} from '../helper/PlaybackManager'; import { rf } from '../helper/Metric'; + +interface PodcastProps { + id: string; + downloaded: boolean; + display: boolean; + title: string; + host: string; + audioUrl: string; + imageUri: string; + views: number; + tags: Category[]; + duration: string; + handleClick: () => void; + downLoadAudio: () => void; + handleReport: () => void; + playlistAct: (id: string) => void; +} + +const PodcastCard = ({ + id, + title, + host, + imageUri, + views, + duration, + tags, + audioUrl, + handleClick, + downLoadAudio, + handleReport, + downloaded, + display, + playlistAct, +}: PodcastProps) => { + const sheetRef = useRef(null); + const {isGuest} = useSelector((state: any) => state.user); + const navigation = useNavigation(); + const [progress, setProgress] = React.useState(null); + + useFocusEffect( + React.useCallback(() => { + let isMounted = true; + getPlaybackPosition(id).then(pos => { + if (isMounted) { + setProgress(pos); + } + }); + return () => { + isMounted = false; + }; + }, [id]) + ); + + const handleOpenSheet = () => { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: 'Please sign in or sign up for more actions.', + iconName: 'ellipsis-v', + }); + return; + } + sheetRef.current?.present(); + }; + + const handleShare = async () => { + try { + const url = `https://uhsocial.in/api/share/podcast?trackId=${id}&audioUrl=${audioUrl}`; + const result = await Share.open({ + title: title, + message: `${title} : Check out this awesome podcast on UltimateHealth app!`, + url: url, + subject: 'Podcast Sharing', + }); + console.log(result); + } catch (error) { + console.log('Error sharing:', error); + Alert.alert('Error', 'Something went wrong while sharing.'); + } + }; + + const uri = + imageUri && imageUri !== '' + ? imageUri.startsWith('https') + ? imageUri + : `${GET_STORAGE_DATA}/${imageUri}` + : 'https://t3.ftcdn.net/jpg/05/10/75/30/360_F_510753092_f4AOmCJAczuGgRLCmHxmowga2tC9VYQP.jpg'; + + return ( + + + + + + + + + + {progress && progress.duration > 0 && ( + + + + )} + + + + + {title} + + + + + + {host} + + + + + {tags?.slice(0, 3).map((tag, i) => ( + + #{tag.name} + + ))} + + + + + + + {views <= 1 ? `${views} view` : `${formatCount(views)} views`} + + + + + + + {duration} + + + + + + {display && !isGuest && ( + + + + )} + + playlistAct(id)} + /> + + + ); +}; + +const styles = StyleSheet.create({ + cardWrapper: { + marginVertical: 8, + width: '100%', + }, + cardContainer: { + overflow: 'hidden', + position: 'relative', + }, + imageContainer: { + width: '100%', + height: PODCAST_CARD.imageHeight, + position: 'relative', + overflow: 'hidden', + }, + coverImage: { + width: '100%', + height: '100%', + resizeMode: 'cover', + }, + imageOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + playButton: { + width: 56, + height: 56, + borderRadius: 28, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 191, 255, 0.9)', + }, + tag: { + backgroundColor: ProfessionalColors.primaryGlass, + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: BorderRadius.lg, + borderWidth: 1, + borderColor: ProfessionalColors.primary + '30', + }, + tagText: { + fontSize: rf(11), + fontWeight: '600', + color: ProfessionalColors.primary, + textTransform: 'capitalize', + }, + menuButton: { + position: 'absolute', + top: 12, + right: 12, + width: 36, + height: 36, + borderRadius: 18, + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, + }, + progressBarContainer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 4, + backgroundColor: 'rgba(255, 255, 255, 0.3)', + }, + progressBar: { + height: '100%', + backgroundColor: ProfessionalColors.primary, + }, +}); + export default PodcastCard; \ No newline at end of file diff --git a/frontend/src/components/PodcastEmptyComponent.tsx b/frontend/src/components/PodcastEmptyComponent.tsx index 5ffc292e..8f07f38a 100644 --- a/frontend/src/components/PodcastEmptyComponent.tsx +++ b/frontend/src/components/PodcastEmptyComponent.tsx @@ -1,42 +1,43 @@ -import {Text, StyleSheet} from 'react-native'; -import {hp} from '../helper/Metric'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import {Text, StyleSheet} from 'react-native'; +import {hp} from '../helper/Metric'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { rf } from '../helper/Metric'; -export default function PodcastEmptyComponent() { - return ( - - - No podcast Found - - ); -} - -const styles = StyleSheet.create({ - image: { - height: 160, - width: 160, - borderRadius: 80, - resizeMode: 'cover', - marginBottom: hp(4), - }, - - message: { - fontSize: 17, - color: '#555', - fontWeight: '500', - textAlign: 'center', - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 10, - marginTop: hp(15), - alignSelf: 'center', - }, -}); + +export default function PodcastEmptyComponent() { + return ( + + + No podcast Found + + ); +} + +const styles = StyleSheet.create({ + image: { + height: 160, + width: 160, + borderRadius: 80, + resizeMode: 'cover', + marginBottom: hp(4), + }, + + message: { + fontSize: rf(17), + color: '#555', + fontWeight: '500', + textAlign: 'center', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 10, + marginTop: hp(15), + alignSelf: 'center', + }, +}); diff --git a/frontend/src/components/PodcastPlayer.tsx b/frontend/src/components/PodcastPlayer.tsx index be7b7107..1c22f231 100644 --- a/frontend/src/components/PodcastPlayer.tsx +++ b/frontend/src/components/PodcastPlayer.tsx @@ -1,470 +1,471 @@ -import { - StyleSheet, - Text, - TouchableOpacity, - View, - Image, - Platform, -} from 'react-native'; -import React, {useEffect, useState, useRef} from 'react'; -import Ionicons from '@expo/vector-icons/Ionicons'; -import Entypo from '@expo/vector-icons/Entypo'; -import EvilIcons from '@expo/vector-icons/EvilIcons'; -import Slider from '@react-native-community/slider'; -import FontAwesome6 from '@expo/vector-icons/FontAwesome6'; -import Feather from '@expo/vector-icons/Feather'; -import {PRIMARY_COLOR} from '../helper/Theme'; -import Tts from 'react-native-tts'; - -const WORDS_PER_MINUTE = 130; -const IOS_TTS_RATE = 0.4; -const ANDROID_TTS_RATE = 0.6; - -// iOS and Android expose slightly different playback rates, so we keep the -// existing platform-specific tuning to preserve the current speaking cadence. -const IOS_TTS_DURATION_ADJUSTMENT = 1.1; -const ANDROID_TTS_DURATION_ADJUSTMENT = 0.9; - -interface TtsProgressEvent { - elapsedTime?: number; - currentTime?: number; -} - -type TtsEventName = - | 'tts-start' - | 'tts-progress' - | 'tts-finish' - | 'tts-cancel' - | 'tts-error'; - -type TtsEventHandler = T extends 'tts-progress' - ? (event: TtsProgressEvent) => void - : (event: any) => void; - -type TtsSubscription = { - remove?: () => void; -}; - -const PodcastPlayer = ({navigation}: any) => { - const [isLiked, setisLiked] = useState(false); - const [isPlaying, setisPlaying] = useState(false); - const [duration, setDuration] = useState(0); - const [currentPosition, setCurrentPosition] = useState(0); - - // Playback-synchronization refs (best-effort, event-driven when supported) - const isSliderActiveRef = useRef(false); - const currentSpokenOffsetMsRef = useRef(0); - const currentPositionRef = useRef(0); - const durationRef = useRef(0); - - const text = `You're seeking new ways to diversify your portfolio, but it's not always easy to find new reliable investment opportunities. Each week, our financial expert with four decades of successful investing experience will help you discover opportunities outside of your current strategy that you've probably never considered before. If you want to learn about ways to diversify your portfolio in ways that have various levels of risk, this show is for you.`; - - const textSpokenAdjustment = - Platform.OS === 'ios' - ? IOS_TTS_DURATION_ADJUSTMENT - : ANDROID_TTS_DURATION_ADJUSTMENT; - const defaultRate = Platform.OS === 'ios' ? IOS_TTS_RATE : ANDROID_TTS_RATE; - - const estimateTTSDuration = (txt: string) => { - const adjustedWordsPerMinute = WORDS_PER_MINUTE * textSpokenAdjustment; - const words = txt.split(' ').length; - return (words / adjustedWordsPerMinute) * 60 * 1000; - }; - - const updateDuration = (value: number) => { - durationRef.current = value; - setDuration(value); - }; - - const initTts = async () => { - const totalDuration = estimateTTSDuration(text); - updateDuration(totalDuration); - - try { - const voices = await Tts.voices(); - const availableVoices = voices - .filter( - (v: any) => - !v.networkConnectionRequired && - !v.notInstalled && - (v.language === 'en-IN' || v.language === 'en-US'), - ) - .map((v: any) => ({id: v.id, name: v.name, language: v.language})); - - if (availableVoices && availableVoices.length > 0) { - try { - await Tts.setDefaultLanguage(availableVoices[0].language); - } catch (err) { - console.warn(`Failed to set TTS language to ${availableVoices[0].language}, using default.`, err); - } - await Tts.setDefaultVoice(availableVoices[0].id); - Tts.setDefaultRate(defaultRate, true); - Tts.setDefaultPitch(1.5); - Tts.setDucking(true); - Tts.setIgnoreSilentSwitch('ignore'); - } else { - setisPlaying(false); - } - } catch (error) { - console.error('Failed to initialize TTS voices', error); - setisPlaying(false); - } - }; - - const syncCurrentPosition = (position: number) => { - currentPositionRef.current = position; - setCurrentPosition(position); - }; - - const removeTtsSubscription = ( - subscription: TtsSubscription | null | void, - eventName: TtsEventName, - handler: TtsEventHandler, - ) => { - if (subscription?.remove) { - subscription.remove(); - return; - } - - if (typeof Tts.removeEventListener === 'function') { - Tts.removeEventListener(eventName, handler as any); - } - }; - - // Best-effort event-driven sync (react-native-tts varies by version/platform) - useEffect(() => { - let isMounted = true; - - const setup = async () => { - try { - await Tts.getInitStatus(); - if (!isMounted) { - return; - } - - await initTts(); - } catch (error) { - console.error('Failed to initialize TTS', error); - if (isMounted) { - setisPlaying(false); - } - } - }; - - setup(); - - const onStart = () => { - if (isSliderActiveRef.current) return; - currentSpokenOffsetMsRef.current = currentPositionRef.current; - setisPlaying(true); - }; - - const onProgress = (event: any) => { - if (isSliderActiveRef.current) return; - - const eventPosMs: number | null = - typeof event?.elapsedTime === 'number' - ? event.elapsedTime - : typeof event?.currentTime === 'number' - ? event.currentTime - : null; - - if (eventPosMs == null) { - console.warn('TTS progress event did not include a position update.'); - return; - } - - const newPos = Math.min( - durationRef.current, - currentSpokenOffsetMsRef.current + eventPosMs, - ); - syncCurrentPosition(newPos); - }; - - const onFinish = () => { - setisPlaying(false); - syncCurrentPosition(0); - currentSpokenOffsetMsRef.current = 0; - }; - - const onCancel = () => setisPlaying(false); - const onError = () => setisPlaying(false); - - const startSub = Tts.addEventListener('tts-start', onStart); - const progressSub = Tts.addEventListener('tts-progress', onProgress); - const finishSub = Tts.addEventListener('tts-finish', onFinish); - const cancelSub = Tts.addEventListener('tts-cancel', onCancel); - const errorSub = Tts.addEventListener('tts-error', onError); - - return () => { - isMounted = false; - removeTtsSubscription(startSub, 'tts-start', onStart); - removeTtsSubscription(progressSub, 'tts-progress', onProgress); - removeTtsSubscription(finishSub, 'tts-finish', onFinish); - removeTtsSubscription(cancelSub, 'tts-cancel', onCancel); - removeTtsSubscription(errorSub, 'tts-error', onError); - }; - }, []); - - const handleLike = () => setisLiked(!isLiked); - - // Best-effort seek: stop + re-speak from an approximated offset - const speakFromPositionMs = async (seekPositionMs: number) => { - const safeDuration = durationRef.current || 1; - const clamped = Math.max(0, Math.min(safeDuration, seekPositionMs)); - - const words = text.split(' '); - const totalWords = words.length; - const approxElapsedRatio = clamped / safeDuration; - - const wordsToSkip = Math.max( - 0, - Math.min(totalWords - 1, Math.floor(totalWords * approxElapsedRatio)), - ); - - const newText = words.slice(wordsToSkip).join(' '); - - currentSpokenOffsetMsRef.current = clamped; - currentPositionRef.current = clamped; - - Tts.stop(); - - try { - await Tts.speak(newText); - setisPlaying(true); - } catch (error) { - console.error('Failed to start TTS playback', error); - setisPlaying(false); - } - }; - - const handlePlay = () => { - if (isPlaying) { - Tts.stop(); - setisPlaying(false); - return; - } - - void speakFromPositionMs(currentPosition); - }; - - const handleSlidingStart = () => { - isSliderActiveRef.current = true; - Tts.stop(); - setisPlaying(false); - }; - - const handleSliderValueChange = (value: number) => { - const safeDuration = durationRef.current || 1; - const seekPosition = Math.max(0, Math.min(safeDuration, value * safeDuration)); - - syncCurrentPosition(seekPosition); - }; - - const handleSlidingComplete = (value: number) => { - const safeDuration = durationRef.current || 1; - const seekPosition = Math.max(0, Math.min(safeDuration, value * safeDuration)); - - isSliderActiveRef.current = false; - syncCurrentPosition(seekPosition); - void speakFromPositionMs(seekPosition); - }; - - const handleForward = () => {}; - const handleBackward = () => {}; - const handleDownload = () => {}; - const handleShare = () => {}; - - return ( - <> - Feel Better. Live More - - 8 Hidden Habits To Live Your Healthiest, Happiest and Most Fulfilled - Life with Robin Sharma - - - - - - - Posted by - Dr Rangan Chatterjee - - - - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi vulputate - augue erat, congue lacinia turpis pellentesque aliquam. Quisque eu - tellus varius, eleifend dui sed, luctus nibh. Duis et dolor eu ligula - ultrices dictum. Class aptent taciti sociosqu ad litora torquent per - conubia nostra, per inceptos himenaeos. - - - - - - - {isLiked ? ( - - ) : ( - - )} - - 30 - - - - - - - - - - - 0 ? currentPosition / duration : 0} - minimumTrackTintColor="#FFFFFF" - maximumTrackTintColor="#000000" - onSlidingStart={handleSlidingStart} - onValueChange={handleSliderValueChange} - onSlidingComplete={handleSlidingComplete} - /> - - - - {Math.floor(currentPosition / 1000 / 60)}: - {Math.floor((currentPosition / 1000) % 60) - .toString() - .padStart(2, '0')} - - - -{Math.floor((duration - currentPosition) / 1000 / 60)}: - {Math.floor(((duration - currentPosition) / 1000) % 60) - .toString() - .padStart(2, '0')} - - - - - - - - - {isPlaying ? ( - - ) : ( - - )} - - - - - - - ); -}; - -export default PodcastPlayer; - -const styles = StyleSheet.create({ - podcast: { - fontSize: 15, - fontWeight: 'normal', - color: 'white', - marginTop: 10, - }, - podcastname: { - fontSize: 18, - fontWeight: 'bold', - color: 'white', - marginBottom: 5, - }, - postedByContainer: { - alignItems: 'flex-end', - marginBottom: 5, - }, - postedByContent: { - flexDirection: 'row', - alignItems: 'center', - gap: 7, - }, - profileImage: { - height: 40, - width: 40, - borderRadius: 100, - }, - postedByText: { - color: 'white', - fontSize: 10, - fontWeight: '300', - }, - postedByName: { - color: 'white', - fontSize: 13, - fontWeight: 'bold', - }, - description: { - color: 'white', - fontSize: 13, - textAlign: 'justify', - marginBottom: 15, - }, - actionsContainer: { - alignItems: 'flex-end', - marginBottom: 0, - }, - actionsContent: { - flexDirection: 'row', - alignItems: 'center', - gap: 20, - }, - likeContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 2, - }, - likeText: { - fontSize: 13, - fontWeight: 'bold', - color: 'white', - }, - slider: { - width: '100%', - }, - timeContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - top: -5, - }, - timeText: { - color: 'white', - fontSize: 14, - }, - controlContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - controlButton: { - marginHorizontal: 20, - }, - playButton: { - backgroundColor: 'white', - height: 40, - width: 40, - borderRadius: 100, - alignItems: 'center', - justifyContent: 'center', - }, -}); - +import { + StyleSheet, + Text, + TouchableOpacity, + View, + Image, + Platform, +} from 'react-native'; +import React, {useEffect, useState, useRef} from 'react'; +import Ionicons from '@expo/vector-icons/Ionicons'; +import Entypo from '@expo/vector-icons/Entypo'; +import EvilIcons from '@expo/vector-icons/EvilIcons'; +import Slider from '@react-native-community/slider'; +import FontAwesome6 from '@expo/vector-icons/FontAwesome6'; +import Feather from '@expo/vector-icons/Feather'; +import {PRIMARY_COLOR} from '../helper/Theme'; +import Tts from 'react-native-tts'; import { rf } from '../helper/Metric'; + + +const WORDS_PER_MINUTE = 130; +const IOS_TTS_RATE = 0.4; +const ANDROID_TTS_RATE = 0.6; + +// iOS and Android expose slightly different playback rates, so we keep the +// existing platform-specific tuning to preserve the current speaking cadence. +const IOS_TTS_DURATION_ADJUSTMENT = 1.1; +const ANDROID_TTS_DURATION_ADJUSTMENT = 0.9; + +interface TtsProgressEvent { + elapsedTime?: number; + currentTime?: number; +} + +type TtsEventName = + | 'tts-start' + | 'tts-progress' + | 'tts-finish' + | 'tts-cancel' + | 'tts-error'; + +type TtsEventHandler = T extends 'tts-progress' + ? (event: TtsProgressEvent) => void + : (event: any) => void; + +type TtsSubscription = { + remove?: () => void; +}; + +const PodcastPlayer = ({navigation}: any) => { + const [isLiked, setisLiked] = useState(false); + const [isPlaying, setisPlaying] = useState(false); + const [duration, setDuration] = useState(0); + const [currentPosition, setCurrentPosition] = useState(0); + + // Playback-synchronization refs (best-effort, event-driven when supported) + const isSliderActiveRef = useRef(false); + const currentSpokenOffsetMsRef = useRef(0); + const currentPositionRef = useRef(0); + const durationRef = useRef(0); + + const text = `You're seeking new ways to diversify your portfolio, but it's not always easy to find new reliable investment opportunities. Each week, our financial expert with four decades of successful investing experience will help you discover opportunities outside of your current strategy that you've probably never considered before. If you want to learn about ways to diversify your portfolio in ways that have various levels of risk, this show is for you.`; + + const textSpokenAdjustment = + Platform.OS === 'ios' + ? IOS_TTS_DURATION_ADJUSTMENT + : ANDROID_TTS_DURATION_ADJUSTMENT; + const defaultRate = Platform.OS === 'ios' ? IOS_TTS_RATE : ANDROID_TTS_RATE; + + const estimateTTSDuration = (txt: string) => { + const adjustedWordsPerMinute = WORDS_PER_MINUTE * textSpokenAdjustment; + const words = txt.split(' ').length; + return (words / adjustedWordsPerMinute) * 60 * 1000; + }; + + const updateDuration = (value: number) => { + durationRef.current = value; + setDuration(value); + }; + + const initTts = async () => { + const totalDuration = estimateTTSDuration(text); + updateDuration(totalDuration); + + try { + const voices = await Tts.voices(); + const availableVoices = voices + .filter( + (v: any) => + !v.networkConnectionRequired && + !v.notInstalled && + (v.language === 'en-IN' || v.language === 'en-US'), + ) + .map((v: any) => ({id: v.id, name: v.name, language: v.language})); + + if (availableVoices && availableVoices.length > 0) { + try { + await Tts.setDefaultLanguage(availableVoices[0].language); + } catch (err) { + console.warn(`Failed to set TTS language to ${availableVoices[0].language}, using default.`, err); + } + await Tts.setDefaultVoice(availableVoices[0].id); + Tts.setDefaultRate(defaultRate, true); + Tts.setDefaultPitch(1.5); + Tts.setDucking(true); + Tts.setIgnoreSilentSwitch('ignore'); + } else { + setisPlaying(false); + } + } catch (error) { + console.error('Failed to initialize TTS voices', error); + setisPlaying(false); + } + }; + + const syncCurrentPosition = (position: number) => { + currentPositionRef.current = position; + setCurrentPosition(position); + }; + + const removeTtsSubscription = ( + subscription: TtsSubscription | null | void, + eventName: TtsEventName, + handler: TtsEventHandler, + ) => { + if (subscription?.remove) { + subscription.remove(); + return; + } + + if (typeof Tts.removeEventListener === 'function') { + Tts.removeEventListener(eventName, handler as any); + } + }; + + // Best-effort event-driven sync (react-native-tts varies by version/platform) + useEffect(() => { + let isMounted = true; + + const setup = async () => { + try { + await Tts.getInitStatus(); + if (!isMounted) { + return; + } + + await initTts(); + } catch (error) { + console.error('Failed to initialize TTS', error); + if (isMounted) { + setisPlaying(false); + } + } + }; + + setup(); + + const onStart = () => { + if (isSliderActiveRef.current) return; + currentSpokenOffsetMsRef.current = currentPositionRef.current; + setisPlaying(true); + }; + + const onProgress = (event: any) => { + if (isSliderActiveRef.current) return; + + const eventPosMs: number | null = + typeof event?.elapsedTime === 'number' + ? event.elapsedTime + : typeof event?.currentTime === 'number' + ? event.currentTime + : null; + + if (eventPosMs == null) { + console.warn('TTS progress event did not include a position update.'); + return; + } + + const newPos = Math.min( + durationRef.current, + currentSpokenOffsetMsRef.current + eventPosMs, + ); + syncCurrentPosition(newPos); + }; + + const onFinish = () => { + setisPlaying(false); + syncCurrentPosition(0); + currentSpokenOffsetMsRef.current = 0; + }; + + const onCancel = () => setisPlaying(false); + const onError = () => setisPlaying(false); + + const startSub = Tts.addEventListener('tts-start', onStart); + const progressSub = Tts.addEventListener('tts-progress', onProgress); + const finishSub = Tts.addEventListener('tts-finish', onFinish); + const cancelSub = Tts.addEventListener('tts-cancel', onCancel); + const errorSub = Tts.addEventListener('tts-error', onError); + + return () => { + isMounted = false; + removeTtsSubscription(startSub, 'tts-start', onStart); + removeTtsSubscription(progressSub, 'tts-progress', onProgress); + removeTtsSubscription(finishSub, 'tts-finish', onFinish); + removeTtsSubscription(cancelSub, 'tts-cancel', onCancel); + removeTtsSubscription(errorSub, 'tts-error', onError); + }; + }, []); + + const handleLike = () => setisLiked(!isLiked); + + // Best-effort seek: stop + re-speak from an approximated offset + const speakFromPositionMs = async (seekPositionMs: number) => { + const safeDuration = durationRef.current || 1; + const clamped = Math.max(0, Math.min(safeDuration, seekPositionMs)); + + const words = text.split(' '); + const totalWords = words.length; + const approxElapsedRatio = clamped / safeDuration; + + const wordsToSkip = Math.max( + 0, + Math.min(totalWords - 1, Math.floor(totalWords * approxElapsedRatio)), + ); + + const newText = words.slice(wordsToSkip).join(' '); + + currentSpokenOffsetMsRef.current = clamped; + currentPositionRef.current = clamped; + + Tts.stop(); + + try { + await Tts.speak(newText); + setisPlaying(true); + } catch (error) { + console.error('Failed to start TTS playback', error); + setisPlaying(false); + } + }; + + const handlePlay = () => { + if (isPlaying) { + Tts.stop(); + setisPlaying(false); + return; + } + + void speakFromPositionMs(currentPosition); + }; + + const handleSlidingStart = () => { + isSliderActiveRef.current = true; + Tts.stop(); + setisPlaying(false); + }; + + const handleSliderValueChange = (value: number) => { + const safeDuration = durationRef.current || 1; + const seekPosition = Math.max(0, Math.min(safeDuration, value * safeDuration)); + + syncCurrentPosition(seekPosition); + }; + + const handleSlidingComplete = (value: number) => { + const safeDuration = durationRef.current || 1; + const seekPosition = Math.max(0, Math.min(safeDuration, value * safeDuration)); + + isSliderActiveRef.current = false; + syncCurrentPosition(seekPosition); + void speakFromPositionMs(seekPosition); + }; + + const handleForward = () => {}; + const handleBackward = () => {}; + const handleDownload = () => {}; + const handleShare = () => {}; + + return ( + <> + Feel Better. Live More + + 8 Hidden Habits To Live Your Healthiest, Happiest and Most Fulfilled + Life with Robin Sharma + + + + + + + Posted by + Dr Rangan Chatterjee + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi vulputate + augue erat, congue lacinia turpis pellentesque aliquam. Quisque eu + tellus varius, eleifend dui sed, luctus nibh. Duis et dolor eu ligula + ultrices dictum. Class aptent taciti sociosqu ad litora torquent per + conubia nostra, per inceptos himenaeos. + + + + + + + {isLiked ? ( + + ) : ( + + )} + + 30 + + + + + + + + + + + 0 ? currentPosition / duration : 0} + minimumTrackTintColor="#FFFFFF" + maximumTrackTintColor="#000000" + onSlidingStart={handleSlidingStart} + onValueChange={handleSliderValueChange} + onSlidingComplete={handleSlidingComplete} + /> + + + + {Math.floor(currentPosition / 1000 / 60)}: + {Math.floor((currentPosition / 1000) % 60) + .toString() + .padStart(2, '0')} + + + -{Math.floor((duration - currentPosition) / 1000 / 60)}: + {Math.floor(((duration - currentPosition) / 1000) % 60) + .toString() + .padStart(2, '0')} + + + + + + + + + {isPlaying ? ( + + ) : ( + + )} + + + + + + + ); +}; + +export default PodcastPlayer; + +const styles = StyleSheet.create({ + podcast: { + fontSize: rf(15), + fontWeight: 'normal', + color: 'white', + marginTop: 10, + }, + podcastname: { + fontSize: rf(18), + fontWeight: 'bold', + color: 'white', + marginBottom: 5, + }, + postedByContainer: { + alignItems: 'flex-end', + marginBottom: 5, + }, + postedByContent: { + flexDirection: 'row', + alignItems: 'center', + gap: 7, + }, + profileImage: { + height: 40, + width: 40, + borderRadius: 100, + }, + postedByText: { + color: 'white', + fontSize: rf(10), + fontWeight: '300', + }, + postedByName: { + color: 'white', + fontSize: rf(13), + fontWeight: 'bold', + }, + description: { + color: 'white', + fontSize: rf(13), + textAlign: 'justify', + marginBottom: 15, + }, + actionsContainer: { + alignItems: 'flex-end', + marginBottom: 0, + }, + actionsContent: { + flexDirection: 'row', + alignItems: 'center', + gap: 20, + }, + likeContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + likeText: { + fontSize: rf(13), + fontWeight: 'bold', + color: 'white', + }, + slider: { + width: '100%', + }, + timeContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + top: -5, + }, + timeText: { + color: 'white', + fontSize: rf(14), + }, + controlContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + controlButton: { + marginHorizontal: 20, + }, + playButton: { + backgroundColor: 'white', + height: 40, + width: 40, + borderRadius: 100, + alignItems: 'center', + justifyContent: 'center', + }, +}); + diff --git a/frontend/src/components/ProfessionalTab.tsx b/frontend/src/components/ProfessionalTab.tsx index 128d34ca..bcf5807c 100644 --- a/frontend/src/components/ProfessionalTab.tsx +++ b/frontend/src/components/ProfessionalTab.tsx @@ -1,187 +1,188 @@ -import { - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; -import React, {useEffect} from 'react'; -import { useForm, Controller } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import {PRIMARY_COLOR} from '../helper/Theme'; +import { + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import React, {useEffect} from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import {PRIMARY_COLOR} from '../helper/Theme'; import { rf } from '../helper/Metric'; -const profSchema = z.object({ - specialization: z.string().min(1, 'Specialization is required'), - qualification: z.string().min(1, 'Qualification is required'), - experience: z.string().min(1, 'Experience is required'), -}); -export type ProfFormData = z.infer; - -export interface ProfileEditProfessionalTab { - user: any; - handleSubmitProfessionalDetails: (data: ProfFormData) => void; -} - -const ProfessionalTab = ({ - user, - handleSubmitProfessionalDetails, -}: ProfileEditProfessionalTab) => { - const { control, handleSubmit, reset } = useForm({ - resolver: zodResolver(profSchema), - mode: 'onChange', - defaultValues: { - specialization: user?.specialization || '', - qualification: user?.qualification || '', - experience: user?.Years_of_experience ? user.Years_of_experience.toString() : '', - } - }); - - useEffect(() => { - reset({ - specialization: user?.specialization || '', - qualification: user?.qualification || '', - experience: user?.Years_of_experience ? user.Years_of_experience.toString() : '', - }); - }, [user, reset]); - return ( - - - {/* Specialization Input */} - - Specialization - ( - <> - - {error && {error.message}} - - )} - /> - - - {/* Qualification Input */} - - Qualification - ( - <> - - {error && {error.message}} - - )} - /> - - - {/* Years of Experience Input */} - - Years of Experience - ( - <> - - {error && {error.message}} - - )} - /> - - - - {/* Save Button */} - - Save - - - ); -}; - -export default ProfessionalTab; - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'space-between', - height: '100%', - flexDirection: 'column', - }, - content: { - width: '100%', - flexDirection: 'column', - gap: 15, - alignItems: 'center', - }, - input: { - width: '100%', - }, - inputLabel: { - fontSize: 17, - fontWeight: '600', - color: '#222', - marginBottom: 8, - }, - inputControl: { - height: 50, - backgroundColor: '#fff', - paddingHorizontal: 16, - borderRadius: 12, - fontSize: 15, - fontWeight: '500', - color: '#222', - borderWidth: 1, - borderColor: '#C9D3DB', - borderStyle: 'solid', - }, - btn: { - width: '100%', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 8, - paddingVertical: 10, - paddingHorizontal: 16, - backgroundColor: PRIMARY_COLOR, - marginTop: 20, - }, - btnText: { - fontSize: 18, - fontWeight: 'bold', - color: 'white', - }, - errorText: { - color: 'red', - fontSize: 12, - marginTop: 4, - }, -}); + +const profSchema = z.object({ + specialization: z.string().min(1, 'Specialization is required'), + qualification: z.string().min(1, 'Qualification is required'), + experience: z.string().min(1, 'Experience is required'), +}); +export type ProfFormData = z.infer; + +export interface ProfileEditProfessionalTab { + user: any; + handleSubmitProfessionalDetails: (data: ProfFormData) => void; +} + +const ProfessionalTab = ({ + user, + handleSubmitProfessionalDetails, +}: ProfileEditProfessionalTab) => { + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(profSchema), + mode: 'onChange', + defaultValues: { + specialization: user?.specialization || '', + qualification: user?.qualification || '', + experience: user?.Years_of_experience ? user.Years_of_experience.toString() : '', + } + }); + + useEffect(() => { + reset({ + specialization: user?.specialization || '', + qualification: user?.qualification || '', + experience: user?.Years_of_experience ? user.Years_of_experience.toString() : '', + }); + }, [user, reset]); + return ( + + + {/* Specialization Input */} + + Specialization + ( + <> + + {error && {error.message}} + + )} + /> + + + {/* Qualification Input */} + + Qualification + ( + <> + + {error && {error.message}} + + )} + /> + + + {/* Years of Experience Input */} + + Years of Experience + ( + <> + + {error && {error.message}} + + )} + /> + + + + {/* Save Button */} + + Save + + + ); +}; + +export default ProfessionalTab; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'space-between', + height: '100%', + flexDirection: 'column', + }, + content: { + width: '100%', + flexDirection: 'column', + gap: 15, + alignItems: 'center', + }, + input: { + width: '100%', + }, + inputLabel: { + fontSize: rf(17), + fontWeight: '600', + color: '#222', + marginBottom: 8, + }, + inputControl: { + height: 50, + backgroundColor: '#fff', + paddingHorizontal: 16, + borderRadius: 12, + fontSize: rf(15), + fontWeight: '500', + color: '#222', + borderWidth: 1, + borderColor: '#C9D3DB', + borderStyle: 'solid', + }, + btn: { + width: '100%', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 16, + backgroundColor: PRIMARY_COLOR, + marginTop: 20, + }, + btnText: { + fontSize: rf(18), + fontWeight: 'bold', + color: 'white', + }, + errorText: { + color: 'red', + fontSize: rf(12), + marginTop: 4, + }, +}); diff --git a/frontend/src/components/ResearchSummaryCard.tsx b/frontend/src/components/ResearchSummaryCard.tsx index e995a6ba..da524008 100644 --- a/frontend/src/components/ResearchSummaryCard.tsx +++ b/frontend/src/components/ResearchSummaryCard.tsx @@ -1,161 +1,162 @@ -import React, { useState } from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - ActivityIndicator, - useColorScheme, -} from 'react-native'; -import { ArticleSummary } from '../services/SummaryService'; +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ActivityIndicator, + useColorScheme, +} from 'react-native'; +import { ArticleSummary } from '../services/SummaryService'; import { rf } from '../helper/Metric'; -interface Props { - summary: ArticleSummary | null; - loading: boolean; -} - -const ResearchSummaryCard: React.FC = ({ summary, loading }) => { - const [expanded, setExpanded] = useState(false); - const isDark = useColorScheme() === 'dark'; - - const cardBg = isDark ? '#1E2A38' : '#EAF4FB'; - const border = isDark ? '#2E4057' : '#B0D4F1'; - const textColor = isDark ? '#E0E0E0' : '#1A1A2E'; - const accent = '#3A86FF'; - const mutedText = isDark ? '#888888' : '#999999'; - - // Show spinner while API is loading - if (loading) { - return ( - - - 📋 Generating AI Summary... - - - - This may take a few seconds - - - ); - } - - // Show nothing if summary failed or not available - if (!summary) return null; - - return ( - - {/* Header row (tap to expand/collapse) */} - setExpanded(prev => !prev)} - style={styles.header} - accessibilityRole="button" - accessibilityLabel="Toggle AI research summary" - activeOpacity={0.7} - > - - - 📋 Research Summary - - - ✨ AI-generated · Not medical advice - - - - {expanded ? '▲' : '▼'} - - - - {/* Simplified explanation — always visible */} - - {summary.simplifiedExplanation} - - - {/* Expandable section */} - {expanded && ( - - {/* Key Findings */} - - 🔬 Key Findings - - {summary.keyFindings.map((item, i) => ( - - • {item} - - ))} - - {/* Beginner Takeaways */} - - 💡 Beginner Takeaways - - {summary.beginnerTakeaways.map((item, i) => ( - - ✓ {item} - - ))} - - {/* Why It Matters */} - - ❤️ Why This Matters - - - {summary.whyItMatters} - - - )} - - ); -}; - -const styles = StyleSheet.create({ - card: { - borderRadius: 12, - borderWidth: 1, - padding: 16, - marginVertical: 12, - marginHorizontal: 16, - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.08, - shadowRadius: 4, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - marginBottom: 10, - }, - headerTitle: { - fontSize: 16, - fontWeight: '700', - }, - aiTag: { - fontSize: 10, - marginTop: 3, - fontStyle: 'italic', - }, - loadingNote: { - fontSize: 12, - marginTop: 6, - textAlign: 'center', - }, - bodyText: { - fontSize: 14, - lineHeight: 22, - marginBottom: 6, - }, - sectionTitle: { - fontSize: 14, - fontWeight: '700', - marginTop: 14, - marginBottom: 6, - }, - bullet: { - fontSize: 13, - lineHeight: 20, - paddingLeft: 8, - marginBottom: 4, - }, -}); - -export default ResearchSummaryCard; + +interface Props { + summary: ArticleSummary | null; + loading: boolean; +} + +const ResearchSummaryCard: React.FC = ({ summary, loading }) => { + const [expanded, setExpanded] = useState(false); + const isDark = useColorScheme() === 'dark'; + + const cardBg = isDark ? '#1E2A38' : '#EAF4FB'; + const border = isDark ? '#2E4057' : '#B0D4F1'; + const textColor = isDark ? '#E0E0E0' : '#1A1A2E'; + const accent = '#3A86FF'; + const mutedText = isDark ? '#888888' : '#999999'; + + // Show spinner while API is loading + if (loading) { + return ( + + + 📋 Generating AI Summary... + + + + This may take a few seconds + + + ); + } + + // Show nothing if summary failed or not available + if (!summary) return null; + + return ( + + {/* Header row (tap to expand/collapse) */} + setExpanded(prev => !prev)} + style={styles.header} + accessibilityRole="button" + accessibilityLabel="Toggle AI research summary" + activeOpacity={0.7} + > + + + 📋 Research Summary + + + ✨ AI-generated · Not medical advice + + + + {expanded ? '▲' : '▼'} + + + + {/* Simplified explanation — always visible */} + + {summary.simplifiedExplanation} + + + {/* Expandable section */} + {expanded && ( + + {/* Key Findings */} + + 🔬 Key Findings + + {summary.keyFindings.map((item, i) => ( + + • {item} + + ))} + + {/* Beginner Takeaways */} + + 💡 Beginner Takeaways + + {summary.beginnerTakeaways.map((item, i) => ( + + ✓ {item} + + ))} + + {/* Why It Matters */} + + ❤️ Why This Matters + + + {summary.whyItMatters} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + card: { + borderRadius: 12, + borderWidth: 1, + padding: 16, + marginVertical: 12, + marginHorizontal: 16, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.08, + shadowRadius: 4, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 10, + }, + headerTitle: { + fontSize: rf(16), + fontWeight: '700', + }, + aiTag: { + fontSize: rf(10), + marginTop: 3, + fontStyle: 'italic', + }, + loadingNote: { + fontSize: rf(12), + marginTop: 6, + textAlign: 'center', + }, + bodyText: { + fontSize: rf(14), + lineHeight: 22, + marginBottom: 6, + }, + sectionTitle: { + fontSize: rf(14), + fontWeight: '700', + marginTop: 14, + marginBottom: 6, + }, + bullet: { + fontSize: rf(13), + lineHeight: 20, + paddingLeft: 8, + marginBottom: 4, + }, +}); + +export default ResearchSummaryCard; diff --git a/frontend/src/components/ReviewCard.tsx b/frontend/src/components/ReviewCard.tsx index 29925ead..94ac1874 100644 --- a/frontend/src/components/ReviewCard.tsx +++ b/frontend/src/components/ReviewCard.tsx @@ -1,318 +1,319 @@ -import { - StyleSheet, - Text, - View, - Pressable, - TouchableOpacity, -} from 'react-native'; -import React, { useState } from 'react'; -import {fp, hp} from '../helper/Metric'; -import {ReviewCardProps} from '../type'; -import { formatDateShortYear } from '../helper/dateUtils'; -import {BUTTON_COLOR, PRIMARY_COLOR} from '../helper/Theme'; -import {formatCount, StatusEnum} from '../helper/Utils'; -import { - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; -import ArticleFloatingMenu from './ArticleFloatingMenu'; -//import io from 'socket.io-client'; -import Entypo from '@expo/vector-icons/Entypo'; -import AntDesign from '@expo/vector-icons/AntDesign'; +import { + StyleSheet, + Text, + View, + Pressable, + TouchableOpacity, +} from 'react-native'; +import React, { useState } from 'react'; +import {fp, hp} from '../helper/Metric'; +import {ReviewCardProps} from '../type'; +import { formatDateShortYear } from '../helper/dateUtils'; +import {BUTTON_COLOR, PRIMARY_COLOR} from '../helper/Theme'; +import {formatCount, StatusEnum} from '../helper/Utils'; +import { + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import ArticleFloatingMenu from './ArticleFloatingMenu'; +//import io from 'socket.io-client'; +import Entypo from '@expo/vector-icons/Entypo'; +import AntDesign from '@expo/vector-icons/AntDesign'; import { rf } from '../helper/Metric'; -const ReviewCard = ({ - item, - //navigation, - onclick, - isSelected, - setSelectedCardId, -}: ReviewCardProps) => { - //const socket = io('http://51.20.1.81:8084'); - //const socket = useSocket(); - const width = useSharedValue(0); - const yValue = useSharedValue(60); - - const [menuVisible, setMenuVisible] = useState(false); - const backgroundColor = - item?.status === StatusEnum.PUBLISHED - ? 'green' - : item?.status === StatusEnum.DISCARDED - ? 'red' - : BUTTON_COLOR; - - //console.log('Image Utils', item?.imageUtils[0]); - - const handleAnimation = () => { - if (width.value === 0) { - width.value = withTiming(250, {duration: 250}); - yValue.value = withTiming(-1, {duration: 250}); - setSelectedCardId(item._id); - } else { - width.value = withTiming(0, {duration: 250}); - yValue.value = withTiming(100, {duration: 250}); - setSelectedCardId(''); - } - }; - - return ( - { - width.value = withTiming(0, {duration: 250}); - yValue.value = withTiming(100, {duration: 250}); - setSelectedCardId(''); - /* - navigation.navigate('ArticleScreen', { - articleId: Number(item._id), - authorId: item.authorId, - }); - */ - onclick(item); - }}> - - {/* Image Section */} - - - {/* Share Icon */} - - { - handleAnimation(); - }, - icon: 'edit', - }, - ]} - visible={menuVisible} - onDismiss={()=>{ - setMenuVisible(false); - } } - /> - - - {/* Icon for more options */} - { - setMenuVisible(true) - }}> - - - - {/* Title & Footer Text */} - - {item?.tags.map(tag => tag.name).join(' | ')} - - {item?.title} - - {item?.description} - - - {item?.viewCount - ? item?.viewCount > 1 - ? `${formatCount(item?.viewCount)} views` - : `${item?.viewCount} view` - : '0 view'} - - - - Last updated: {''} - {formatDateShortYear(item?.lastUpdated)} - - - - - {item?.status ? item.status.toLocaleUpperCase() : 'Not found'} - - - { - onclick(item); - }}> - View - - - - - {/* Like, Save, and Comment Actions */} - - - - ); -}; - -export default ReviewCard; - -const styles = StyleSheet.create({ - cardContainer: { - flex: 0, - width: '100%', - backgroundColor: '#ffffff', - flexDirection: 'row', - marginVertical: hp(1.5), - overflow: 'hidden', - borderRadius: 16, - shadowColor: '#000', - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.08, - shadowRadius: 12, - elevation: 5, - borderWidth: 1, - borderColor: '#F0F0F0', - }, - image: { - flex: 0.8, - resizeMode: 'cover', - }, - - likeSaveContainer: { - flexDirection: 'row', - width: '100%', - marginTop: 6, - justifyContent: 'space-between', - }, - - likeSaveChildContainer: { - flexDirection: 'row', - justifyContent: 'flex-start', - marginHorizontal: hp(0), - marginVertical: hp(1), - }, - textContainer: { - flex: 1, - backgroundColor: 'white', - paddingHorizontal: 16, - paddingVertical: 16, - }, - title: { - fontSize: fp(5.5), - fontWeight: '800', - color: '#1A1A1A', - marginBottom: 6, - lineHeight: fp(6.5), - letterSpacing: 0.3, - }, - description: { - fontSize: fp(3.4), - fontWeight: '500', - lineHeight: 20, - color: '#666666', - marginBottom: 12, - }, - footerText: { - fontSize: fp(3.5), - fontWeight: '700', - color: BUTTON_COLOR, - marginBottom: 6, - textTransform: 'uppercase', - letterSpacing: 0.5, - }, - - footerText1: { - fontSize: fp(3.3), - fontWeight: '500', - color: '#5A5A5A', - marginBottom: 4, - }, - - footerContainer: { - flex: 0, - width: '100%', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginTop: 4, - }, - shareIconContainer: { - position: 'absolute', - top: 8, - right: 8, - zIndex: 1, - backgroundColor: '#F8F8F8', - borderRadius: 20, - padding: 8, - }, - viewContainer: { - justifyContent: 'space-between', - flexDirection: 'row', - paddingHorizontal: 2, - marginTop: 8, - paddingTop: 12, - borderTopWidth: 1, - borderTopColor: '#F0F0F0', - }, - viewInnnerContainer: { - justifyContent: 'flex-start', - flexDirection: 'row', - padding: 4, - backgroundColor: '#F0F8FF', - borderRadius: 8, - paddingHorizontal: 12, - }, - viewText: { - fontSize: fp(3.8), - color: PRIMARY_COLOR, - fontWeight: '700', - marginRight: 4, - }, - // future card styles - // card: { - // marginBottom: 20, - // backgroundColor: 'white', - // padding: 15, - // borderRadius: 10, - // }, - // image: { - // width: '100%', - // height: 200, - // borderRadius: 10, - // resizeMode: 'cover', - // }, - // content: { - // padding: 10, - // }, - // title: { - // fontSize: 20, - // fontWeight: 'bold', - // marginBottom: 10, - // }, - // author: { - // fontSize: 14, - // color: '#999', - // marginBottom: 10, - // }, - // description: { - // fontSize: 14, - // }, - // categoriesContainer: { - // flexDirection: 'row', - // marginTop: 10, - // gap: 5, - // }, - // category: { - // padding: 10, - // borderRadius: 50, - // backgroundColor: PRIMARY_COLOR, - // marginTop: 5, - // }, - // categoryText: { - // color: 'white', - // fontWeight: '600', - // }, -}); + +const ReviewCard = ({ + item, + //navigation, + onclick, + isSelected, + setSelectedCardId, +}: ReviewCardProps) => { + //const socket = io('http://51.20.1.81:8084'); + //const socket = useSocket(); + const width = useSharedValue(0); + const yValue = useSharedValue(60); + + const [menuVisible, setMenuVisible] = useState(false); + const backgroundColor = + item?.status === StatusEnum.PUBLISHED + ? 'green' + : item?.status === StatusEnum.DISCARDED + ? 'red' + : BUTTON_COLOR; + + //console.log('Image Utils', item?.imageUtils[0]); + + const handleAnimation = () => { + if (width.value === 0) { + width.value = withTiming(250, {duration: 250}); + yValue.value = withTiming(-1, {duration: 250}); + setSelectedCardId(item._id); + } else { + width.value = withTiming(0, {duration: 250}); + yValue.value = withTiming(100, {duration: 250}); + setSelectedCardId(''); + } + }; + + return ( + { + width.value = withTiming(0, {duration: 250}); + yValue.value = withTiming(100, {duration: 250}); + setSelectedCardId(''); + /* + navigation.navigate('ArticleScreen', { + articleId: Number(item._id), + authorId: item.authorId, + }); + */ + onclick(item); + }}> + + {/* Image Section */} + + + {/* Share Icon */} + + { + handleAnimation(); + }, + icon: 'edit', + }, + ]} + visible={menuVisible} + onDismiss={()=>{ + setMenuVisible(false); + } } + /> + + + {/* Icon for more options */} + { + setMenuVisible(true) + }}> + + + + {/* Title & Footer Text */} + + {item?.tags.map(tag => tag.name).join(' | ')} + + {item?.title} + + {item?.description} + + + {item?.viewCount + ? item?.viewCount > 1 + ? `${formatCount(item?.viewCount)} views` + : `${item?.viewCount} view` + : '0 view'} + + + + Last updated: {''} + {formatDateShortYear(item?.lastUpdated)} + + + + + {item?.status ? item.status.toLocaleUpperCase() : 'Not found'} + + + { + onclick(item); + }}> + View + + + + + {/* Like, Save, and Comment Actions */} + + + + ); +}; + +export default ReviewCard; + +const styles = StyleSheet.create({ + cardContainer: { + flex: 0, + width: '100%', + backgroundColor: '#ffffff', + flexDirection: 'row', + marginVertical: hp(1.5), + overflow: 'hidden', + borderRadius: 16, + shadowColor: '#000', + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.08, + shadowRadius: 12, + elevation: 5, + borderWidth: 1, + borderColor: '#F0F0F0', + }, + image: { + flex: 0.8, + resizeMode: 'cover', + }, + + likeSaveContainer: { + flexDirection: 'row', + width: '100%', + marginTop: 6, + justifyContent: 'space-between', + }, + + likeSaveChildContainer: { + flexDirection: 'row', + justifyContent: 'flex-start', + marginHorizontal: hp(0), + marginVertical: hp(1), + }, + textContainer: { + flex: 1, + backgroundColor: 'white', + paddingHorizontal: 16, + paddingVertical: 16, + }, + title: { + fontSize: fp(5.5), + fontWeight: '800', + color: '#1A1A1A', + marginBottom: 6, + lineHeight: fp(6.5), + letterSpacing: 0.3, + }, + description: { + fontSize: fp(3.4), + fontWeight: '500', + lineHeight: 20, + color: '#666666', + marginBottom: 12, + }, + footerText: { + fontSize: fp(3.5), + fontWeight: '700', + color: BUTTON_COLOR, + marginBottom: 6, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + + footerText1: { + fontSize: fp(3.3), + fontWeight: '500', + color: '#5A5A5A', + marginBottom: 4, + }, + + footerContainer: { + flex: 0, + width: '100%', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 4, + }, + shareIconContainer: { + position: 'absolute', + top: 8, + right: 8, + zIndex: 1, + backgroundColor: '#F8F8F8', + borderRadius: 20, + padding: 8, + }, + viewContainer: { + justifyContent: 'space-between', + flexDirection: 'row', + paddingHorizontal: 2, + marginTop: 8, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: '#F0F0F0', + }, + viewInnnerContainer: { + justifyContent: 'flex-start', + flexDirection: 'row', + padding: 4, + backgroundColor: '#F0F8FF', + borderRadius: 8, + paddingHorizontal: 12, + }, + viewText: { + fontSize: fp(3.8), + color: PRIMARY_COLOR, + fontWeight: '700', + marginRight: 4, + }, + // future card styles + // card: { + // marginBottom: 20, + // backgroundColor: 'white', + // padding: 15, + // borderRadius: 10, + // }, + // image: { + // width: '100%', + // height: 200, + // borderRadius: 10, + // resizeMode: 'cover', + // }, + // content: { + // padding: 10, + // }, + // title: { + // fontSize: rf(20), + // fontWeight: 'bold', + // marginBottom: 10, + // }, + // author: { + // fontSize: rf(14), + // color: '#999', + // marginBottom: 10, + // }, + // description: { + // fontSize: rf(14), + // }, + // categoriesContainer: { + // flexDirection: 'row', + // marginTop: 10, + // gap: 5, + // }, + // category: { + // padding: 10, + // borderRadius: 50, + // backgroundColor: PRIMARY_COLOR, + // marginTop: 5, + // }, + // categoryText: { + // color: 'white', + // fontWeight: '600', + // }, +}); diff --git a/frontend/src/components/ReviewItem.tsx b/frontend/src/components/ReviewItem.tsx index 9c6f432b..26785e4a 100644 --- a/frontend/src/components/ReviewItem.tsx +++ b/frontend/src/components/ReviewItem.tsx @@ -1,89 +1,90 @@ -import React from 'react'; -import {Comment} from '../type'; -import { formatWithOrdinalAndDay } from '../helper/dateUtils'; -import {Avatar, XStack, YStack, Text, Paragraph} from 'tamagui'; -import {GET_STORAGE_DATA} from '../helper/APIUtils'; - -export default function ReviewItem({item}: {item: Comment}) { - - - const formatWithOrdinal = (date: string | Date) => { - return formatWithOrdinalAndDay(date); - }; - - - return ( - - {/* Main Comment Layout */} - - {/* Profile Image */} - - - - - - {/* Comment Content */} - - - - - {item.adminId ? item.adminId.user_handle : item.userId.user_handle} - - {item.isEdited && ( - - (edited) - - )} - - - - - {item.content} - - - - Last updated {formatWithOrdinal(item.updatedAt)} - - - - - ); -} - +import React from 'react'; +import {Comment} from '../type'; +import { formatWithOrdinalAndDay } from '../helper/dateUtils'; +import {Avatar, XStack, YStack, Text, Paragraph} from 'tamagui'; +import {GET_STORAGE_DATA} from '../helper/APIUtils'; import { rf } from '../helper/Metric'; + +export default function ReviewItem({item}: {item: Comment}) { + + + const formatWithOrdinal = (date: string | Date) => { + return formatWithOrdinalAndDay(date); + }; + + + return ( + + {/* Main Comment Layout */} + + {/* Profile Image */} + + + + + + {/* Comment Content */} + + + + + {item.adminId ? item.adminId.user_handle : item.userId.user_handle} + + {item.isEdited && ( + + (edited) + + )} + + + + + {item.content} + + + + Last updated {formatWithOrdinal(item.updatedAt)} + + + + + ); +} + + diff --git a/frontend/src/components/SecurityWarningModal.tsx b/frontend/src/components/SecurityWarningModal.tsx index 03412db4..f5766bad 100644 --- a/frontend/src/components/SecurityWarningModal.tsx +++ b/frontend/src/components/SecurityWarningModal.tsx @@ -1,114 +1,115 @@ -import React from 'react'; -import { - Modal, - View, - Text, - TouchableOpacity, - StyleSheet, -} from 'react-native'; +import React from 'react'; +import { import { rf } from '../helper/Metric'; -interface Props { - visible: boolean; - onContinue: () => void; - onCancel: () => void; -} - -const SecurityWarningModal: React.FC = ({ - visible, - onContinue, - onCancel, -}) => { - return ( - - - - Security Warning - - Do not create an account using the same password as your Google, - Facebook, or any other authentication account. Please use a unique - and strong password for this platform. Please do not share your - password with anyone else. - - - - Cancel - - - Continue - - - - - - ); -}; - -const styles = StyleSheet.create({ - modalBackground: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.5)', - }, - container: { - width: '85%', - backgroundColor: '#fff', - borderRadius: 10, - padding: 24, - }, - title: { - fontSize: 20, - fontWeight: '700', - color: '#d9534f', - marginBottom: 12, - textAlign: 'center', - }, - message: { - fontSize: 15, - color: '#444', - marginBottom: 24, - lineHeight: 22, - textAlign: 'center', - }, - buttonRow: { - flexDirection: 'row', - justifyContent: 'space-between', - gap: 12, - }, - button: { - flex: 1, - padding: 12, - borderRadius: 6, - alignItems: 'center', - }, - cancelButton: { - backgroundColor: '#f0f0f0', - borderWidth: 1, - borderColor: '#ccc', - }, - continueButton: { - backgroundColor: '#007BFF', - }, - cancelButtonText: { - fontSize: 15, - color: '#333', - fontWeight: '600', - }, - continueButtonText: { - fontSize: 15, - color: '#fff', - fontWeight: '600', - }, -}); - -export default SecurityWarningModal; + Modal, + View, + Text, + TouchableOpacity, + StyleSheet, +} from 'react-native'; + +interface Props { + visible: boolean; + onContinue: () => void; + onCancel: () => void; +} + +const SecurityWarningModal: React.FC = ({ + visible, + onContinue, + onCancel, +}) => { + return ( + + + + Security Warning + + Do not create an account using the same password as your Google, + Facebook, or any other authentication account. Please use a unique + and strong password for this platform. Please do not share your + password with anyone else. + + + + Cancel + + + Continue + + + + + + ); +}; + +const styles = StyleSheet.create({ + modalBackground: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + container: { + width: '85%', + backgroundColor: '#fff', + borderRadius: 10, + padding: 24, + }, + title: { + fontSize: rf(20), + fontWeight: '700', + color: '#d9534f', + marginBottom: 12, + textAlign: 'center', + }, + message: { + fontSize: rf(15), + color: '#444', + marginBottom: 24, + lineHeight: 22, + textAlign: 'center', + }, + buttonRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 12, + }, + button: { + flex: 1, + padding: 12, + borderRadius: 6, + alignItems: 'center', + }, + cancelButton: { + backgroundColor: '#f0f0f0', + borderWidth: 1, + borderColor: '#ccc', + }, + continueButton: { + backgroundColor: '#007BFF', + }, + cancelButtonText: { + fontSize: rf(15), + color: '#333', + fontWeight: '600', + }, + continueButtonText: { + fontSize: rf(15), + color: '#fff', + fontWeight: '600', + }, +}); + +export default SecurityWarningModal; diff --git a/frontend/src/components/StatisticsCard.tsx b/frontend/src/components/StatisticsCard.tsx index d930b94a..d0d0eec2 100644 --- a/frontend/src/components/StatisticsCard.tsx +++ b/frontend/src/components/StatisticsCard.tsx @@ -1,117 +1,118 @@ -import React from 'react'; -import {StyleSheet, View} from 'react-native'; -import {Card, XStack, YStack, Text} from 'tamagui'; -import {PRIMARY_COLOR} from '../helper/Theme'; -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import FontAwesome from '@expo/vector-icons/FontAwesome'; +import React from 'react'; +import {StyleSheet, View} from 'react-native'; +import {Card, XStack, YStack, Text} from 'tamagui'; +import {PRIMARY_COLOR} from '../helper/Theme'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import FontAwesome from '@expo/vector-icons/FontAwesome'; import { rf } from '../helper/Metric'; - -interface StatisticsCardProps { - totalLikes: number; - totalViews: number; - totalArticles: number; - totalPodcasts?: number; - improvements?: number; -} - -const StatisticsCard = ({ - totalLikes, - totalViews, - totalArticles, - totalPodcasts = 0, - improvements = 0, -}:StatisticsCardProps) => { - // removed isDarkMode variable - - const StatItem = ({ - icon, - label, - value, - color, - }: { - icon: React.ReactNode; - label: string; - value: string | number; - color: string; - }) => ( - - - {icon} - - {value} - - {label} - - - ); - - return ( - - - Statistics Overview - - - - } - label="Total Likes" - value={totalLikes} - color="#E91E63" - /> - } - label="Total Views" - value={totalViews} - color="#2196F3" - /> - } - label="Articles" - value={`${totalArticles + improvements}`} - color="#FF9800" - /> - - - ); -}; - -export default StatisticsCard; - -const styles = StyleSheet.create({ - sectionTitle: { - fontSize: 19, - fontWeight: '700', - marginBottom: 8, - }, - iconContainer: { - width: 50, - height: 50, - borderRadius: 25, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 8, - }, - statValue: { - fontSize: 22, - fontWeight: '700', - marginVertical: 4, - }, - statLabel: { - fontSize: 13, - fontWeight: '500', - textAlign: 'center', - }, -}); + + +interface StatisticsCardProps { + totalLikes: number; + totalViews: number; + totalArticles: number; + totalPodcasts?: number; + improvements?: number; +} + +const StatisticsCard = ({ + totalLikes, + totalViews, + totalArticles, + totalPodcasts = 0, + improvements = 0, +}:StatisticsCardProps) => { + // removed isDarkMode variable + + const StatItem = ({ + icon, + label, + value, + color, + }: { + icon: React.ReactNode; + label: string; + value: string | number; + color: string; + }) => ( + + + {icon} + + {value} + + {label} + + + ); + + return ( + + + Statistics Overview + + + + } + label="Total Likes" + value={totalLikes} + color="#E91E63" + /> + } + label="Total Views" + value={totalViews} + color="#2196F3" + /> + } + label="Articles" + value={`${totalArticles + improvements}`} + color="#FF9800" + /> + + + ); +}; + +export default StatisticsCard; + +const styles = StyleSheet.create({ + sectionTitle: { + fontSize: rf(19), + fontWeight: '700', + marginBottom: 8, + }, + iconContainer: { + width: 50, + height: 50, + borderRadius: 25, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 8, + }, + statValue: { + fontSize: rf(22), + fontWeight: '700', + marginVertical: 4, + }, + statLabel: { + fontSize: rf(13), + fontWeight: '500', + textAlign: 'center', + }, +}); diff --git a/frontend/src/components/StructuredPodcastCard.tsx b/frontend/src/components/StructuredPodcastCard.tsx index 77bdce63..9fcf3dbf 100644 --- a/frontend/src/components/StructuredPodcastCard.tsx +++ b/frontend/src/components/StructuredPodcastCard.tsx @@ -1,161 +1,162 @@ -import React from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - useColorScheme, -} from 'react-native'; -import { NavigationProp, useNavigation, useFocusEffect } from '@react-navigation/native'; -import { RootStackParamList } from '../type'; -import { getPlaybackPosition, PlaybackPosition } from '../helper/PlaybackManager'; +import React from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + useColorScheme, +} from 'react-native'; +import { NavigationProp, useNavigation, useFocusEffect } from '@react-navigation/native'; +import { RootStackParamList } from '../type'; +import { getPlaybackPosition, PlaybackPosition } from '../helper/PlaybackManager'; import { rf } from '../helper/Metric'; -interface PodcastEpisode { - id: string; - title: string; - description: string; - durationMinutes: number; - topic: string; -} - -interface StructuredPodcastCardProps { - relatedEpisodes: PodcastEpisode[]; -} - -const StructuredPodcastCard: React.FC = ({ - relatedEpisodes, -}: StructuredPodcastCardProps) => { - const colorScheme = useColorScheme(); - const isDark = colorScheme === 'dark'; - const cardBg = isDark ? '#1A2A1A' : '#F0FAF0'; - const textColor = isDark ? '#E0E0E0' : '#1A1A2E'; - const accentColor = '#2DC653'; - const borderColor = isDark ? '#2A402A' : '#A8DDB5'; - const episodeBg = isDark ? '#253525' : '#FFFFFF'; - - const navigation = useNavigation>(); - const [progresses, setProgresses] = React.useState>({}); - - useFocusEffect( - React.useCallback(() => { - let isMounted = true; - const fetchProgresses = async () => { - if (!relatedEpisodes) return; - const results: Record = {}; - for (const ep of relatedEpisodes) { - const pos = await getPlaybackPosition(ep.id); - if (pos) { - results[ep.id] = pos; - } - } - if (isMounted) { - setProgresses(results); - } - }; - fetchProgresses(); - return () => { - isMounted = false; - }; - }, [relatedEpisodes]) - ); - - if (!relatedEpisodes || relatedEpisodes.length === 0) return null; - - return ( - - 🎙️ Related Podcast Episodes - Deepen your understanding with expert audio - - {relatedEpisodes.map((episode: PodcastEpisode) => ( - - navigation.navigate('PodcastDetail', { trackId: episode.id }) - } - accessibilityRole="button" - accessibilityLabel={`Listen to ${episode.title}`} - > - - ▶️ - - - {episode.title} - - {episode.description} - - 🕐 {episode.durationMinutes} min · {episode.topic} - {progresses[episode.id] && progresses[episode.id].duration > 0 && ( - - - - )} - - - ))} - - ); -}; - -const styles = StyleSheet.create({ - card: { - borderRadius: 12, - borderWidth: 1, - padding: 16, - marginVertical: 12, - marginHorizontal: 16, - }, - headerTitle: { - fontSize: 16, - fontWeight: '700', - marginBottom: 4, - }, - subtitle: { - fontSize: 12, - marginBottom: 12, - opacity: 0.8, - }, - episodeRow: { - flexDirection: 'row', - alignItems: 'flex-start', - borderRadius: 8, - padding: 10, - marginBottom: 8, - elevation: 1, - }, - episodeIcon: { - marginRight: 12, - paddingTop: 2, - }, - episodeInfo: { - flex: 1, - }, - episodeTitle: { - fontSize: 14, - fontWeight: '600', - marginBottom: 3, - }, - episodeDesc: { - fontSize: 12, - lineHeight: 18, - marginBottom: 4, - }, - episodeMeta: { - fontSize: 11, - fontWeight: '500', - }, - progressBarContainer: { - height: 3, - backgroundColor: 'rgba(150, 150, 150, 0.2)', - borderRadius: 2, - marginTop: 8, - width: '100%', - overflow: 'hidden', - }, - progressBar: { - height: '100%', - borderRadius: 2, - }, -}); - -export default StructuredPodcastCard; + +interface PodcastEpisode { + id: string; + title: string; + description: string; + durationMinutes: number; + topic: string; +} + +interface StructuredPodcastCardProps { + relatedEpisodes: PodcastEpisode[]; +} + +const StructuredPodcastCard: React.FC = ({ + relatedEpisodes, +}: StructuredPodcastCardProps) => { + const colorScheme = useColorScheme(); + const isDark = colorScheme === 'dark'; + const cardBg = isDark ? '#1A2A1A' : '#F0FAF0'; + const textColor = isDark ? '#E0E0E0' : '#1A1A2E'; + const accentColor = '#2DC653'; + const borderColor = isDark ? '#2A402A' : '#A8DDB5'; + const episodeBg = isDark ? '#253525' : '#FFFFFF'; + + const navigation = useNavigation>(); + const [progresses, setProgresses] = React.useState>({}); + + useFocusEffect( + React.useCallback(() => { + let isMounted = true; + const fetchProgresses = async () => { + if (!relatedEpisodes) return; + const results: Record = {}; + for (const ep of relatedEpisodes) { + const pos = await getPlaybackPosition(ep.id); + if (pos) { + results[ep.id] = pos; + } + } + if (isMounted) { + setProgresses(results); + } + }; + fetchProgresses(); + return () => { + isMounted = false; + }; + }, [relatedEpisodes]) + ); + + if (!relatedEpisodes || relatedEpisodes.length === 0) return null; + + return ( + + 🎙️ Related Podcast Episodes + Deepen your understanding with expert audio + + {relatedEpisodes.map((episode: PodcastEpisode) => ( + + navigation.navigate('PodcastDetail', { trackId: episode.id }) + } + accessibilityRole="button" + accessibilityLabel={`Listen to ${episode.title}`} + > + + ▶️ + + + {episode.title} + + {episode.description} + + 🕐 {episode.durationMinutes} min · {episode.topic} + {progresses[episode.id] && progresses[episode.id].duration > 0 && ( + + + + )} + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + card: { + borderRadius: 12, + borderWidth: 1, + padding: 16, + marginVertical: 12, + marginHorizontal: 16, + }, + headerTitle: { + fontSize: rf(16), + fontWeight: '700', + marginBottom: 4, + }, + subtitle: { + fontSize: rf(12), + marginBottom: 12, + opacity: 0.8, + }, + episodeRow: { + flexDirection: 'row', + alignItems: 'flex-start', + borderRadius: 8, + padding: 10, + marginBottom: 8, + elevation: 1, + }, + episodeIcon: { + marginRight: 12, + paddingTop: 2, + }, + episodeInfo: { + flex: 1, + }, + episodeTitle: { + fontSize: rf(14), + fontWeight: '600', + marginBottom: 3, + }, + episodeDesc: { + fontSize: rf(12), + lineHeight: 18, + marginBottom: 4, + }, + episodeMeta: { + fontSize: rf(11), + fontWeight: '500', + }, + progressBarContainer: { + height: 3, + backgroundColor: 'rgba(150, 150, 150, 0.2)', + borderRadius: 2, + marginTop: 8, + width: '100%', + overflow: 'hidden', + }, + progressBar: { + height: '100%', + borderRadius: 2, + }, +}); + +export default StructuredPodcastCard; diff --git a/frontend/src/components/UpdateModal.tsx b/frontend/src/components/UpdateModal.tsx index a2e99950..4b1dfa10 100644 --- a/frontend/src/components/UpdateModal.tsx +++ b/frontend/src/components/UpdateModal.tsx @@ -1,46 +1,47 @@ -import { Modal, View, Text, Pressable, Linking } from 'react-native'; +import { Modal, View, Text, Pressable, Linking } from 'react-native'; import { rf } from '../helper/Metric'; -export default function UpdateModal({ visible, storeUrl }: any) { - return ( - - - - - Update Available 🚀 - - - - Please update the app to continue using all features. - - - Linking.openURL(storeUrl)} - style={{ - backgroundColor: '#000', - paddingVertical: 10, - paddingHorizontal: 20, - borderRadius: 8 - }} - > - Update Now - - - - - ); -} + +export default function UpdateModal({ visible, storeUrl }: any) { + return ( + + + + + Update Available 🚀 + + + + Please update the app to continue using all features. + + + Linking.openURL(storeUrl)} + style={{ + backgroundColor: '#000', + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 8 + }} + > + Update Now + + + + + ); +} diff --git a/frontend/src/components/VerifiedModal.tsx b/frontend/src/components/VerifiedModal.tsx index 617c242d..8788c16e 100644 --- a/frontend/src/components/VerifiedModal.tsx +++ b/frontend/src/components/VerifiedModal.tsx @@ -1,91 +1,92 @@ -import React from 'react'; -import { - Modal, - View, - Text, - Image, - TouchableOpacity, - StyleSheet, -} from 'react-native'; +import React from 'react'; +import { import { rf } from '../helper/Metric'; -interface Props { - visible: boolean; - onClick: () => void; - onClose: () => void; - message: string; -} - -const EmailVerifiedModal: React.FC = ({ - visible, - onClick, - onClose, - message, -}) => { - return ( - - - - - Welcome - - Registration successful. Please verify your email. - - - {message} - - - - - ); -}; - -const styles = StyleSheet.create({ - modalBackground: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.5)', - }, - container: { - width: '80%', - backgroundColor: '#fff', - borderRadius: 10, - padding: 40, - }, - logo: { - width: 100, - height: 100, - alignSelf: 'center', - }, - title: { - fontSize: 24, - color: '#007BFF', - marginBottom: 10, - alignSelf: 'center', - }, - message: { - fontSize: 16, - color: '#666', - marginBottom: 20, - textAlign: 'center', - }, - button: { - backgroundColor: '#007BFF', - padding: 10, - borderRadius: 5, - }, - buttonText: { - fontSize: 16, - color: '#fff', - alignSelf: 'center', - }, -}); - -export default EmailVerifiedModal; + Modal, + View, + Text, + Image, + TouchableOpacity, + StyleSheet, +} from 'react-native'; + +interface Props { + visible: boolean; + onClick: () => void; + onClose: () => void; + message: string; +} + +const EmailVerifiedModal: React.FC = ({ + visible, + onClick, + onClose, + message, +}) => { + return ( + + + + + Welcome + + Registration successful. Please verify your email. + + + {message} + + + + + ); +}; + +const styles = StyleSheet.create({ + modalBackground: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + container: { + width: '80%', + backgroundColor: '#fff', + borderRadius: 10, + padding: 40, + }, + logo: { + width: 100, + height: 100, + alignSelf: 'center', + }, + title: { + fontSize: rf(24), + color: '#007BFF', + marginBottom: 10, + alignSelf: 'center', + }, + message: { + fontSize: rf(16), + color: '#666', + marginBottom: 20, + textAlign: 'center', + }, + button: { + backgroundColor: '#007BFF', + padding: 10, + borderRadius: 5, + }, + buttonText: { + fontSize: rf(16), + color: '#fff', + alignSelf: 'center', + }, +}); + +export default EmailVerifiedModal; diff --git a/frontend/src/helper/Metric.ts b/frontend/src/helper/Metric.ts index 2f924b48..2c5108c8 100644 --- a/frontend/src/helper/Metric.ts +++ b/frontend/src/helper/Metric.ts @@ -1,4 +1,4 @@ -import {Dimensions} from 'react-native'; +import {Dimensions, PixelRatio} from 'react-native'; export const {width, height} = Dimensions.get('window'); export const baseHeight = height * 0.1; @@ -17,3 +17,8 @@ export const fp = (percent: number): number => { // Using the width for font size calculation as it usually scales better with different screen sizes return (width * percent) / 100; }; + +// Responsive font size using PixelRatio.getFontScale() +export const rf = (size: number): number => { + return size * PixelRatio.getFontScale(); +}; diff --git a/frontend/src/navigations/StackNavigation.tsx b/frontend/src/navigations/StackNavigation.tsx index 804981ce..f4f66640 100644 --- a/frontend/src/navigations/StackNavigation.tsx +++ b/frontend/src/navigations/StackNavigation.tsx @@ -1,903 +1,904 @@ -import React, {useEffect} from 'react'; -import {createStackNavigator} from '@react-navigation/stack'; -import TabNavigation from './TabNavigation'; -import SplashScreen from '../screens/SplashScreen'; -import LoginScreen from '../screens/auth/LoginScreen'; -import SignUpScreenFirst from '../screens/auth/SignUpScreenFirst'; -import SignUpScreenSecond from '../screens/auth/SignUpScreenSecond'; -import OtpScreen from '../screens/auth/OtpScreen'; -import NewPasswordScreen from '../screens/auth/NewPasswordScreen'; -import CommentScreen from '../screens/CommentScreen'; -import ReportScreen from '../screens/report/ReportScreen'; -import ReportConfirmationScreen from '../screens/report/ReportConfirmationScreen'; -import NotificationScreen from '../screens/NotificationScreen'; -import EditorScreen from '../screens/article/EditorScreen'; -import PreviewScreen from '../screens/article/PreviewScreen'; -import ArticleScreen from '../screens/article/ArticleScreen'; -import { - TouchableOpacity, - StyleSheet, - Alert, - BackHandler, - Pressable, -} from 'react-native'; -import FontAwesome6 from '@expo/vector-icons/FontAwesome6'; -import Ionicon from '@expo/vector-icons/Ionicons'; -import ArticleDescriptionScreen from '../screens/article/ArticleDescriptionScreen'; -import ProfileEditScreen from '../screens/ProfileEditScreen'; -import UserProfileScreen from '../screens/UserProfileScreen'; -import {RootStackParamList, TabParamList} from '../type'; -import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; -import LogoutScreen from '../screens/auth/LogoutScreen'; -import {useNavigation, NavigationProp} from '@react-navigation/native'; -import OverviewScreen from '../screens/overview/OverviewScreen'; -import ReviewScreen from '../screens/overview/ReviewScreen'; -import ImprovementReviewScreen from '../screens/overview/ImprovementReviewScreen'; -import SocialScreen from '../screens/SocialScreen'; -import {useQueryClient} from '@tanstack/react-query'; -import RenderSuggestion from '../screens/article/RenderSuggestion'; -import PodcastDetail from '../screens/PodcastDetail'; -import OfflinePodcastList from '../screens/OfflinePodcastList'; -import OfflinePodcastDetail from '../screens/OfflinePodcastDetails'; -import PodcastDiscussion from '../screens/PodcastDiscussion'; -import PodcastSearch from '../screens/PodcastSearch'; -import PodcastRecorder from '../screens/PodcastRecorder'; -import PodcastForm from '../screens/PodcastForm'; -import PodcastPlayer from '../screens/PodcastPlayer'; -import PodcastProfile from '../screens/PodcastProfile'; -import PrivacyPolicyScreen from '../screens/PrivacyPolicy'; -import CommunityGuidelinesScreen from '../screens/CommunityGuidelinesScreen'; -import ContributorPage from '../screens/ContributorPage'; -import OpenSourcePage from '../screens/OpenSourcePage'; -import NotificationPreferencesScreen from '../screens/NotificationPreferencesScreen'; -import GuestPlaceholderScreen from '../components/GuestPlaceholderScreen'; - -const Stack = createStackNavigator(); - - -const ROOT_SCREENS: string[] = [ - 'TabNavigation', - 'LoginScreen', - 'LogoutScreen', - 'GuestPlaceholderScreen', -]; - -const StackNavigation = () => { - const navigation = useNavigation>(); - const nav = useNavigation>(); - const queryClient = useQueryClient(); - useEffect(() => { - const backAction = () => { - const currentRoute = - navigation?.getState()?.routes[navigation?.getState()?.index || 0] - ?.name; - const currTab = nav?.getState()?.routes[nav?.getState()?.index || 0]?.name; - - // Show exit dialog when the user is on a root-level screen (no meaningful - // back destination), or when a tab-root is active. ROOT_SCREENS includes - // LogoutScreen and GuestPlaceholderScreen so pressing Back mid-logout or - // from the guest placeholder doesn't silently fall through. - const isRootScreen = currentRoute ? ROOT_SCREENS.includes(currentRoute) : false; - const isRootTab = - currTab === 'Home' || - currTab === 'Podcasts' || - currTab === 'Profile'; - - if (isRootScreen || isRootTab) { - Alert.alert('Warning', 'Do you want to exit?', [ - {text: 'No', onPress: () => null}, - {text: 'Yes', onPress: () => BackHandler.exitApp()}, - ]); - return true; // Prevent default back behaviour - } else if (navigation.canGoBack()) { - navigation.goBack(); // Allow back navigation for non-root screens - return true; - } else { - // Fallback: no back history and not a known root — treat as exit. - Alert.alert('Warning', 'Do you want to exit?', [ - {text: 'No', onPress: () => null}, - {text: 'Yes', onPress: () => BackHandler.exitApp()}, - ]); - return true; - } - }; - - const backHandler = BackHandler.addEventListener( - 'hardwareBackPress', - backAction, - ); - - return () => backHandler.remove(); - }, [navigation, nav]); - return ( - - - - - - - - - - - - ({ - headerShown: true, - headerTitle: 'Write your post', - headerBackTitleVisible: false, - headerTintColor: 'white', - headerTransparent: false, - headerStyle: { - backgroundColor: '#000A60', - }, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: 'Start Writing', - headerBackTitleVisible: false, - - headerTintColor: 'white', - headerTransparent: false, - headerStyle: { - backgroundColor: '#000A60', - }, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - {/* */} - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: 'Start Podcasting', - headerBackTitleVisible: false, - headerTitleStyle: {color: '#ffffff'}, - headerStyle: { - backgroundColor: '#000A60', - }, - headerLeft: () => ( - { - navigation.goBack(); - }}> - - {/* */} - - ), - })} - /> - - ( - { - navigation.goBack(); - }}> - - {/* */} - - ), - }} - /> - - ({ - headerShown: true, - headerTitle: 'Preview your post', - headerBackTitleVisible: false, - headerTintColor: 'white', - headerTransparent: false, - headerStyle: { - backgroundColor: '#000A60', - }, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: 'Downloaded Podcasts', - headerTintColor: '#ffffff', - headerBackTitleVisible: false, - headerTransparent: true, - headerStyle: { - backgroundColor: '#000A60', - }, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: '', - headerBackTitleVisible: false, - headerTransparent: true, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: 'Suggestions', - headerBackTitleVisible: false, - headerTintColor: 'white', - headerTransparent: false, - headerStyle: { - backgroundColor: '#000A60', - }, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: '', - headerTransparent: true, - headerBackTitleVisible: false, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: false, - headerTitle: '', - headerTransparent: true, - headerBackTitleVisible: false, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: '', - headerTransparent: true, - headerBackTitleVisible: false, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - - - - - - - ({ - headerShown: true, - headerTitle: '', - headerTransparent: true, - headerBackTitleVisible: false, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: 'Leave a feedback', - headerTintColor: 'white', - headerTransparent: false, - headerStyle: { - backgroundColor: '#000A60', - }, - headerBackTitleVisible: false, - - headerLeft: () => ( - { - queryClient.invalidateQueries({queryKey: ['get-user-socials']}); - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: 'Start Discussion', - headerTintColor: 'white', - headerTransparent: false, - headerStyle: { - backgroundColor: '#000A60', - }, - headerBackTitleVisible: false, - headerLeft: () => ( - { - queryClient.invalidateQueries({queryKey: ['get-user-socials']}); - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: 'My Podcasts', - headerTintColor: 'white', - headerTransparent: false, - headerStyle: { - backgroundColor: PRIMARY_COLOR, - }, - headerBackTitleVisible: false, - headerLeft: () => ( - { - queryClient.invalidateQueries({queryKey: ['get-my-playlists']}); - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: 'Notifications', - //headerTransparent: true, - headerTitleAlign: 'center', - headerBackTitleVisible: false, - headerTintColor: 'white', - headerTransparent: false, - - headerStyle: { - elevation: 4, - - backgroundColor: '#000A60', - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.25, - shadowRadius: 3.5, - }, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: 'Terms and Conditions', - //headerTransparent: true, - headerTitleAlign: 'center', - headerBackTitleVisible: false, - headerTintColor: 'white', - headerTransparent: false, - - headerStyle: { - elevation: 4, - - backgroundColor: '#000A60', - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.25, - shadowRadius: 3.5, - }, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - ({ - headerShown: true, - headerTitle: 'Community Guidelines', - headerTitleAlign: 'center', - headerBackTitleVisible: false, - headerTintColor: 'white', - headerTransparent: false, - - headerStyle: { - elevation: 4, - backgroundColor: '#000A60', - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.25, - shadowRadius: 3.5, - }, - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - ({ - headerShown: true, - headerTitle: 'Report', - //headerTransparent: true, - headerTitleAlign: 'center', - headerBackTitleVisible: false, - headerStyle: { - elevation: 4, // Elevation for Android - // backgroundColor:'red', - shadowColor: '#000', // Shadow color for iOS - shadowOffset: {width: 0, height: 2}, // Shadow offset for iOS - shadowOpacity: 0.25, // Shadow opacity for iOS - shadowRadius: 3.5, // Shadow radius for iOS - }, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - headerTitle: 'Confirmation', - //headerTransparent: true, - headerTitleAlign: 'center', - headerBackTitleVisible: false, - headerStyle: { - elevation: 4, // Elevation for Android - // backgroundColor:'red', - shadowColor: '#000', // Shadow color for iOS - shadowOffset: {width: 0, height: 2}, // Shadow offset for iOS - shadowOpacity: 0.25, // Shadow opacity for iOS - shadowRadius: 3.5, // Shadow radius for iOS - }, - - headerLeft: () => ( - { - // navigation.goBack(); - }}> - - - ), - })} - /> - - - ({ - headerShown: false, - headerTitle: 'Overview', - //headerTransparent: true, - headerTitleAlign: 'center', - headerBackTitleVisible: false, - headerStyle: { - elevation: 4, // Elevation for Android - // backgroundColor:'red', - shadowColor: '#000', // Shadow color for iOS - shadowOffset: {width: 0, height: 2}, // Shadow offset for iOS - shadowOpacity: 0.25, // Shadow opacity for iOS - shadowRadius: 3.5, // Shadow radius for iOS - }, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - {/* */} - - ({ - headerShown: true, - // headerTitle: 'Followers', - //headerTransparent: true, - headerTitleAlign: 'center', - headerBackTitleVisible: false, - headerStyle: { - elevation: 4, // Elevation for Android - // backgroundColor:'red', - shadowColor: '#000', // Shadow color for iOS - shadowOffset: {width: 0, height: 2}, // Shadow offset for iOS - shadowOpacity: 0.25, // Shadow opacity for iOS - shadowRadius: 3.5, // Shadow radius for iOS - }, - - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - title: 'Edit Profile', - headerBackTitleVisible: false, - // headerTitleAlign:"center", - headerBackgroundContainerStyle: { - backgroundColor: ON_PRIMARY_COLOR - }, - headerShadowVisible: false, - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - ({ - headerShown: false, - headerTitle: 'Notification Preferences', - headerTitleAlign: 'center', - headerBackTitleVisible: false, - headerTintColor: 'white', - headerStyle: { - backgroundColor: '#000A60', - elevation: 4, - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.25, - shadowRadius: 3.5, - }, - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - ({ - headerShown: true, - title: route?.params?.title || 'Sign In Required', - headerBackTitleVisible: false, - })} - /> - {/* ({ - headerTitleAlign: 'center', - title: 'Chatbot', - headerShown: true, - headerBackTitleVisible: false, - headerShadowVisible: false, - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> */} - - ); -}; -const styles = StyleSheet.create({ - headerLeftButton: { - marginLeft: 15, - backgroundColor: 'rgba(0,0,0,0.4)', - paddingHorizontal: 8, - paddingVertical: 6, - borderRadius: 50, - }, - headerLeftButtonEditorScreen: { - marginLeft: 15, - paddingHorizontal: 15, - paddingVertical: 6, - }, - - headerLeftButtonCommentScreen: { - marginStart: 15, - //backgroundColor: '#ffffff', - paddingHorizontal: 10, - paddingVertical: 4, - marginTop: 0, - }, - profileScreenHeaderLeftButton: { - marginLeft: 15, - paddingHorizontal: 8, - paddingVertical: 6, - borderRadius: 50, - }, - dropdown: { - height: 40, - // borderColor: '#0CAFFF', - // borderWidth: 1, - borderRadius: 100, - paddingHorizontal: 17, - marginBottom: 20, - paddingRight: 12, - width: 150, - backgroundColor: 'rgb(229, 233, 241)', - }, - placeholderStyle: { - fontSize: 15, - color: 'black', - }, -}); -export default StackNavigation; +import React, {useEffect} from 'react'; +import {createStackNavigator} from '@react-navigation/stack'; +import TabNavigation from './TabNavigation'; +import SplashScreen from '../screens/SplashScreen'; +import LoginScreen from '../screens/auth/LoginScreen'; +import SignUpScreenFirst from '../screens/auth/SignUpScreenFirst'; +import SignUpScreenSecond from '../screens/auth/SignUpScreenSecond'; +import OtpScreen from '../screens/auth/OtpScreen'; +import NewPasswordScreen from '../screens/auth/NewPasswordScreen'; +import CommentScreen from '../screens/CommentScreen'; +import ReportScreen from '../screens/report/ReportScreen'; +import ReportConfirmationScreen from '../screens/report/ReportConfirmationScreen'; +import NotificationScreen from '../screens/NotificationScreen'; +import EditorScreen from '../screens/article/EditorScreen'; +import PreviewScreen from '../screens/article/PreviewScreen'; +import ArticleScreen from '../screens/article/ArticleScreen'; +import { + TouchableOpacity, + StyleSheet, + Alert, + BackHandler, + Pressable, +} from 'react-native'; +import FontAwesome6 from '@expo/vector-icons/FontAwesome6'; +import Ionicon from '@expo/vector-icons/Ionicons'; +import ArticleDescriptionScreen from '../screens/article/ArticleDescriptionScreen'; +import ProfileEditScreen from '../screens/ProfileEditScreen'; +import UserProfileScreen from '../screens/UserProfileScreen'; +import {RootStackParamList, TabParamList} from '../type'; +import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; +import LogoutScreen from '../screens/auth/LogoutScreen'; +import {useNavigation, NavigationProp} from '@react-navigation/native'; +import OverviewScreen from '../screens/overview/OverviewScreen'; +import ReviewScreen from '../screens/overview/ReviewScreen'; +import ImprovementReviewScreen from '../screens/overview/ImprovementReviewScreen'; +import SocialScreen from '../screens/SocialScreen'; +import {useQueryClient} from '@tanstack/react-query'; +import RenderSuggestion from '../screens/article/RenderSuggestion'; +import PodcastDetail from '../screens/PodcastDetail'; +import OfflinePodcastList from '../screens/OfflinePodcastList'; +import OfflinePodcastDetail from '../screens/OfflinePodcastDetails'; +import PodcastDiscussion from '../screens/PodcastDiscussion'; +import PodcastSearch from '../screens/PodcastSearch'; +import PodcastRecorder from '../screens/PodcastRecorder'; +import PodcastForm from '../screens/PodcastForm'; +import PodcastPlayer from '../screens/PodcastPlayer'; +import PodcastProfile from '../screens/PodcastProfile'; +import PrivacyPolicyScreen from '../screens/PrivacyPolicy'; +import CommunityGuidelinesScreen from '../screens/CommunityGuidelinesScreen'; +import ContributorPage from '../screens/ContributorPage'; +import OpenSourcePage from '../screens/OpenSourcePage'; +import NotificationPreferencesScreen from '../screens/NotificationPreferencesScreen'; +import GuestPlaceholderScreen from '../components/GuestPlaceholderScreen'; import { rf } from '../helper/Metric'; + + +const Stack = createStackNavigator(); + + +const ROOT_SCREENS: string[] = [ + 'TabNavigation', + 'LoginScreen', + 'LogoutScreen', + 'GuestPlaceholderScreen', +]; + +const StackNavigation = () => { + const navigation = useNavigation>(); + const nav = useNavigation>(); + const queryClient = useQueryClient(); + useEffect(() => { + const backAction = () => { + const currentRoute = + navigation?.getState()?.routes[navigation?.getState()?.index || 0] + ?.name; + const currTab = nav?.getState()?.routes[nav?.getState()?.index || 0]?.name; + + // Show exit dialog when the user is on a root-level screen (no meaningful + // back destination), or when a tab-root is active. ROOT_SCREENS includes + // LogoutScreen and GuestPlaceholderScreen so pressing Back mid-logout or + // from the guest placeholder doesn't silently fall through. + const isRootScreen = currentRoute ? ROOT_SCREENS.includes(currentRoute) : false; + const isRootTab = + currTab === 'Home' || + currTab === 'Podcasts' || + currTab === 'Profile'; + + if (isRootScreen || isRootTab) { + Alert.alert('Warning', 'Do you want to exit?', [ + {text: 'No', onPress: () => null}, + {text: 'Yes', onPress: () => BackHandler.exitApp()}, + ]); + return true; // Prevent default back behaviour + } else if (navigation.canGoBack()) { + navigation.goBack(); // Allow back navigation for non-root screens + return true; + } else { + // Fallback: no back history and not a known root — treat as exit. + Alert.alert('Warning', 'Do you want to exit?', [ + {text: 'No', onPress: () => null}, + {text: 'Yes', onPress: () => BackHandler.exitApp()}, + ]); + return true; + } + }; + + const backHandler = BackHandler.addEventListener( + 'hardwareBackPress', + backAction, + ); + + return () => backHandler.remove(); + }, [navigation, nav]); + return ( + + + + + + + + + + + + ({ + headerShown: true, + headerTitle: 'Write your post', + headerBackTitleVisible: false, + headerTintColor: 'white', + headerTransparent: false, + headerStyle: { + backgroundColor: '#000A60', + }, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: 'Start Writing', + headerBackTitleVisible: false, + + headerTintColor: 'white', + headerTransparent: false, + headerStyle: { + backgroundColor: '#000A60', + }, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + {/* */} + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: 'Start Podcasting', + headerBackTitleVisible: false, + headerTitleStyle: {color: '#ffffff'}, + headerStyle: { + backgroundColor: '#000A60', + }, + headerLeft: () => ( + { + navigation.goBack(); + }}> + + {/* */} + + ), + })} + /> + + ( + { + navigation.goBack(); + }}> + + {/* */} + + ), + }} + /> + + ({ + headerShown: true, + headerTitle: 'Preview your post', + headerBackTitleVisible: false, + headerTintColor: 'white', + headerTransparent: false, + headerStyle: { + backgroundColor: '#000A60', + }, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: 'Downloaded Podcasts', + headerTintColor: '#ffffff', + headerBackTitleVisible: false, + headerTransparent: true, + headerStyle: { + backgroundColor: '#000A60', + }, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: '', + headerBackTitleVisible: false, + headerTransparent: true, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: 'Suggestions', + headerBackTitleVisible: false, + headerTintColor: 'white', + headerTransparent: false, + headerStyle: { + backgroundColor: '#000A60', + }, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: '', + headerTransparent: true, + headerBackTitleVisible: false, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: false, + headerTitle: '', + headerTransparent: true, + headerBackTitleVisible: false, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: '', + headerTransparent: true, + headerBackTitleVisible: false, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + + + + + + + ({ + headerShown: true, + headerTitle: '', + headerTransparent: true, + headerBackTitleVisible: false, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: 'Leave a feedback', + headerTintColor: 'white', + headerTransparent: false, + headerStyle: { + backgroundColor: '#000A60', + }, + headerBackTitleVisible: false, + + headerLeft: () => ( + { + queryClient.invalidateQueries({queryKey: ['get-user-socials']}); + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: 'Start Discussion', + headerTintColor: 'white', + headerTransparent: false, + headerStyle: { + backgroundColor: '#000A60', + }, + headerBackTitleVisible: false, + headerLeft: () => ( + { + queryClient.invalidateQueries({queryKey: ['get-user-socials']}); + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: 'My Podcasts', + headerTintColor: 'white', + headerTransparent: false, + headerStyle: { + backgroundColor: PRIMARY_COLOR, + }, + headerBackTitleVisible: false, + headerLeft: () => ( + { + queryClient.invalidateQueries({queryKey: ['get-my-playlists']}); + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: 'Notifications', + //headerTransparent: true, + headerTitleAlign: 'center', + headerBackTitleVisible: false, + headerTintColor: 'white', + headerTransparent: false, + + headerStyle: { + elevation: 4, + + backgroundColor: '#000A60', + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.25, + shadowRadius: 3.5, + }, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: 'Terms and Conditions', + //headerTransparent: true, + headerTitleAlign: 'center', + headerBackTitleVisible: false, + headerTintColor: 'white', + headerTransparent: false, + + headerStyle: { + elevation: 4, + + backgroundColor: '#000A60', + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.25, + shadowRadius: 3.5, + }, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + ({ + headerShown: true, + headerTitle: 'Community Guidelines', + headerTitleAlign: 'center', + headerBackTitleVisible: false, + headerTintColor: 'white', + headerTransparent: false, + + headerStyle: { + elevation: 4, + backgroundColor: '#000A60', + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.25, + shadowRadius: 3.5, + }, + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + ({ + headerShown: true, + headerTitle: 'Report', + //headerTransparent: true, + headerTitleAlign: 'center', + headerBackTitleVisible: false, + headerStyle: { + elevation: 4, // Elevation for Android + // backgroundColor:'red', + shadowColor: '#000', // Shadow color for iOS + shadowOffset: {width: 0, height: 2}, // Shadow offset for iOS + shadowOpacity: 0.25, // Shadow opacity for iOS + shadowRadius: 3.5, // Shadow radius for iOS + }, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + headerTitle: 'Confirmation', + //headerTransparent: true, + headerTitleAlign: 'center', + headerBackTitleVisible: false, + headerStyle: { + elevation: 4, // Elevation for Android + // backgroundColor:'red', + shadowColor: '#000', // Shadow color for iOS + shadowOffset: {width: 0, height: 2}, // Shadow offset for iOS + shadowOpacity: 0.25, // Shadow opacity for iOS + shadowRadius: 3.5, // Shadow radius for iOS + }, + + headerLeft: () => ( + { + // navigation.goBack(); + }}> + + + ), + })} + /> + + + ({ + headerShown: false, + headerTitle: 'Overview', + //headerTransparent: true, + headerTitleAlign: 'center', + headerBackTitleVisible: false, + headerStyle: { + elevation: 4, // Elevation for Android + // backgroundColor:'red', + shadowColor: '#000', // Shadow color for iOS + shadowOffset: {width: 0, height: 2}, // Shadow offset for iOS + shadowOpacity: 0.25, // Shadow opacity for iOS + shadowRadius: 3.5, // Shadow radius for iOS + }, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + {/* */} + + ({ + headerShown: true, + // headerTitle: 'Followers', + //headerTransparent: true, + headerTitleAlign: 'center', + headerBackTitleVisible: false, + headerStyle: { + elevation: 4, // Elevation for Android + // backgroundColor:'red', + shadowColor: '#000', // Shadow color for iOS + shadowOffset: {width: 0, height: 2}, // Shadow offset for iOS + shadowOpacity: 0.25, // Shadow opacity for iOS + shadowRadius: 3.5, // Shadow radius for iOS + }, + + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + title: 'Edit Profile', + headerBackTitleVisible: false, + // headerTitleAlign:"center", + headerBackgroundContainerStyle: { + backgroundColor: ON_PRIMARY_COLOR + }, + headerShadowVisible: false, + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + ({ + headerShown: false, + headerTitle: 'Notification Preferences', + headerTitleAlign: 'center', + headerBackTitleVisible: false, + headerTintColor: 'white', + headerStyle: { + backgroundColor: '#000A60', + elevation: 4, + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.25, + shadowRadius: 3.5, + }, + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + ({ + headerShown: true, + title: route?.params?.title || 'Sign In Required', + headerBackTitleVisible: false, + })} + /> + {/* ({ + headerTitleAlign: 'center', + title: 'Chatbot', + headerShown: true, + headerBackTitleVisible: false, + headerShadowVisible: false, + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> */} + + ); +}; +const styles = StyleSheet.create({ + headerLeftButton: { + marginLeft: 15, + backgroundColor: 'rgba(0,0,0,0.4)', + paddingHorizontal: 8, + paddingVertical: 6, + borderRadius: 50, + }, + headerLeftButtonEditorScreen: { + marginLeft: 15, + paddingHorizontal: 15, + paddingVertical: 6, + }, + + headerLeftButtonCommentScreen: { + marginStart: 15, + //backgroundColor: '#ffffff', + paddingHorizontal: 10, + paddingVertical: 4, + marginTop: 0, + }, + profileScreenHeaderLeftButton: { + marginLeft: 15, + paddingHorizontal: 8, + paddingVertical: 6, + borderRadius: 50, + }, + dropdown: { + height: 40, + // borderColor: '#0CAFFF', + // borderWidth: 1, + borderRadius: 100, + paddingHorizontal: 17, + marginBottom: 20, + paddingRight: 12, + width: 150, + backgroundColor: 'rgb(229, 233, 241)', + }, + placeholderStyle: { + fontSize: rf(15), + color: 'black', + }, +}); +export default StackNavigation; diff --git a/frontend/src/navigations/TabNavigation.tsx b/frontend/src/navigations/TabNavigation.tsx index 76fe0c66..7110d58b 100644 --- a/frontend/src/navigations/TabNavigation.tsx +++ b/frontend/src/navigations/TabNavigation.tsx @@ -1,137 +1,138 @@ -import React from 'react'; -import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; -import HomeScreen from '../screens/HomeScreen'; -import PodcastsScreen from '../screens/PodcastsScreen'; -import ProfileScreen from '../screens/ProfileScreen'; -// import {KeyboardAvoidingView, StyleSheet} from 'react-native'; -// import {Colors} from 'react-native/Libraries/NewAppScreen'; -import TabBar from './TabBar'; -import {TouchableOpacity} from 'react-native'; -import {TabParamList} from '../type'; -import FontAwesome6 from '@expo/vector-icons/FontAwesome6'; -import {BUTTON_COLOR} from '../helper/Theme'; -import HeaderRightMenu from '../components/HeaderRightMenu'; -import ChatbotScreen from '../screens/ChatbotScreen'; -import AboutScreen from '../screens/AboutPage'; -import {useSelector} from 'react-redux'; -import GuestPlaceholderScreen from '../components/GuestPlaceholderScreen'; +import React from 'react'; +import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; +import HomeScreen from '../screens/HomeScreen'; +import PodcastsScreen from '../screens/PodcastsScreen'; +import ProfileScreen from '../screens/ProfileScreen'; +// import {KeyboardAvoidingView, StyleSheet} from 'react-native'; +// import {Colors} from 'react-native/Libraries/NewAppScreen'; +import TabBar from './TabBar'; +import {TouchableOpacity} from 'react-native'; +import {TabParamList} from '../type'; +import FontAwesome6 from '@expo/vector-icons/FontAwesome6'; +import {BUTTON_COLOR} from '../helper/Theme'; +import HeaderRightMenu from '../components/HeaderRightMenu'; +import ChatbotScreen from '../screens/ChatbotScreen'; +import AboutScreen from '../screens/AboutPage'; +import {useSelector} from 'react-redux'; +import GuestPlaceholderScreen from '../components/GuestPlaceholderScreen'; import { rf } from '../helper/Metric'; -const Tab = createBottomTabNavigator(); - -const ChatbotGuestScreen = () => ; -const ProfileGuestScreen = () => ; - -const TabNavigation = () => { - const isGuest = useSelector((state: any) => state.user.isGuest); - return ( - }> - - ({ - headerShown: true, - headerTitle: '🎧 Podcasts', - headerTransparent: true, - headerStyle: { - backgroundColor: '#000A60', - }, - headerTitleStyle: { - fontSize: 23, - marginBottom: 12, - color: 'white', - }, - headerRight: () => ( - { - (navigation as any).navigate('PodcastSearch'); - }} - /> - ), - })} - /> - - ({ - headerTitleAlign: 'center', - title: 'Chatbot', - headerShown: false, - headerBackTitleVisible: false, - headerStyle: { - backgroundColor: BUTTON_COLOR, - }, - headerTintColor: 'white', - headerShadowVisible: false, - tabBarHideOnKeyboard: true, - headerLeft: () => ( - { - navigation.goBack(); - }}> - - - ), - })} - /> - - - - - ); -}; -export default TabNavigation; + +const Tab = createBottomTabNavigator(); + +const ChatbotGuestScreen = () => ; +const ProfileGuestScreen = () => ; + +const TabNavigation = () => { + const isGuest = useSelector((state: any) => state.user.isGuest); + return ( + }> + + ({ + headerShown: true, + headerTitle: '🎧 Podcasts', + headerTransparent: true, + headerStyle: { + backgroundColor: '#000A60', + }, + headerTitleStyle: { + fontSize: rf(23), + marginBottom: 12, + color: 'white', + }, + headerRight: () => ( + { + (navigation as any).navigate('PodcastSearch'); + }} + /> + ), + })} + /> + + ({ + headerTitleAlign: 'center', + title: 'Chatbot', + headerShown: false, + headerBackTitleVisible: false, + headerStyle: { + backgroundColor: BUTTON_COLOR, + }, + headerTintColor: 'white', + headerShadowVisible: false, + tabBarHideOnKeyboard: true, + headerLeft: () => ( + { + navigation.goBack(); + }}> + + + ), + })} + /> + + + + + ); +}; +export default TabNavigation; diff --git a/frontend/src/screens/AboutPage.tsx b/frontend/src/screens/AboutPage.tsx index a074e62a..7bd84021 100644 --- a/frontend/src/screens/AboutPage.tsx +++ b/frontend/src/screens/AboutPage.tsx @@ -1,394 +1,395 @@ -import React from 'react'; -import { Share, Linking, Image, useColorScheme } from 'react-native'; -import VersionCheck from 'react-native-version-check'; -import { - YStack, - XStack, - Text, - ScrollView, - Button, - View, -} from 'tamagui'; +import React from 'react'; +import { Share, Linking, Image, useColorScheme } from 'react-native'; +import VersionCheck from 'react-native-version-check'; +import { + YStack, + XStack, + Text, + ScrollView, + Button, + View, +} from 'tamagui'; + +import { + Ionicons, + FontAwesome6, +} from '@expo/vector-icons'; +import { StatusBar } from 'expo-status-bar'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { AboutScreenProps } from '../type'; +import { GlassContainer } from '../components/GlassContainer'; +import { ProfessionalColors, Typography, Spacing } from '../styles/GlassStyles'; +import { useBackToTop } from '../components/BackToTopScrollView'; +import { BackToTopButton } from '../components/BackToTopButton'; import { rf } from '../helper/Metric'; -import { - Ionicons, - FontAwesome6, -} from '@expo/vector-icons'; -import { StatusBar } from 'expo-status-bar'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { AboutScreenProps } from '../type'; -import { GlassContainer } from '../components/GlassContainer'; -import { ProfessionalColors, Typography, Spacing } from '../styles/GlassStyles'; -import { useBackToTop } from '../components/BackToTopScrollView'; -import { BackToTopButton } from '../components/BackToTopButton'; - -const AboutScreen = ({ navigation }: AboutScreenProps) => { - const colorScheme = useColorScheme(); - const isDarkMode = colorScheme === 'dark'; - const { onScroll, visible, opacity, scrollToTop } = useBackToTop({ threshold: 300 }); - const currentVersion = VersionCheck.getCurrentVersion(); - - const onShare = async () => { - try { - await Share.share({ - message: - 'Get fit with Ultimate-Health 🌿\nYour ultimate wellness companion for a healthier lifestyle.\n\nDownload now:\nhttps://play.google.com/store/apps/details?id=com.anonymous.UltimateHealth', - }); - } catch (error: any) { - console.log(error.message); - } - }; - - const openLink = (url: string) => { - Linking.openURL(url).catch(err => console.error('URL error', err)); - }; - - - - return ( - - - - - - {/* Header Section */} - - - - - - - - Ultimate-Health - - - - VERSION {currentVersion} - - - - - - - UltimateHealth is an innovative open-source platform that brings - trusted health resources and reliable articles together in one - place. It serves as a single repository for verified health - insights and dependable information. - - - - - {/* Legal Section */} - - - LEGAL - - - - - navigation.navigate('Privacy')} - isDarkMode={isDarkMode} - /> - - - openLink( - 'https://www.freeprivacypolicy.com/live/0b40215e-e456-48cc-a549-424216da1e01', - ) - } - isDarkMode={isDarkMode} - /> - - navigation.navigate('CommunityGuidelines')} - isDarkMode={isDarkMode} - /> - - - - - {/* Community Section */} - - - COMMUNITY - - - - - - - navigation.navigate('ContributorPage')} - isDarkMode={isDarkMode} - /> - - navigation.navigate('OpenSourcePage')} - isDarkMode={isDarkMode} - /> - - - openLink( - 'https://play.google.com/store/apps/details?id=com.anonymous.UltimateHealth', - ) - } - isDarkMode={isDarkMode} - /> - - - - - {/* Social Links */} - - - CONNECT WITH US - - - - - openLink('https://github.com/SB2318/UltimateHealth') - } - isDarkMode={isDarkMode} - /> - - - openLink('https://linkedin.com/in/ultimate-health-9290873a8/') - } - isDarkMode={isDarkMode} - /> - - openLink('mailto:ultimate.health25@gmail.com')} - isDarkMode={isDarkMode} - /> - - - - {/* Footer */} - - - Built for excellence - - - ultimate.health25@gmail.com - - - - - - - ); -}; - -// Menu Button Component -const MenuButton = ({ - icon, - title, - iconColor, - onPress, - isDarkMode, -}: { - icon: string; - title: string; - iconColor: string; - onPress: () => void; - isDarkMode: boolean; -}) => ( - -); - -// Social Circle Component -const SocialCircle = ({ - icon, - onPress, - isDarkMode, -}: { - icon: string; - onPress: () => void; - isDarkMode: boolean; -}) => ( - -); - -export default AboutScreen; + +const AboutScreen = ({ navigation }: AboutScreenProps) => { + const colorScheme = useColorScheme(); + const isDarkMode = colorScheme === 'dark'; + const { onScroll, visible, opacity, scrollToTop } = useBackToTop({ threshold: 300 }); + const currentVersion = VersionCheck.getCurrentVersion(); + + const onShare = async () => { + try { + await Share.share({ + message: + 'Get fit with Ultimate-Health 🌿\nYour ultimate wellness companion for a healthier lifestyle.\n\nDownload now:\nhttps://play.google.com/store/apps/details?id=com.anonymous.UltimateHealth', + }); + } catch (error: any) { + console.log(error.message); + } + }; + + const openLink = (url: string) => { + Linking.openURL(url).catch(err => console.error('URL error', err)); + }; + + + + return ( + + + + + + {/* Header Section */} + + + + + + + + Ultimate-Health + + + + VERSION {currentVersion} + + + + + + + UltimateHealth is an innovative open-source platform that brings + trusted health resources and reliable articles together in one + place. It serves as a single repository for verified health + insights and dependable information. + + + + + {/* Legal Section */} + + + LEGAL + + + + + navigation.navigate('Privacy')} + isDarkMode={isDarkMode} + /> + + + openLink( + 'https://www.freeprivacypolicy.com/live/0b40215e-e456-48cc-a549-424216da1e01', + ) + } + isDarkMode={isDarkMode} + /> + + navigation.navigate('CommunityGuidelines')} + isDarkMode={isDarkMode} + /> + + + + + {/* Community Section */} + + + COMMUNITY + + + + + + + navigation.navigate('ContributorPage')} + isDarkMode={isDarkMode} + /> + + navigation.navigate('OpenSourcePage')} + isDarkMode={isDarkMode} + /> + + + openLink( + 'https://play.google.com/store/apps/details?id=com.anonymous.UltimateHealth', + ) + } + isDarkMode={isDarkMode} + /> + + + + + {/* Social Links */} + + + CONNECT WITH US + + + + + openLink('https://github.com/SB2318/UltimateHealth') + } + isDarkMode={isDarkMode} + /> + + + openLink('https://linkedin.com/in/ultimate-health-9290873a8/') + } + isDarkMode={isDarkMode} + /> + + openLink('mailto:ultimate.health25@gmail.com')} + isDarkMode={isDarkMode} + /> + + + + {/* Footer */} + + + Built for excellence + + + ultimate.health25@gmail.com + + + + + + + ); +}; + +// Menu Button Component +const MenuButton = ({ + icon, + title, + iconColor, + onPress, + isDarkMode, +}: { + icon: string; + title: string; + iconColor: string; + onPress: () => void; + isDarkMode: boolean; +}) => ( + +); + +// Social Circle Component +const SocialCircle = ({ + icon, + onPress, + isDarkMode, +}: { + icon: string; + onPress: () => void; + isDarkMode: boolean; +}) => ( + +); + +export default AboutScreen; diff --git a/frontend/src/screens/ChatbotScreen.tsx b/frontend/src/screens/ChatbotScreen.tsx index e66d7a4f..596445cd 100644 --- a/frontend/src/screens/ChatbotScreen.tsx +++ b/frontend/src/screens/ChatbotScreen.tsx @@ -1,339 +1,340 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import { - Bubble, - GiftedChat, - IMessage, - InputToolbar, - Send, -} from 'react-native-gifted-chat'; -import {PRIMARY_COLOR} from '../helper/Theme'; -import {useDispatch, useSelector} from 'react-redux'; -import { - Alert, - View, - KeyboardAvoidingView, - Platform, - Text, - TouchableOpacity, -} from 'react-native'; -import Ionicons from '@expo/vector-icons/Ionicons'; -import {GET_STORAGE_DATA} from '../helper/APIUtils'; -import {AxiosError} from 'axios'; -import {ChatBotScreenProps, Message} from '../type'; -import {hp} from '../helper/Metric'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import {MaterialCommunityIcons} from '@expo/vector-icons'; -import {useGetProfile} from '../hooks/useGetProfile'; -import {useSendMessageToGemini} from '../hooks/useSendMessageToGemini'; -import {useLoadAIConversations} from '../hooks/useLoadAIChats'; -import Snackbar from 'react-native-snackbar'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import { + Bubble, + GiftedChat, + IMessage, + InputToolbar, + Send, +} from 'react-native-gifted-chat'; +import {PRIMARY_COLOR} from '../helper/Theme'; +import {useDispatch, useSelector} from 'react-redux'; +import { + Alert, + View, + KeyboardAvoidingView, + Platform, + Text, + TouchableOpacity, +} from 'react-native'; +import Ionicons from '@expo/vector-icons/Ionicons'; +import {GET_STORAGE_DATA} from '../helper/APIUtils'; +import {AxiosError} from 'axios'; +import {ChatBotScreenProps, Message} from '../type'; +import {hp} from '../helper/Metric'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {MaterialCommunityIcons} from '@expo/vector-icons'; +import {useGetProfile} from '../hooks/useGetProfile'; +import {useSendMessageToGemini} from '../hooks/useSendMessageToGemini'; +import {useLoadAIConversations} from '../hooks/useLoadAIChats'; +import Snackbar from 'react-native-snackbar'; import { rf } from '../helper/Metric'; -// interface ChatbotResponse { -// id: string; -// created: number; -// model: string; -// choices: Choice[]; -// usage: Usage; -// } - -// interface Usage { -// prompt_tokens: number; -// completion_tokens: number; -// total_tokens: number; -// } - -// interface Choice { -// index: number; -// message: Message; -// finish_reason: string; -// } - -const ChatbotScreen = ({navigation}: ChatBotScreenProps) => { - const {user_id, user_token} = useSelector((state: any) => state.user); - const {isConnected} = useSelector((state: any) => state.network); - - const [messages, setMessages] = useState([]); - const [isTyping, setIsTyping] = useState(true); - const isMountedRef = useRef(true); - const dispatch = useDispatch(); - const {data: user} = useGetProfile(); - // const token = 'GPMFAQIV2BGXCWYMCVQ3IPVXSOOLI53H5NYA'; //token - - //console.log("User Token", user_token); - - const {mutate: sendMessageToAI, isPending: messageProcessPending} = - useSendMessageToGemini(); - const {data: conversations, isLoading: conversationLoading} = - useLoadAIConversations(isConnected); - - useEffect(() => { - return () => { - isMountedRef.current = false; - }; - }, []); - - const safeSetMessages = useCallback( - (updater: React.SetStateAction) => { - if (isMountedRef.current) { - setMessages(updater); - } - }, - [], - ); - - const safeSetIsTyping = useCallback((typing: boolean) => { - if (isMountedRef.current) { - setIsTyping(typing); - } - }, []); - - useEffect(() => { - if (conversations) { - const refined = convertToGiftedFormat(conversations); - safeSetMessages([ - { - _id: refined.length + 1, - text: "Hello! 👋 I'm here to assist you. How can I help you today?", - createdAt: new Date(), - user: { - _id: 2, - avatar: - 'https://static.vecteezy.com/system/resources/previews/026/309/247/non_2x/robot-chat-or-chat-bot-logo-modern-conversation-automatic-technology-logo-design-template-vector.jpg', - }, - }, - ...refined.reverse(), - ]); - - safeSetIsTyping(false); - } - }, [conversations, safeSetIsTyping, safeSetMessages]); - - const convertToGiftedFormat = (items: Message[]): IMessage[] => { - return items.map(m => ({ - _id: m._id, - text: m.text, - createdAt: new Date(m.timestamp), - user: { - _id: m.role === 'user' ? 1 : 2, - avatar: m.profileImage - ? `${GET_STORAGE_DATA}/${m.profileImage}` - : m.role === 'assistant' - ? 'https://static.vecteezy.com/system/resources/previews/026/309/247/non_2x/robot-chat-or-chat-bot-logo-modern-conversation-automatic-technology-logo-design-template-vector.jpg' - : undefined, - }, - })); - }; - - - const onSend = useCallback((messages: IMessage[] = []) => { - if (!isConnected) { - Snackbar.show({ - text: 'Please check your internet connection and try again!', - duration: Snackbar.LENGTH_SHORT, - }); - return; - } - sendMessageToAI(messages[0]?.text ?? 'AI in health within 100 words', { - onSuccess: (responseData: Message) => { - safeSetMessages(previousMessages => - GiftedChat.append(previousMessages, [ - { - _id: responseData._id, - text: responseData.text, - createdAt: new Date(), - user: { - _id: 2, - avatar: - 'https://static.vecteezy.com/system/resources/previews/026/309/247/non_2x/robot-chat-or-chat-bot-logo-modern-conversation-automatic-technology-logo-design-template-vector.jpg', - }, - }, - ]), - ); - }, - onError: (error: AxiosError) => { - if (!isMountedRef.current) { - return; - } - console.log('Error', error); - if (error.response) { - const statusCode = error.response.status; - switch (statusCode) { - case 401: - Alert.alert('Authentication Error', 'Unauthorized Access'); - - break; - case 422: - Alert.alert( - 'Bad Request', - 'Invalid request. Please check your input.', - ); - - break; - case 429: - safeSetMessages(previousMessages => - GiftedChat.append(previousMessages, [ - { - _id: previousMessages.length + 1, - text: 'You’ve reached your daily limit. You can ask up to 5 questions per day', - createdAt: new Date(), - user: { - _id: 2, - avatar: - 'https://static.vecteezy.com/system/resources/previews/026/309/247/non_2x/robot-chat-or-chat-bot-logo-modern-conversation-automatic-technology-logo-design-template-vector.jpg', - }, - }, - ]), - ); - break; - - case 500: - Alert.alert( - 'Server Error', - 'An internal server error occurred. Please try again later.', - ); - - break; - default: - Alert.alert( - 'Unknown Error', - 'An unexpected error occurred. Please try again later.', - ); - } - } else { - if (error.message === 'Network Error') { - Alert.alert( - 'Network Error', - 'Unable to connect. Please check your internet connection and try again.', - ); - } else { - Alert.alert('Error', 'Something went wrong. Please try again.'); - } - } - }, - }); - safeSetMessages(previousMessages => - GiftedChat.append(previousMessages, messages), - ); - }, [isConnected, safeSetMessages, sendMessageToAI]); - - return ( - - - navigation.goBack()}> - - - - - - - - - - - - Care Companion AI - - - {isTyping && ( - typing... - )} - - - - - - ( - - )} - renderInputToolbar={props => ( - - )} - renderSend={props => ( - - - - - - )} - /> - - - - ); -}; - -export default ChatbotScreen; + +// interface ChatbotResponse { +// id: string; +// created: number; +// model: string; +// choices: Choice[]; +// usage: Usage; +// } + +// interface Usage { +// prompt_tokens: number; +// completion_tokens: number; +// total_tokens: number; +// } + +// interface Choice { +// index: number; +// message: Message; +// finish_reason: string; +// } + +const ChatbotScreen = ({navigation}: ChatBotScreenProps) => { + const {user_id, user_token} = useSelector((state: any) => state.user); + const {isConnected} = useSelector((state: any) => state.network); + + const [messages, setMessages] = useState([]); + const [isTyping, setIsTyping] = useState(true); + const isMountedRef = useRef(true); + const dispatch = useDispatch(); + const {data: user} = useGetProfile(); + // const token = 'GPMFAQIV2BGXCWYMCVQ3IPVXSOOLI53H5NYA'; //token + + //console.log("User Token", user_token); + + const {mutate: sendMessageToAI, isPending: messageProcessPending} = + useSendMessageToGemini(); + const {data: conversations, isLoading: conversationLoading} = + useLoadAIConversations(isConnected); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + const safeSetMessages = useCallback( + (updater: React.SetStateAction) => { + if (isMountedRef.current) { + setMessages(updater); + } + }, + [], + ); + + const safeSetIsTyping = useCallback((typing: boolean) => { + if (isMountedRef.current) { + setIsTyping(typing); + } + }, []); + + useEffect(() => { + if (conversations) { + const refined = convertToGiftedFormat(conversations); + safeSetMessages([ + { + _id: refined.length + 1, + text: "Hello! 👋 I'm here to assist you. How can I help you today?", + createdAt: new Date(), + user: { + _id: 2, + avatar: + 'https://static.vecteezy.com/system/resources/previews/026/309/247/non_2x/robot-chat-or-chat-bot-logo-modern-conversation-automatic-technology-logo-design-template-vector.jpg', + }, + }, + ...refined.reverse(), + ]); + + safeSetIsTyping(false); + } + }, [conversations, safeSetIsTyping, safeSetMessages]); + + const convertToGiftedFormat = (items: Message[]): IMessage[] => { + return items.map(m => ({ + _id: m._id, + text: m.text, + createdAt: new Date(m.timestamp), + user: { + _id: m.role === 'user' ? 1 : 2, + avatar: m.profileImage + ? `${GET_STORAGE_DATA}/${m.profileImage}` + : m.role === 'assistant' + ? 'https://static.vecteezy.com/system/resources/previews/026/309/247/non_2x/robot-chat-or-chat-bot-logo-modern-conversation-automatic-technology-logo-design-template-vector.jpg' + : undefined, + }, + })); + }; + + + const onSend = useCallback((messages: IMessage[] = []) => { + if (!isConnected) { + Snackbar.show({ + text: 'Please check your internet connection and try again!', + duration: Snackbar.LENGTH_SHORT, + }); + return; + } + sendMessageToAI(messages[0]?.text ?? 'AI in health within 100 words', { + onSuccess: (responseData: Message) => { + safeSetMessages(previousMessages => + GiftedChat.append(previousMessages, [ + { + _id: responseData._id, + text: responseData.text, + createdAt: new Date(), + user: { + _id: 2, + avatar: + 'https://static.vecteezy.com/system/resources/previews/026/309/247/non_2x/robot-chat-or-chat-bot-logo-modern-conversation-automatic-technology-logo-design-template-vector.jpg', + }, + }, + ]), + ); + }, + onError: (error: AxiosError) => { + if (!isMountedRef.current) { + return; + } + console.log('Error', error); + if (error.response) { + const statusCode = error.response.status; + switch (statusCode) { + case 401: + Alert.alert('Authentication Error', 'Unauthorized Access'); + + break; + case 422: + Alert.alert( + 'Bad Request', + 'Invalid request. Please check your input.', + ); + + break; + case 429: + safeSetMessages(previousMessages => + GiftedChat.append(previousMessages, [ + { + _id: previousMessages.length + 1, + text: 'You’ve reached your daily limit. You can ask up to 5 questions per day', + createdAt: new Date(), + user: { + _id: 2, + avatar: + 'https://static.vecteezy.com/system/resources/previews/026/309/247/non_2x/robot-chat-or-chat-bot-logo-modern-conversation-automatic-technology-logo-design-template-vector.jpg', + }, + }, + ]), + ); + break; + + case 500: + Alert.alert( + 'Server Error', + 'An internal server error occurred. Please try again later.', + ); + + break; + default: + Alert.alert( + 'Unknown Error', + 'An unexpected error occurred. Please try again later.', + ); + } + } else { + if (error.message === 'Network Error') { + Alert.alert( + 'Network Error', + 'Unable to connect. Please check your internet connection and try again.', + ); + } else { + Alert.alert('Error', 'Something went wrong. Please try again.'); + } + } + }, + }); + safeSetMessages(previousMessages => + GiftedChat.append(previousMessages, messages), + ); + }, [isConnected, safeSetMessages, sendMessageToAI]); + + return ( + + + navigation.goBack()}> + + + + + + + + + + + + Care Companion AI + + + {isTyping && ( + typing... + )} + + + + + + ( + + )} + renderInputToolbar={props => ( + + )} + renderSend={props => ( + + + + + + )} + /> + + + + ); +}; + +export default ChatbotScreen; diff --git a/frontend/src/screens/CommentScreen.tsx b/frontend/src/screens/CommentScreen.tsx index 869630d9..1db423f6 100644 --- a/frontend/src/screens/CommentScreen.tsx +++ b/frontend/src/screens/CommentScreen.tsx @@ -1,782 +1,783 @@ -import React, { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { - Alert, - FlatList, - Keyboard, - KeyboardAvoidingView, - Platform, - Pressable, - StyleSheet, - TextInput, - TouchableOpacity, - View -} from 'react-native'; - -import { useDispatch, useSelector } from 'react-redux'; -import { H3, Image, Paragraph, Text, YStack, TextArea, XStack, Button } from 'tamagui'; - -import CommentItem from '../components/CommentItem'; -import Loader from '../components/Loader'; - -import { useSocket } from '../contexts/SocketContext'; -import { PRIMARY_COLOR } from '../helper/Theme'; -import { useArticleRoom } from '../hooks/useArticleRoom'; - -import { Comment, CommentScreenProp, User } from '../type'; - -import { - parseValue, - PatternsConfig, - replaceTriggerValues, - SuggestionsProvidedProps, - TriggersConfig, - useMentions, -} from 'react-native-controlled-mentions'; - -import { SafeAreaView } from 'react-native-safe-area-context'; - -import { - GET_IMAGE, - GET_STORAGE_DATA, -} from '../helper/APIUtils'; - -const CommentScreen = ({ - navigation, - route, -}: CommentScreenProp) => { - const socket = useSocket(); - - const {articleId, mentionedUsers, article} = - route.params; - - useArticleRoom(articleId.toString(), null); - - const flatListRef = - useRef>(null); - - const [comments, setComments] = useState< - Comment[] - >([]); - const MAX_COMMENT_LENGTH = 500; - const [newComment, setNewComment] = - useState(''); - - const {user_id} = useSelector( - (state: any) => state.user, - ); - - const [selectedCommentId, setSelectedCommentId] = - useState(''); - - const [keyboardHeight, setKeyboardHeight] = - useState(0); - - useEffect(() => { - const keyboardDidShowListener = Keyboard.addListener( - 'keyboardDidShow', - e => { - setKeyboardHeight(e.endCoordinates.height); - }, - ); - const keyboardDidHideListener = Keyboard.addListener( - 'keyboardDidHide', - () => { - setKeyboardHeight(0); - }, - ); - - return () => { - keyboardDidShowListener.remove(); - keyboardDidHideListener.remove(); - }; - }, []); - - const [editMode, setEditMode] = - useState(false); - - const [editCommentId, setEditCommentId] = - useState(null); - - const [commentLoading, setCommentLoading] = - useState(false); - - const [ - commentLikeLoading, - setCommentLikeLoading, - ] = useState(false); - - const [mentions, setMentions] = useState< - User[] - >([]); - - const dispatch = useDispatch(); - - const triggersConfig: TriggersConfig< - 'mention' | 'hashtag' - > = { - mention: { - trigger: '@', - textStyle: { - fontWeight: 'bold', - color: 'blue', - }, - }, - - hashtag: { - trigger: '#', - allowedSpacesCount: 0, - textStyle: { - fontWeight: 'bold', - color: 'grey', - }, - }, - }; - - const patternsConfig: PatternsConfig = { - url: { - pattern: - /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi, - - textStyle: { - color: 'blue', - }, - }, - }; - - const {textInputProps, triggers} = - useMentions({ - value: newComment, - onChange: setNewComment, - triggersConfig, - patternsConfig, - }); - - useEffect(() => { - if (!socket) return; - - socket.emit('fetch-comments', { - articleId: route.params.articleId, - }); - - socket.on( - 'like-comment-processing', - (data: boolean) => - setCommentLikeLoading(data), - ); - - socket.on('fetch-comments', data => { - if ( - data.articleId === - route.params.articleId - ) { - setComments(data.comments); - } - }); - - socket.on('comment', data => { - if ( - data.articleId === - route.params.articleId - ) { - setComments(prev => { - const newList = [ - data.comment, - ...prev, - ]; - - if ( - flatListRef.current && - newList.length > 1 - ) { - flatListRef.current.scrollToIndex({ - index: 0, - animated: true, - }); - } - - return newList; - }); - } - }); - - socket.on('new-reply', data => { - if ( - data.articleId === - route.params.articleId - ) { - setComments(prev => - prev.map(comment => - comment._id === - data.parentCommentId - ? { - ...comment, - replies: [ - ...comment.replies, - data.reply, - ], - } - : comment, - ), - ); - } - }); - - socket.on('edit-comment', data => { - setComments(prev => - prev.map(comment => - comment._id === data._id - ? {...comment, ...data} - : comment, - ), - ); - }); - - socket.on('like-comment', data => { - setComments(prev => - prev.map(comment => - comment._id === data._id - ? {...comment, ...data} - : comment, - ), - ); - }); - - socket.on('delete-comment', data => { - setComments(prev => - prev.filter( - c => c._id !== data.commentId, - ), - ); - }); - - return () => { - socket.off('fetch-comments'); - socket.off('comment'); - socket.off('new-reply'); - socket.off('edit-comment'); - socket.off('delete-comment'); - socket.off('like-comment'); - }; - }, [socket, route.params.articleId]); - - const handleEditAction = ( - comment: Comment, - ) => { - setNewComment(comment.content); - setEditMode(true); - setEditCommentId(comment._id); - }; - - const handleMentionClick = ( - user_handle: string, - ) => { - - console.log('Mention clicked:', user_handle); - navigation.navigate('UserProfileScreen', { - author_handle: user_handle, - userHandle: user_handle, - }); - }; - - const handleDeleteAction = ( - comment: Comment, - ) => { - Alert.alert( - 'Alert', - 'Are you sure you want to delete this comment?', - [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'OK', - onPress: () => { - if (!socket) return; - - socket.emit('delete-comment', { - commentId: comment._id, - articleId: - route.params.articleId, - userId: user_id, - }); - }, - }, - ], - {cancelable: false}, - ); - }; - - const handleLikeAction = ( - comment: Comment, - ) => { - if (!socket) return; - - socket.emit('like-comment', { - commentId: comment._id, - articleId: route.params.articleId, - userId: user_id, - }); - }; - - const handleCommentSubmit = () => { - if (!newComment.trim()) { - Alert.alert( - 'Please enter a comment before submitting.', - ); - - return; - } - - const formatted = replaceTriggerValues( - newComment, - ({name}) => `@${name}`, - ); - - if (!socket) return; - - if (editMode && editCommentId) { - socket.emit('edit-comment', { - commentId: editCommentId, - content: formatted, - articleId: route.params.articleId, - userId: user_id, - }); - - setEditMode(false); - setEditCommentId(null); - } else { - const newCommentObj = { - userId: user_id, - articleId: route.params.articleId, - content: formatted, - parentCommentId: null, - mentionedUsers: mentions, - }; - - socket.emit('comment', newCommentObj); - } - - setNewComment(''); - }; - - const handleReportAction = ( - commentId: string, - authorId: string, - ) => { - navigation.navigate('ReportScreen', { - articleId: articleId.toString(), - authorId, - commentId, - podcastId: null, - }); - }; - - const Suggestions: FC< - SuggestionsProvidedProps & { - suggestions: User[]; - } - > = ({ - keyword, - onSelect, - suggestions, - }) => { - if (keyword == null) { - return null; - } - - return ( - - {suggestions - .filter(one => - one && - one.user_handle && - typeof one.user_handle === 'string' && - one.user_handle - .toLowerCase() - .includes( - (keyword || '').toLowerCase(), - ), - ) - .map(one => ( - { - onSelect({ - id: one._id, - name: one.user_handle, - }); - - setMentions(prev => [ - ...prev, - one, - ]); - }} - style={styles.suggestionItem}> - - - - {one.user_handle} - - - ))} - - ); - }; - - const usedUserIds = useMemo( - () => - parseValue(newComment, [ - triggersConfig.mention, - ]).parts.reduce((acc, part) => { - if (part.data?.id) { - acc.push(part.data.id); - } - - return acc; - }, [] as string[]), - - [newComment], - ); - - const filteredUsers = useMemo( - () => - mentionedUsers.filter( - user => - !usedUserIds.includes(user._id), - ), - - [mentionedUsers, usedUserIds], - ); - - if (commentLoading) { - return ; - } - - return ( - - - item._id} - keyboardShouldPersistTaps="handled" - ListHeaderComponent={ - - - -

- {article.title} -

-
- - - - - - - navigation.navigate( - 'ArticleScreen', - { - articleId: Number( - article._id, - ), - authorId: - article.authorId.toString(), - recordId: - article.pb_recordId, - }, - ) - } - style={ - styles.viewArticleButton - }> - - View Full Article - - - - - - {article.description} - - - - - - {/* 1. Updated Input Component with Strict 500 Character Boundary */} - - - {/* 2. Brand New Layout Row for Counter and Submit Button */} - - - {/* Real-time Dynamic Character Counter */} - = 480 ? '$red10' : '$colorMuted'} - fontWeight={newComment.length >= 480 ? '600' : '400'} - > - {newComment.length} / {MAX_COMMENT_LENGTH} - - - {/* 3. Submit Button (Always visible but visually disabled/faded when text is empty) */} - - - {editMode ? 'Update Comment' : 'Submit Comment'} - - - - - - - {comments.length}{' '} - {comments.length === 1 - ? 'Comment' - : 'Comments'} - - -
-
- } - renderItem={({item}) => ( - - )} - contentContainerStyle={[ - styles.scrollContent, - { - paddingBottom: - keyboardHeight > 0 - ? keyboardHeight + (Platform.OS === 'ios' ? 0 : 20) - : 20, - }, - ]} - showsVerticalScrollIndicator={ - false - } - /> -
-
- ); -}; - -const styles = StyleSheet.create({ - safeArea: { - flex: 1, - backgroundColor: '#FFFFFF', - }, - - scrollContent: { - paddingBottom: 20, - paddingHorizontal: 16, - }, - - articleTitleCard: { - backgroundColor: '#FFFFFF', - borderRadius: 12, - padding: 16, - marginTop: 8, - }, - - imageContainer: { - position: 'relative', - }, - - articleImage: { - width: '100%', - height: 200, - borderRadius: 12, - }, - - viewArticleButton: { - backgroundColor: PRIMARY_COLOR, - padding: 14, - borderRadius: 12, - alignItems: 'center', - }, - - viewArticleText: { - color: '#FFFFFF', - fontWeight: '700', - fontSize: 16, - }, - - descriptionCard: { - backgroundColor: '#FFFFFF', - borderRadius: 12, - padding: 16, - }, - - suggestionsContainer: { - maxHeight: 200, - backgroundColor: '#FFFFFF', - borderRadius: 12, - borderWidth: 1, - borderColor: '#E5E7EB', - }, - - suggestionItem: { - padding: 12, - flexDirection: 'row', - alignItems: 'center', - }, - - profileImage2: { - height: 36, - width: 36, - borderRadius: 18, - marginRight: 12, - }, - - username2: { - fontSize: 14, - fontWeight: '600', - color: '#374151', - }, - - textInput: { - minHeight: 100, - fontSize: 15, - borderRadius: 12, - padding: 16, - textAlignVertical: 'top', - backgroundColor: '#FFFFFF', - borderWidth: 1.5, - borderColor: '#D1D5DB', - color: '#1F2937', - marginTop: 8, - }, - - submitButton: { - backgroundColor: PRIMARY_COLOR, - padding: 16, - marginTop: 8, - borderRadius: 12, - alignItems: 'center', - }, - - submitButtonText: { - fontSize: 16, - color: '#FFFFFF', - fontWeight: '700', - }, - - commentsHeader: { - backgroundColor: '#F3F4F6', - padding: 12, - marginTop: 8, - borderRadius: 12, - borderLeftWidth: 4, - borderLeftColor: PRIMARY_COLOR, - }, - - commentsHeaderText: { - fontWeight: '700', - fontSize: 18, - color: '#1F2937', - }, -}); - -export default CommentScreen; +import React, { FC, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, + FlatList, + Keyboard, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + TextInput, + TouchableOpacity, + View +} from 'react-native'; + +import { useDispatch, useSelector } from 'react-redux'; +import { H3, Image, Paragraph, Text, YStack, TextArea, XStack, Button } from 'tamagui'; + +import CommentItem from '../components/CommentItem'; +import Loader from '../components/Loader'; + +import { useSocket } from '../contexts/SocketContext'; +import { PRIMARY_COLOR } from '../helper/Theme'; +import { useArticleRoom } from '../hooks/useArticleRoom'; + +import { Comment, CommentScreenProp, User } from '../type'; + +import { + parseValue, + PatternsConfig, + replaceTriggerValues, + SuggestionsProvidedProps, + TriggersConfig, + useMentions, +} from 'react-native-controlled-mentions'; + +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { import { rf } from '../helper/Metric'; + + GET_IMAGE, + GET_STORAGE_DATA, +} from '../helper/APIUtils'; + +const CommentScreen = ({ + navigation, + route, +}: CommentScreenProp) => { + const socket = useSocket(); + + const {articleId, mentionedUsers, article} = + route.params; + + useArticleRoom(articleId.toString(), null); + + const flatListRef = + useRef>(null); + + const [comments, setComments] = useState< + Comment[] + >([]); + const MAX_COMMENT_LENGTH = 500; + const [newComment, setNewComment] = + useState(''); + + const {user_id} = useSelector( + (state: any) => state.user, + ); + + const [selectedCommentId, setSelectedCommentId] = + useState(''); + + const [keyboardHeight, setKeyboardHeight] = + useState(0); + + useEffect(() => { + const keyboardDidShowListener = Keyboard.addListener( + 'keyboardDidShow', + e => { + setKeyboardHeight(e.endCoordinates.height); + }, + ); + const keyboardDidHideListener = Keyboard.addListener( + 'keyboardDidHide', + () => { + setKeyboardHeight(0); + }, + ); + + return () => { + keyboardDidShowListener.remove(); + keyboardDidHideListener.remove(); + }; + }, []); + + const [editMode, setEditMode] = + useState(false); + + const [editCommentId, setEditCommentId] = + useState(null); + + const [commentLoading, setCommentLoading] = + useState(false); + + const [ + commentLikeLoading, + setCommentLikeLoading, + ] = useState(false); + + const [mentions, setMentions] = useState< + User[] + >([]); + + const dispatch = useDispatch(); + + const triggersConfig: TriggersConfig< + 'mention' | 'hashtag' + > = { + mention: { + trigger: '@', + textStyle: { + fontWeight: 'bold', + color: 'blue', + }, + }, + + hashtag: { + trigger: '#', + allowedSpacesCount: 0, + textStyle: { + fontWeight: 'bold', + color: 'grey', + }, + }, + }; + + const patternsConfig: PatternsConfig = { + url: { + pattern: + /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi, + + textStyle: { + color: 'blue', + }, + }, + }; + + const {textInputProps, triggers} = + useMentions({ + value: newComment, + onChange: setNewComment, + triggersConfig, + patternsConfig, + }); + + useEffect(() => { + if (!socket) return; + + socket.emit('fetch-comments', { + articleId: route.params.articleId, + }); + + socket.on( + 'like-comment-processing', + (data: boolean) => + setCommentLikeLoading(data), + ); + + socket.on('fetch-comments', data => { + if ( + data.articleId === + route.params.articleId + ) { + setComments(data.comments); + } + }); + + socket.on('comment', data => { + if ( + data.articleId === + route.params.articleId + ) { + setComments(prev => { + const newList = [ + data.comment, + ...prev, + ]; + + if ( + flatListRef.current && + newList.length > 1 + ) { + flatListRef.current.scrollToIndex({ + index: 0, + animated: true, + }); + } + + return newList; + }); + } + }); + + socket.on('new-reply', data => { + if ( + data.articleId === + route.params.articleId + ) { + setComments(prev => + prev.map(comment => + comment._id === + data.parentCommentId + ? { + ...comment, + replies: [ + ...comment.replies, + data.reply, + ], + } + : comment, + ), + ); + } + }); + + socket.on('edit-comment', data => { + setComments(prev => + prev.map(comment => + comment._id === data._id + ? {...comment, ...data} + : comment, + ), + ); + }); + + socket.on('like-comment', data => { + setComments(prev => + prev.map(comment => + comment._id === data._id + ? {...comment, ...data} + : comment, + ), + ); + }); + + socket.on('delete-comment', data => { + setComments(prev => + prev.filter( + c => c._id !== data.commentId, + ), + ); + }); + + return () => { + socket.off('fetch-comments'); + socket.off('comment'); + socket.off('new-reply'); + socket.off('edit-comment'); + socket.off('delete-comment'); + socket.off('like-comment'); + }; + }, [socket, route.params.articleId]); + + const handleEditAction = ( + comment: Comment, + ) => { + setNewComment(comment.content); + setEditMode(true); + setEditCommentId(comment._id); + }; + + const handleMentionClick = ( + user_handle: string, + ) => { + + console.log('Mention clicked:', user_handle); + navigation.navigate('UserProfileScreen', { + author_handle: user_handle, + userHandle: user_handle, + }); + }; + + const handleDeleteAction = ( + comment: Comment, + ) => { + Alert.alert( + 'Alert', + 'Are you sure you want to delete this comment?', + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'OK', + onPress: () => { + if (!socket) return; + + socket.emit('delete-comment', { + commentId: comment._id, + articleId: + route.params.articleId, + userId: user_id, + }); + }, + }, + ], + {cancelable: false}, + ); + }; + + const handleLikeAction = ( + comment: Comment, + ) => { + if (!socket) return; + + socket.emit('like-comment', { + commentId: comment._id, + articleId: route.params.articleId, + userId: user_id, + }); + }; + + const handleCommentSubmit = () => { + if (!newComment.trim()) { + Alert.alert( + 'Please enter a comment before submitting.', + ); + + return; + } + + const formatted = replaceTriggerValues( + newComment, + ({name}) => `@${name}`, + ); + + if (!socket) return; + + if (editMode && editCommentId) { + socket.emit('edit-comment', { + commentId: editCommentId, + content: formatted, + articleId: route.params.articleId, + userId: user_id, + }); + + setEditMode(false); + setEditCommentId(null); + } else { + const newCommentObj = { + userId: user_id, + articleId: route.params.articleId, + content: formatted, + parentCommentId: null, + mentionedUsers: mentions, + }; + + socket.emit('comment', newCommentObj); + } + + setNewComment(''); + }; + + const handleReportAction = ( + commentId: string, + authorId: string, + ) => { + navigation.navigate('ReportScreen', { + articleId: articleId.toString(), + authorId, + commentId, + podcastId: null, + }); + }; + + const Suggestions: FC< + SuggestionsProvidedProps & { + suggestions: User[]; + } + > = ({ + keyword, + onSelect, + suggestions, + }) => { + if (keyword == null) { + return null; + } + + return ( + + {suggestions + .filter(one => + one && + one.user_handle && + typeof one.user_handle === 'string' && + one.user_handle + .toLowerCase() + .includes( + (keyword || '').toLowerCase(), + ), + ) + .map(one => ( + { + onSelect({ + id: one._id, + name: one.user_handle, + }); + + setMentions(prev => [ + ...prev, + one, + ]); + }} + style={styles.suggestionItem}> + + + + {one.user_handle} + + + ))} + + ); + }; + + const usedUserIds = useMemo( + () => + parseValue(newComment, [ + triggersConfig.mention, + ]).parts.reduce((acc, part) => { + if (part.data?.id) { + acc.push(part.data.id); + } + + return acc; + }, [] as string[]), + + [newComment], + ); + + const filteredUsers = useMemo( + () => + mentionedUsers.filter( + user => + !usedUserIds.includes(user._id), + ), + + [mentionedUsers, usedUserIds], + ); + + if (commentLoading) { + return ; + } + + return ( + + + item._id} + keyboardShouldPersistTaps="handled" + ListHeaderComponent={ + + + +

+ {article.title} +

+
+ + + + + + + navigation.navigate( + 'ArticleScreen', + { + articleId: Number( + article._id, + ), + authorId: + article.authorId.toString(), + recordId: + article.pb_recordId, + }, + ) + } + style={ + styles.viewArticleButton + }> + + View Full Article + + + + + + {article.description} + + + + + + {/* 1. Updated Input Component with Strict 500 Character Boundary */} + + + {/* 2. Brand New Layout Row for Counter and Submit Button */} + + + {/* Real-time Dynamic Character Counter */} + = 480 ? '$red10' : '$colorMuted'} + fontWeight={newComment.length >= 480 ? '600' : '400'} + > + {newComment.length} / {MAX_COMMENT_LENGTH} + + + {/* 3. Submit Button (Always visible but visually disabled/faded when text is empty) */} + + + {editMode ? 'Update Comment' : 'Submit Comment'} + + + + + + + {comments.length}{' '} + {comments.length === 1 + ? 'Comment' + : 'Comments'} + + +
+
+ } + renderItem={({item}) => ( + + )} + contentContainerStyle={[ + styles.scrollContent, + { + paddingBottom: + keyboardHeight > 0 + ? keyboardHeight + (Platform.OS === 'ios' ? 0 : 20) + : 20, + }, + ]} + showsVerticalScrollIndicator={ + false + } + /> +
+
+ ); +}; + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#FFFFFF', + }, + + scrollContent: { + paddingBottom: 20, + paddingHorizontal: 16, + }, + + articleTitleCard: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 16, + marginTop: 8, + }, + + imageContainer: { + position: 'relative', + }, + + articleImage: { + width: '100%', + height: 200, + borderRadius: 12, + }, + + viewArticleButton: { + backgroundColor: PRIMARY_COLOR, + padding: 14, + borderRadius: 12, + alignItems: 'center', + }, + + viewArticleText: { + color: '#FFFFFF', + fontWeight: '700', + fontSize: rf(16), + }, + + descriptionCard: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 16, + }, + + suggestionsContainer: { + maxHeight: 200, + backgroundColor: '#FFFFFF', + borderRadius: 12, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + + suggestionItem: { + padding: 12, + flexDirection: 'row', + alignItems: 'center', + }, + + profileImage2: { + height: 36, + width: 36, + borderRadius: 18, + marginRight: 12, + }, + + username2: { + fontSize: rf(14), + fontWeight: '600', + color: '#374151', + }, + + textInput: { + minHeight: 100, + fontSize: rf(15), + borderRadius: 12, + padding: 16, + textAlignVertical: 'top', + backgroundColor: '#FFFFFF', + borderWidth: 1.5, + borderColor: '#D1D5DB', + color: '#1F2937', + marginTop: 8, + }, + + submitButton: { + backgroundColor: PRIMARY_COLOR, + padding: 16, + marginTop: 8, + borderRadius: 12, + alignItems: 'center', + }, + + submitButtonText: { + fontSize: rf(16), + color: '#FFFFFF', + fontWeight: '700', + }, + + commentsHeader: { + backgroundColor: '#F3F4F6', + padding: 12, + marginTop: 8, + borderRadius: 12, + borderLeftWidth: 4, + borderLeftColor: PRIMARY_COLOR, + }, + + commentsHeaderText: { + fontWeight: '700', + fontSize: rf(18), + color: '#1F2937', + }, +}); + +export default CommentScreen; diff --git a/frontend/src/screens/CommunityGuidelinesScreen.tsx b/frontend/src/screens/CommunityGuidelinesScreen.tsx index 5b69ba86..52445ff1 100644 --- a/frontend/src/screens/CommunityGuidelinesScreen.tsx +++ b/frontend/src/screens/CommunityGuidelinesScreen.tsx @@ -1,419 +1,420 @@ -import React from 'react'; -import { Share, useColorScheme } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { StatusBar } from 'expo-status-bar'; -import { ScrollView, YStack, XStack, Text, Button, View, Separator } from 'tamagui'; -import { Ionicons } from '@expo/vector-icons'; -import { ProfessionalColors, Typography, Spacing } from '../styles/GlassStyles'; -import { GlassContainer } from '../components/GlassContainer'; -import { useBackToTop } from '../components/BackToTopScrollView'; -import { BackToTopButton } from '../components/BackToTopButton'; -import type { CommunityGuidelinesScreenProps } from '../type'; +import React from 'react'; +import { Share, useColorScheme } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { StatusBar } from 'expo-status-bar'; +import { ScrollView, YStack, XStack, Text, Button, View, Separator } from 'tamagui'; +import { Ionicons } from '@expo/vector-icons'; +import { ProfessionalColors, Typography, Spacing } from '../styles/GlassStyles'; +import { GlassContainer } from '../components/GlassContainer'; +import { useBackToTop } from '../components/BackToTopScrollView'; +import { BackToTopButton } from '../components/BackToTopButton'; +import type { CommunityGuidelinesScreenProps } from '../type'; import { rf } from '../helper/Metric'; -interface Section { - id: number; - title: string; - icon: keyof typeof Ionicons.glyphMap; - iconColor: string; - points: string[]; - note: string; - noteType: 'warning' | 'info' | 'error'; -} - -const sections: Section[] = [ - { - id: 1, - title: 'Respect Every Community Member', - icon: 'people-outline', - iconColor: ProfessionalColors.info, - points: [ - 'Communicate with kindness and respect in all interactions', - 'Engage in healthy, constructive discussions', - 'Support fellow community members on their wellness journey', - 'Embrace inclusive participation across all languages and cultures', - ], - note: 'Harassment, hate speech, bullying, and abusive behavior are strictly prohibited.', - noteType: 'warning', - }, - { - id: 2, - title: 'Share Responsible Health Information', - icon: 'medkit-outline', - iconColor: ProfessionalColors.success, - points: [ - 'Share only trustworthy and meaningful health content', - 'Clearly distinguish opinions from established facts', - 'Avoid spreading misinformation or unverified medical claims', - 'Always encourage readers to consult healthcare professionals for medical advice', - ], - note: 'Misleading or harmful health claims may be removed and could result in account restrictions.', - noteType: 'warning', - }, - { - id: 3, - title: 'Respect Language & Cultural Diversity', - icon: 'language-outline', - iconColor: '#a855f7', - points: [ - 'Celebrate the multilingual nature of our global community', - 'Respect all languages and cultural backgrounds', - 'Avoid mockery, discrimination, or exclusion based on language or culture', - 'Promote inclusive communication that bridges communities', - ], - note: 'UltimateHealth supports content in multiple languages \u2014 respect every voice.', - noteType: 'info', - }, - { - id: 4, - title: 'Podcast & Media Guidelines', - icon: 'mic-outline', - iconColor: ProfessionalColors.warning, - points: [ - 'Respect copyrights \u2014 only upload content you have the right to share', - 'Avoid harmful, explicit, or offensive material in podcasts and media', - 'Ensure all uploaded content is meaningful and adds value to the community', - 'Provide accurate descriptions and tags for your media', - ], - note: 'Copyright violations will result in content removal and potential account suspension.', - noteType: 'warning', - }, - { - id: 5, - title: 'Content & Review Guidelines', - icon: 'create-outline', - iconColor: ProfessionalColors.primary, - points: [ - 'Provide constructive and thoughtful reviews of articles and podcasts', - 'Offer meaningful feedback that helps fellow creators improve', - 'Engage honestly with content through ratings and discussions', - ], - note: 'Spam, fake engagement, manipulative ratings, and repetitive low-quality content are not permitted.', - noteType: 'warning', - }, - { - id: 6, - title: 'Privacy & Safety', - icon: 'shield-checkmark-outline', - iconColor: ProfessionalColors.success, - points: [ - "Respect the privacy of all community members", - "Never share sensitive personal information \u2014 yours or others'", - 'Do not impersonate individuals, organizations, or create fake identities', - 'Report privacy violations you encounter to the moderation team', - ], - note: 'Protecting your privacy is our priority \u2014 and yours too.', - noteType: 'info', - }, - { - id: 7, - title: 'Prohibited Activities', - icon: 'warning-outline', - iconColor: ProfessionalColors.error, - points: [ - 'Harassment, intimidation, or targeted abuse of any kind', - 'Hate speech, discriminatory language, or incitement to violence', - 'Spam, phishing, scams, or any form of deceptive activity', - 'Copyright infringement or unauthorized use of intellectual property', - 'Deliberate dissemination of health misinformation', - 'Any malicious activity that compromises platform integrity', - ], - note: 'Engaging in prohibited activities may lead to immediate content removal and account restriction.', - noteType: 'error', - }, - { - id: 8, - title: 'Moderation & Enforcement', - icon: 'shield-checkmark-outline', - iconColor: ProfessionalColors.secondary, - points: [ - 'The UltimateHealth team reserves the right to remove inappropriate content', - 'Discussions may be moderated to maintain a safe and respectful environment', - 'Accounts violating these guidelines may be temporarily or permanently restricted', - 'Repeated or severe violations will result in escalated enforcement actions', - ], - note: "Our moderation decisions are made with fairness and the community's best interests in mind.", - noteType: 'info', - }, -]; - -const NOTE_ICONS = { - warning: 'alert-circle' as const, - error: 'warning' as const, - info: 'information-circle' as const, -} as const; - -const NOTE_COLORS = { - warning: { - bg: ProfessionalColors.warningGlass, - border: ProfessionalColors.warning, - text: ProfessionalColors.warning, - }, - info: { - bg: ProfessionalColors.infoGlass, - border: ProfessionalColors.info, - text: ProfessionalColors.info, - }, - error: { - bg: ProfessionalColors.errorGlass, - border: ProfessionalColors.error, - text: ProfessionalColors.error, - }, -} as const; - -interface SectionNoteProps { - text: string; - type: 'warning' | 'info' | 'error'; -} - -const SectionNote = ({ text, type }: SectionNoteProps) => { - const c = NOTE_COLORS[type]; - - return ( - - - - - - {text} - - - ); -}; - -const CommunityGuidelinesScreen = (_props: CommunityGuidelinesScreenProps) => { - const colorScheme = useColorScheme(); - const isDarkMode = colorScheme === 'dark'; - const { onScroll, visible, opacity, scrollToTop } = useBackToTop({ threshold: 300 }); - - const onShare = async () => { - try { - await Share.share({ - message: - 'Check out the UltimateHealth Community Guidelines \nLearn how we keep our wellness community safe and respectful.\n\nDownload UltimateHealth:\nhttps://play.google.com/store/apps/details?id=com.anonymous.UltimateHealth', - }); - } catch (error: any) { - console.log(error.message); - } - }; - - const bgColor = isDarkMode ? '#000A60' : '#F0F8FF'; - const textColor = isDarkMode ? ProfessionalColors.white : ProfessionalColors.gray900; - const subtextColor = isDarkMode ? ProfessionalColors.gray400 : ProfessionalColors.gray600; - - return ( - - - - - - - - - - - - Community Guidelines - - - Empowering Wellness Through Global Community - - - - - - - UltimateHealth brings together a diverse global community united by a shared passion for wellness. These guidelines help maintain a respectful, safe, and informative environment for everyone. - - - - - - - - {sections.map((section) => ( - - - - - - - - {section.title} - - - - - {section.points.map((point, i) => ( - - - {'\u2022'} - - - {point} - - - ))} - - - - - - ))} - - - - - - - Together, We Build a Healthier Community - - - Thank you for being part of UltimateHealth. Your contributions, respect, and kindness make this community thrive. - - - - - - - - {'\u00A9'} 2025 UltimateHealth {'\u2014'} All rights reserved - - - These guidelines were last updated on 2025 - - - - - - - ); -}; - -export default CommunityGuidelinesScreen; + +interface Section { + id: number; + title: string; + icon: keyof typeof Ionicons.glyphMap; + iconColor: string; + points: string[]; + note: string; + noteType: 'warning' | 'info' | 'error'; +} + +const sections: Section[] = [ + { + id: 1, + title: 'Respect Every Community Member', + icon: 'people-outline', + iconColor: ProfessionalColors.info, + points: [ + 'Communicate with kindness and respect in all interactions', + 'Engage in healthy, constructive discussions', + 'Support fellow community members on their wellness journey', + 'Embrace inclusive participation across all languages and cultures', + ], + note: 'Harassment, hate speech, bullying, and abusive behavior are strictly prohibited.', + noteType: 'warning', + }, + { + id: 2, + title: 'Share Responsible Health Information', + icon: 'medkit-outline', + iconColor: ProfessionalColors.success, + points: [ + 'Share only trustworthy and meaningful health content', + 'Clearly distinguish opinions from established facts', + 'Avoid spreading misinformation or unverified medical claims', + 'Always encourage readers to consult healthcare professionals for medical advice', + ], + note: 'Misleading or harmful health claims may be removed and could result in account restrictions.', + noteType: 'warning', + }, + { + id: 3, + title: 'Respect Language & Cultural Diversity', + icon: 'language-outline', + iconColor: '#a855f7', + points: [ + 'Celebrate the multilingual nature of our global community', + 'Respect all languages and cultural backgrounds', + 'Avoid mockery, discrimination, or exclusion based on language or culture', + 'Promote inclusive communication that bridges communities', + ], + note: 'UltimateHealth supports content in multiple languages \u2014 respect every voice.', + noteType: 'info', + }, + { + id: 4, + title: 'Podcast & Media Guidelines', + icon: 'mic-outline', + iconColor: ProfessionalColors.warning, + points: [ + 'Respect copyrights \u2014 only upload content you have the right to share', + 'Avoid harmful, explicit, or offensive material in podcasts and media', + 'Ensure all uploaded content is meaningful and adds value to the community', + 'Provide accurate descriptions and tags for your media', + ], + note: 'Copyright violations will result in content removal and potential account suspension.', + noteType: 'warning', + }, + { + id: 5, + title: 'Content & Review Guidelines', + icon: 'create-outline', + iconColor: ProfessionalColors.primary, + points: [ + 'Provide constructive and thoughtful reviews of articles and podcasts', + 'Offer meaningful feedback that helps fellow creators improve', + 'Engage honestly with content through ratings and discussions', + ], + note: 'Spam, fake engagement, manipulative ratings, and repetitive low-quality content are not permitted.', + noteType: 'warning', + }, + { + id: 6, + title: 'Privacy & Safety', + icon: 'shield-checkmark-outline', + iconColor: ProfessionalColors.success, + points: [ + "Respect the privacy of all community members", + "Never share sensitive personal information \u2014 yours or others'", + 'Do not impersonate individuals, organizations, or create fake identities', + 'Report privacy violations you encounter to the moderation team', + ], + note: 'Protecting your privacy is our priority \u2014 and yours too.', + noteType: 'info', + }, + { + id: 7, + title: 'Prohibited Activities', + icon: 'warning-outline', + iconColor: ProfessionalColors.error, + points: [ + 'Harassment, intimidation, or targeted abuse of any kind', + 'Hate speech, discriminatory language, or incitement to violence', + 'Spam, phishing, scams, or any form of deceptive activity', + 'Copyright infringement or unauthorized use of intellectual property', + 'Deliberate dissemination of health misinformation', + 'Any malicious activity that compromises platform integrity', + ], + note: 'Engaging in prohibited activities may lead to immediate content removal and account restriction.', + noteType: 'error', + }, + { + id: 8, + title: 'Moderation & Enforcement', + icon: 'shield-checkmark-outline', + iconColor: ProfessionalColors.secondary, + points: [ + 'The UltimateHealth team reserves the right to remove inappropriate content', + 'Discussions may be moderated to maintain a safe and respectful environment', + 'Accounts violating these guidelines may be temporarily or permanently restricted', + 'Repeated or severe violations will result in escalated enforcement actions', + ], + note: "Our moderation decisions are made with fairness and the community's best interests in mind.", + noteType: 'info', + }, +]; + +const NOTE_ICONS = { + warning: 'alert-circle' as const, + error: 'warning' as const, + info: 'information-circle' as const, +} as const; + +const NOTE_COLORS = { + warning: { + bg: ProfessionalColors.warningGlass, + border: ProfessionalColors.warning, + text: ProfessionalColors.warning, + }, + info: { + bg: ProfessionalColors.infoGlass, + border: ProfessionalColors.info, + text: ProfessionalColors.info, + }, + error: { + bg: ProfessionalColors.errorGlass, + border: ProfessionalColors.error, + text: ProfessionalColors.error, + }, +} as const; + +interface SectionNoteProps { + text: string; + type: 'warning' | 'info' | 'error'; +} + +const SectionNote = ({ text, type }: SectionNoteProps) => { + const c = NOTE_COLORS[type]; + + return ( + + + + + + {text} + + + ); +}; + +const CommunityGuidelinesScreen = (_props: CommunityGuidelinesScreenProps) => { + const colorScheme = useColorScheme(); + const isDarkMode = colorScheme === 'dark'; + const { onScroll, visible, opacity, scrollToTop } = useBackToTop({ threshold: 300 }); + + const onShare = async () => { + try { + await Share.share({ + message: + 'Check out the UltimateHealth Community Guidelines \nLearn how we keep our wellness community safe and respectful.\n\nDownload UltimateHealth:\nhttps://play.google.com/store/apps/details?id=com.anonymous.UltimateHealth', + }); + } catch (error: any) { + console.log(error.message); + } + }; + + const bgColor = isDarkMode ? '#000A60' : '#F0F8FF'; + const textColor = isDarkMode ? ProfessionalColors.white : ProfessionalColors.gray900; + const subtextColor = isDarkMode ? ProfessionalColors.gray400 : ProfessionalColors.gray600; + + return ( + + + + + + + + + + + + Community Guidelines + + + Empowering Wellness Through Global Community + + + + + + + UltimateHealth brings together a diverse global community united by a shared passion for wellness. These guidelines help maintain a respectful, safe, and informative environment for everyone. + + + + + + + + {sections.map((section) => ( + + + + + + + + {section.title} + + + + + {section.points.map((point, i) => ( + + + {'\u2022'} + + + {point} + + + ))} + + + + + + ))} + + + + + + + Together, We Build a Healthier Community + + + Thank you for being part of UltimateHealth. Your contributions, respect, and kindness make this community thrive. + + + + + + + + {'\u00A9'} 2025 UltimateHealth {'\u2014'} All rights reserved + + + These guidelines were last updated on 2025 + + + + + + + ); +}; + +export default CommunityGuidelinesScreen; diff --git a/frontend/src/screens/ContributorPage.tsx b/frontend/src/screens/ContributorPage.tsx index 0c94dfc8..4f485128 100644 --- a/frontend/src/screens/ContributorPage.tsx +++ b/frontend/src/screens/ContributorPage.tsx @@ -1,475 +1,476 @@ -import React, {useState, useMemo} from 'react'; -import {Linking, FlatList} from 'react-native'; -import { - YStack, - XStack, - Text, - Avatar, - Card, - Theme, - H2, - Circle, - Input, -} from 'tamagui'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import {MaterialCommunityIcons, FontAwesome5} from '@expo/vector-icons'; +import React, {useState, useMemo} from 'react'; +import {Linking, FlatList} from 'react-native'; +import { + YStack, + XStack, + Text, + Avatar, + Card, + Theme, + H2, + Circle, + Input, +} from 'tamagui'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {MaterialCommunityIcons, FontAwesome5} from '@expo/vector-icons'; import { rf } from '../helper/Metric'; - -const CONTRIBUTORS = [ - { - id: 1, - name: 'Susmita Bhattacharya', - handle: '@SB2318', - img: 'https://avatars.githubusercontent.com/u/87614560?v=4', - url: 'https://github.com/SB2318', - }, - { - id: 2, - name: 'Suhani Singh Paliwal', - handle: '@suhanipaliwal', - img: 'https://avatars.githubusercontent.com/u/161575955?v=4', - url: 'https://github.com/suhanipaliwal', - - }, - { - id: 3, - name: 'Balaharisankar Lakshmanaperumal', - handle: '@BHS-Harish', - img: 'https://avatars.githubusercontent.com/u/114602603?v=4', - url: 'https://github.com/BHS-Harish', - - }, - { - id: 4, - name: 'Sharma Nischay', - handle: '@SharmaNishchay', - img: 'https://avatars.githubusercontent.com/u/146124877?v=4', - url: 'https://github.com/SharmaNishchay', - - }, - { - id: 5, - name: 'Neeraj Saini', - handle: '@officeneerajsaini', - img: 'https://avatars.githubusercontent.com/u/118799941?v=4', - url: 'https://github.com/officeneerajsaini', - }, - { - id: 6, - name: 'Meghana Gottapu', - handle: '@meghanagottapu', - img: 'https://avatars.githubusercontent.com/u/43183125?v=4', - url: 'https://github.com/meghanagottapu', - }, - { - id: 7, - name: 'Jaickey Joy Minj', - handle: '@jaickeyminj', - img: 'https://avatars.githubusercontent.com/u/95216865?v=4', - url: 'https://github.com/jaickeyminj', - }, - { - id: 8, - name: 'Siddheya Kulkarni', - handle: '@Asymtode712', - img: 'https://avatars.githubusercontent.com/u/115717746?v=4', - url: 'https://github.com/Asymtode712', - }, - { - id: 9, - name: 'Pradnya Gaitonde', - handle: '@PradnyaGaitonde', - img: 'https://avatars.githubusercontent.com/u/116059908?v=4', - url: 'https://github.com/PradnyaGaitonde', - }, - { - id: 10, - name: 'Sanmarg Sandeep Paranjpe', - handle: '@sanmarg', - img: 'https://avatars.githubusercontent.com/u/50082154?v=4', - url: 'https://github.com/sanmarg', - }, - { - id: 11, - name: 'Adrika Dwivedi', - handle: '@adrikaDwivedi', - img: 'https://avatars.githubusercontent.com/u/89826992?v=4', - url: 'https://github.com/adrikaDwivedi', - }, - { - id: 12, - name: 'Arpna', - handle: '@Arpcoder', - img: 'https://avatars.githubusercontent.com/u/100352419?v=4', - url: 'https://github.com/Arpcoder', - }, - { - id: 13, - name: 'Alisha Singh', - handle: '@alishasingh06', - img: 'https://avatars.githubusercontent.com/u/114938485?v=4', - url: 'https://github.com/alishasingh06', - }, - { - id: 14, - name: 'Sibam Paul', - handle: '@Sibam-Paul', - img: 'https://avatars.githubusercontent.com/u/158052549?v=4', - url: 'https://github.com/Sibam-Paul', - }, - { - id: 15, - name: 'Hrushikesh Shinde', - handle: '@rushiii3', - img: 'https://avatars.githubusercontent.com/u/105168088?v=4', - url: 'https://github.com/rushiii3', - }, - { - id: 16, - name: 'Soham Adhyapak', - handle: '@soham0005', - img: 'https://avatars.githubusercontent.com/u/83421425?v=4', - url: 'https://github.com/soham0005', - }, - { - id: 17, - name: 'Kylie', - handle: '@kylie-kiaying', - img: 'https://avatars.githubusercontent.com/u/133581245?v=4', - url: 'https://github.com/kylie-kiaying', - }, - { - id: 18, - name: 'Himanshu Choudhary', - handle: '@Himanshu8850', - img: 'https://avatars.githubusercontent.com/u/128601673?v=4', - url: 'https://github.com/Himanshu8850', - }, - { - id: 19, - name: 'Hemanth Kumar', - handle: '@Hemu21', - img: 'https://avatars.githubusercontent.com/u/106808387?v=4', - url: 'https://github.com/Hemu21', - }, - { - id: 20, - name: 'Nishant Kaushal', - handle: '@nishant0708', - img: 'https://avatars.githubusercontent.com/u/101548649?v=4', - url: 'https://github.com/nishant0708', - }, - { - id: 21, - name: 'Kamalesh Bala', - handle: '@Kamaleshbala01', - img: 'https://avatars.githubusercontent.com/u/139665559?v=4', - url: 'https://github.com/Kamaleshbala01', - }, - { - id: 22, - name: 'Parth Nakum', - handle: '@ParthNakum21', - img: 'https://avatars.githubusercontent.com/u/134558990?v=4', - url: 'https://github.com/ParthNakum21', - }, - { - id: 23, - name: 'Abhigna Arsam', - handle: '@Abhigna-arsam', - img: 'https://avatars.githubusercontent.com/u/125258286?v=4', - url: 'https://github.com/Abhigna-arsam', - }, - { - id: 24, - name: 'Maryam Mohamed Yahya', - handle: '@MaryamMohamedYahya', - img: 'https://avatars.githubusercontent.com/u/147263523?v=4', - url: 'https://github.com/MaryamMohamedYahya', - }, - { - id: 25, - name: 'Vijay Shanker Sharma', - handle: '@thevijayshankersharma', - img: 'https://avatars.githubusercontent.com/u/109781385?v=4', - url: 'https://github.com/thevijayshankersharma', - }, - { - id: 26, - name: 'Tony Stark', - handle: '@TonyStark-47', - img: 'https://avatars.githubusercontent.com/u/73957207?v=4', - url: 'https://github.com/TonyStark-47', - }, - { - id: 27, - name: 'Worrell Seville', - handle: '@iamworrell', - img: 'https://avatars.githubusercontent.com/u/99043769?v=4', - url: 'https://github.com/iamworrell', - }, - { - id: 28, - name: 'Aditi', - handle: '@Aditijainnn', - img: 'https://avatars.githubusercontent.com/u/144632601?v=4', - url: 'https://github.com/Aditijainnn', - }, - { - id: 29, - name: 'Ananya Gupta', - handle: '@ananyag309', - img: 'https://avatars.githubusercontent.com/u/145869907?v=4', - url: 'https://github.com/ananyag309', - }, - { - id: 30, - name: 'Akshat', - handle: '@akshathere', - img: 'https://avatars.githubusercontent.com/u/106247875?v=4', - url: 'https://github.com/akshathere', - }, - { - id: 31, - name: 'Ayushmaan Agarwal', - handle: '@Ayushmaanagarwal1211', - img: 'https://avatars.githubusercontent.com/u/118350936?v=4', - url: 'https://github.com/Ayushmaanagarwal1211', - }, - { - id: 32, - name: 'Damini Chachane', - handle: '@Damini2004', - img: 'https://avatars.githubusercontent.com/u/119414762?v=4', - url: 'https://github.com/Damini2004', - }, - { - id: 33, - name: 'Parth Shah', - handle: '@Parth20GitHub', - img: 'https://avatars.githubusercontent.com/u/142086512?v=4', - url: 'https://github.com/Parth20GitHub', - }, - { - id: 34, - name: 'Sree Vidya', - handle: '@sreevidya-16', - img: 'https://avatars.githubusercontent.com/u/115856774?v=4', - url: 'https://github.com/sreevidya-16', - }, - { - id: 35, - name: 'Asmita Mishra', - handle: '@AsmitaMishra24', - img: 'https://avatars.githubusercontent.com/u/146121869?v=4', - url: 'https://github.com/AsmitaMishra24', - }, - { - id: 36, - name: 'Kanhaiya Kumar', - handle: '@iamkanhaiyakumar', - img: 'https://avatars.githubusercontent.com/u/120328606?v=4', - url: 'https://github.com/iamkanhaiyakumar', - }, - { - id: 37, - name: 'Revanth', - handle: '@revanth1718', - img: 'https://avatars.githubusercontent.com/u/109272714?v=4', - url: 'https://github.com/revanth1718', - }, - { - id: 38, - name: 'Arunima Dutta', - handle: '@arunimaChintu', - img: 'https://avatars.githubusercontent.com/u/99474881?v=4', - url: 'https://github.com/arunimaChintu', - }, - { - id: 39, - name: 'Maana Ajmera', - handle: '@Maana-Ajmera', - img: 'https://avatars.githubusercontent.com/u/162733812?v=4', - url: 'https://github.com/Maana-Ajmera', - }, - { - id: 40, - name: 'Aditya Narayan', - handle: '@ANKeshri', - img: 'https://avatars.githubusercontent.com/u/159682348?v=4', - url: 'https://github.com/ANKeshri', - }, - { - id: 41, - name: 'Utsav Ladia', - handle: '@Utsavladia', - img: 'https://avatars.githubusercontent.com/u/124615886?v=4', - url: 'https://github.com/Utsavladia', - }, - { - id: 42, - name: 'Nayanika Mukherjee', - handle: '@Nayanika1402', - img: 'https://avatars.githubusercontent.com/u/132455412?v=4', - url: 'https://github.com/Nayanika1402', - }, - { - id: 43, - name: 'Maheshwari Love', - handle: '@Maheshwari-Love', - img: 'https://avatars.githubusercontent.com/u/142833275?v=4', - url: 'https://github.com/Maheshwari-Love', - }, - { - id: 44, - name: 'Pujan Sarkar', - handle: '@Pujan-sarkar', - img: 'https://avatars.githubusercontent.com/u/144250917?v=4', - url: 'https://github.com/Pujan-sarkar', - }, -]; - -const ContributorPage = () => { - const [search, setSearch] = useState(''); - - const openLink = (url: string) => { - Linking.openURL(url); - }; - - // 🔍 Filter contributors - const filteredContributors = useMemo(() => { - if (!search.trim()) return CONTRIBUTORS; - - const q = search.toLowerCase(); - return CONTRIBUTORS.filter( - item => - item.name.toLowerCase().includes(q) || - item.handle.toLowerCase().includes(q), - ); - }, [search]); - - const renderItem = ({item}: {item: any}) => ( - openLink(item.url)} - pressStyle={{opacity: 0.9}}> - - - - - - - - - - - - {item.name} - - - {item.handle} - - - - - - - - ); - - return ( - - - {/* HEADER */} - - -

- Our Contributors -

- -
- - - Thank you for contributing to our repository - - - - - - We appreciate your help in making UltimateHealth better! - - - - - - - - - -
- - {/* LIST */} - item.id.toString()} - renderItem={renderItem} - contentContainerStyle={{padding: 20}} - showsVerticalScrollIndicator={false} - ListEmptyComponent={ - - - - No contributors found - - - } - /> -
-
- ); -}; - -export default ContributorPage; + + +const CONTRIBUTORS = [ + { + id: 1, + name: 'Susmita Bhattacharya', + handle: '@SB2318', + img: 'https://avatars.githubusercontent.com/u/87614560?v=4', + url: 'https://github.com/SB2318', + }, + { + id: 2, + name: 'Suhani Singh Paliwal', + handle: '@suhanipaliwal', + img: 'https://avatars.githubusercontent.com/u/161575955?v=4', + url: 'https://github.com/suhanipaliwal', + + }, + { + id: 3, + name: 'Balaharisankar Lakshmanaperumal', + handle: '@BHS-Harish', + img: 'https://avatars.githubusercontent.com/u/114602603?v=4', + url: 'https://github.com/BHS-Harish', + + }, + { + id: 4, + name: 'Sharma Nischay', + handle: '@SharmaNishchay', + img: 'https://avatars.githubusercontent.com/u/146124877?v=4', + url: 'https://github.com/SharmaNishchay', + + }, + { + id: 5, + name: 'Neeraj Saini', + handle: '@officeneerajsaini', + img: 'https://avatars.githubusercontent.com/u/118799941?v=4', + url: 'https://github.com/officeneerajsaini', + }, + { + id: 6, + name: 'Meghana Gottapu', + handle: '@meghanagottapu', + img: 'https://avatars.githubusercontent.com/u/43183125?v=4', + url: 'https://github.com/meghanagottapu', + }, + { + id: 7, + name: 'Jaickey Joy Minj', + handle: '@jaickeyminj', + img: 'https://avatars.githubusercontent.com/u/95216865?v=4', + url: 'https://github.com/jaickeyminj', + }, + { + id: 8, + name: 'Siddheya Kulkarni', + handle: '@Asymtode712', + img: 'https://avatars.githubusercontent.com/u/115717746?v=4', + url: 'https://github.com/Asymtode712', + }, + { + id: 9, + name: 'Pradnya Gaitonde', + handle: '@PradnyaGaitonde', + img: 'https://avatars.githubusercontent.com/u/116059908?v=4', + url: 'https://github.com/PradnyaGaitonde', + }, + { + id: 10, + name: 'Sanmarg Sandeep Paranjpe', + handle: '@sanmarg', + img: 'https://avatars.githubusercontent.com/u/50082154?v=4', + url: 'https://github.com/sanmarg', + }, + { + id: 11, + name: 'Adrika Dwivedi', + handle: '@adrikaDwivedi', + img: 'https://avatars.githubusercontent.com/u/89826992?v=4', + url: 'https://github.com/adrikaDwivedi', + }, + { + id: 12, + name: 'Arpna', + handle: '@Arpcoder', + img: 'https://avatars.githubusercontent.com/u/100352419?v=4', + url: 'https://github.com/Arpcoder', + }, + { + id: 13, + name: 'Alisha Singh', + handle: '@alishasingh06', + img: 'https://avatars.githubusercontent.com/u/114938485?v=4', + url: 'https://github.com/alishasingh06', + }, + { + id: 14, + name: 'Sibam Paul', + handle: '@Sibam-Paul', + img: 'https://avatars.githubusercontent.com/u/158052549?v=4', + url: 'https://github.com/Sibam-Paul', + }, + { + id: 15, + name: 'Hrushikesh Shinde', + handle: '@rushiii3', + img: 'https://avatars.githubusercontent.com/u/105168088?v=4', + url: 'https://github.com/rushiii3', + }, + { + id: 16, + name: 'Soham Adhyapak', + handle: '@soham0005', + img: 'https://avatars.githubusercontent.com/u/83421425?v=4', + url: 'https://github.com/soham0005', + }, + { + id: 17, + name: 'Kylie', + handle: '@kylie-kiaying', + img: 'https://avatars.githubusercontent.com/u/133581245?v=4', + url: 'https://github.com/kylie-kiaying', + }, + { + id: 18, + name: 'Himanshu Choudhary', + handle: '@Himanshu8850', + img: 'https://avatars.githubusercontent.com/u/128601673?v=4', + url: 'https://github.com/Himanshu8850', + }, + { + id: 19, + name: 'Hemanth Kumar', + handle: '@Hemu21', + img: 'https://avatars.githubusercontent.com/u/106808387?v=4', + url: 'https://github.com/Hemu21', + }, + { + id: 20, + name: 'Nishant Kaushal', + handle: '@nishant0708', + img: 'https://avatars.githubusercontent.com/u/101548649?v=4', + url: 'https://github.com/nishant0708', + }, + { + id: 21, + name: 'Kamalesh Bala', + handle: '@Kamaleshbala01', + img: 'https://avatars.githubusercontent.com/u/139665559?v=4', + url: 'https://github.com/Kamaleshbala01', + }, + { + id: 22, + name: 'Parth Nakum', + handle: '@ParthNakum21', + img: 'https://avatars.githubusercontent.com/u/134558990?v=4', + url: 'https://github.com/ParthNakum21', + }, + { + id: 23, + name: 'Abhigna Arsam', + handle: '@Abhigna-arsam', + img: 'https://avatars.githubusercontent.com/u/125258286?v=4', + url: 'https://github.com/Abhigna-arsam', + }, + { + id: 24, + name: 'Maryam Mohamed Yahya', + handle: '@MaryamMohamedYahya', + img: 'https://avatars.githubusercontent.com/u/147263523?v=4', + url: 'https://github.com/MaryamMohamedYahya', + }, + { + id: 25, + name: 'Vijay Shanker Sharma', + handle: '@thevijayshankersharma', + img: 'https://avatars.githubusercontent.com/u/109781385?v=4', + url: 'https://github.com/thevijayshankersharma', + }, + { + id: 26, + name: 'Tony Stark', + handle: '@TonyStark-47', + img: 'https://avatars.githubusercontent.com/u/73957207?v=4', + url: 'https://github.com/TonyStark-47', + }, + { + id: 27, + name: 'Worrell Seville', + handle: '@iamworrell', + img: 'https://avatars.githubusercontent.com/u/99043769?v=4', + url: 'https://github.com/iamworrell', + }, + { + id: 28, + name: 'Aditi', + handle: '@Aditijainnn', + img: 'https://avatars.githubusercontent.com/u/144632601?v=4', + url: 'https://github.com/Aditijainnn', + }, + { + id: 29, + name: 'Ananya Gupta', + handle: '@ananyag309', + img: 'https://avatars.githubusercontent.com/u/145869907?v=4', + url: 'https://github.com/ananyag309', + }, + { + id: 30, + name: 'Akshat', + handle: '@akshathere', + img: 'https://avatars.githubusercontent.com/u/106247875?v=4', + url: 'https://github.com/akshathere', + }, + { + id: 31, + name: 'Ayushmaan Agarwal', + handle: '@Ayushmaanagarwal1211', + img: 'https://avatars.githubusercontent.com/u/118350936?v=4', + url: 'https://github.com/Ayushmaanagarwal1211', + }, + { + id: 32, + name: 'Damini Chachane', + handle: '@Damini2004', + img: 'https://avatars.githubusercontent.com/u/119414762?v=4', + url: 'https://github.com/Damini2004', + }, + { + id: 33, + name: 'Parth Shah', + handle: '@Parth20GitHub', + img: 'https://avatars.githubusercontent.com/u/142086512?v=4', + url: 'https://github.com/Parth20GitHub', + }, + { + id: 34, + name: 'Sree Vidya', + handle: '@sreevidya-16', + img: 'https://avatars.githubusercontent.com/u/115856774?v=4', + url: 'https://github.com/sreevidya-16', + }, + { + id: 35, + name: 'Asmita Mishra', + handle: '@AsmitaMishra24', + img: 'https://avatars.githubusercontent.com/u/146121869?v=4', + url: 'https://github.com/AsmitaMishra24', + }, + { + id: 36, + name: 'Kanhaiya Kumar', + handle: '@iamkanhaiyakumar', + img: 'https://avatars.githubusercontent.com/u/120328606?v=4', + url: 'https://github.com/iamkanhaiyakumar', + }, + { + id: 37, + name: 'Revanth', + handle: '@revanth1718', + img: 'https://avatars.githubusercontent.com/u/109272714?v=4', + url: 'https://github.com/revanth1718', + }, + { + id: 38, + name: 'Arunima Dutta', + handle: '@arunimaChintu', + img: 'https://avatars.githubusercontent.com/u/99474881?v=4', + url: 'https://github.com/arunimaChintu', + }, + { + id: 39, + name: 'Maana Ajmera', + handle: '@Maana-Ajmera', + img: 'https://avatars.githubusercontent.com/u/162733812?v=4', + url: 'https://github.com/Maana-Ajmera', + }, + { + id: 40, + name: 'Aditya Narayan', + handle: '@ANKeshri', + img: 'https://avatars.githubusercontent.com/u/159682348?v=4', + url: 'https://github.com/ANKeshri', + }, + { + id: 41, + name: 'Utsav Ladia', + handle: '@Utsavladia', + img: 'https://avatars.githubusercontent.com/u/124615886?v=4', + url: 'https://github.com/Utsavladia', + }, + { + id: 42, + name: 'Nayanika Mukherjee', + handle: '@Nayanika1402', + img: 'https://avatars.githubusercontent.com/u/132455412?v=4', + url: 'https://github.com/Nayanika1402', + }, + { + id: 43, + name: 'Maheshwari Love', + handle: '@Maheshwari-Love', + img: 'https://avatars.githubusercontent.com/u/142833275?v=4', + url: 'https://github.com/Maheshwari-Love', + }, + { + id: 44, + name: 'Pujan Sarkar', + handle: '@Pujan-sarkar', + img: 'https://avatars.githubusercontent.com/u/144250917?v=4', + url: 'https://github.com/Pujan-sarkar', + }, +]; + +const ContributorPage = () => { + const [search, setSearch] = useState(''); + + const openLink = (url: string) => { + Linking.openURL(url); + }; + + // 🔍 Filter contributors + const filteredContributors = useMemo(() => { + if (!search.trim()) return CONTRIBUTORS; + + const q = search.toLowerCase(); + return CONTRIBUTORS.filter( + item => + item.name.toLowerCase().includes(q) || + item.handle.toLowerCase().includes(q), + ); + }, [search]); + + const renderItem = ({item}: {item: any}) => ( + openLink(item.url)} + pressStyle={{opacity: 0.9}}> + + + + + + + + + + + + {item.name} + + + {item.handle} + + + + + + + + ); + + return ( + + + {/* HEADER */} + + +

+ Our Contributors +

+ +
+ + + Thank you for contributing to our repository + + + + + + We appreciate your help in making UltimateHealth better! + + + + + + + + + +
+ + {/* LIST */} + item.id.toString()} + renderItem={renderItem} + contentContainerStyle={{padding: 20}} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + + No contributors found + + + } + /> +
+
+ ); +}; + +export default ContributorPage; diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx index 858a17b5..39d56403 100644 --- a/frontend/src/screens/HomeScreen.tsx +++ b/frontend/src/screens/HomeScreen.tsx @@ -1,1043 +1,1046 @@ -import { - StyleSheet, - View, - Alert, - Text, - TouchableOpacity, - FlatList, - ScrollView, -} from 'react-native'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import { - ON_PRIMARY_COLOR, - PRIMARY_COLOR, - SAVED_CHIP_ACTIVE_BG, - SAVED_CHIP_INACTIVE_BG, - SAVED_CHIP_INACTIVE_BORDER, - EMPTY_STATE_BACKGROUND, - EMPTY_STATE_TEXT_PRIMARY, - EMPTY_STATE_TEXT_SECONDARY, -} from '../helper/Theme'; -import AddIcon from '../components/AddIcon'; -import ArticleCard from '../components/ArticleCard'; - -import HomeScreenHeader from '../components/HomeScreenHeader'; -import {ArticleData, Category, HomeScreenProps} from '../type'; -import FilterModal from '../components/FilterModal'; -import {BottomSheetModal} from '@gorhom/bottom-sheet'; -import {useSelector, useDispatch} from 'react-redux'; -import Loader from '../components/Loader'; -import {usePreferences} from '../contexts/PreferencesContext'; - -import { - setFilteredArticles, - setSearchedArticles, - setSearchMode, - setSelectedTags, - setSortType, - setTags, -} from '../store/dataSlice'; -import Snackbar from 'react-native-snackbar'; -import {useFocusEffect} from '@react-navigation/native'; -import InactiveUserModal from '../components/InactiveUserModal'; -import {StatusBar} from 'expo-status-bar'; -import {wp} from '../helper/Metric'; -import {useGetCategories} from '../hooks/useGetArticleTags'; -import {useGetProfile} from '../hooks/useGetProfile'; -import {useRequestArticleEdit} from '../hooks/useRequestArticleEdit'; -import {useGetUnreadNotificationCount} from '../hooks/useGetUnreadNotificationCount'; -import {useGetPaginatedArticle} from '../hooks/useGetPaginatedArticles'; -import { - OfflineArticleState, - NoArticleState, - BaseEmptyState, -} from '../components/EmptyStates'; - -// Loading State Component with Animation -const LoadingState = () => { - return ( - - ); -}; - -// Error State Component -const ErrorState = ({onRetry}: {onRetry: () => void}) => { - return ( - - ); -}; - -// Offline State Component -const OfflineState = () => { - return ( - - ); -}; - -// Empty Article State Component (for FlatList empty state) -const EmptyArticleState = () => { - return ( - - ); -}; - -const SavedArticleEmptyState = () => ( - - No saved articles yet - - Tap the bookmark icon on any article to save it for later. - - -); - -// Here The purpose of using Redux is to maintain filter state throughout the app session. globally -const HomeScreen = ({navigation}: HomeScreenProps) => { - const dispatch = useDispatch(); - const [articleCategories, setArticleCategories] = useState([]); - const [selectedCategory, setSelectedCategory] = useState(); - const [sortingType, setSortingType] = useState(''); - const {isConnected} = useSelector((state: any) => state.network); - const [selectedCardId, setSelectedCardId] = useState(''); - // const [repostItem, setRepostItem] = useState(null); - const [selectCategoryList, setSelectCategoryList] = useState([]); - const [showSavedOnly, setShowSavedOnly] = useState(false); - const [filterLoading, setFilterLoading] = useState(false); - // Session-level language filter (can override preferences per session) - const [sessionSelectedLanguages, setSessionSelectedLanguages] = useState([]); - const {preferredLanguages, isLoading: preferencesLoading} = usePreferences(); - const {mutate: requestEdit, isPending: requestEditPending} = - useRequestArticleEdit(); - - const { - filteredArticles, - searchedArticles, - searchMode, - selectedTags, - sortType, - } = useSelector((state: any) => state.data); - - const {user_token, isGuest} = useSelector( - (state: any) => state.user, - ); - - const [refreshing, setRefreshing] = useState(false); - const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(0); - // Accumulates all raw articles fetched across pages so we can - // re-apply the active category/sort filter whenever either changes. - const allArticlesRef = useRef([]); - // Tracks the last known filtered count for the active category so we don't - // keep fetching pages when a niche category yields no new articles per page. - const lastCategoryFilteredCountRef = useRef(-1); - const prevSelectedCategoryNameRef = useRef(undefined); - const {data: user, refetch: refetchUser} = useGetProfile(); - const {data: categoryData, isSuccess} = useGetCategories(isConnected); - - useEffect(() => { - if (!isSuccess || !categoryData) return; - - if (!selectedTags || selectedTags.length === 0) { - dispatch( - setSelectedTags({ - selectedTags: categoryData, - }), - ); - setSelectedCategory(categoryData[0]); - } else { - setSelectedCategory(selectedTags[0]); - } - - setArticleCategories(categoryData); - dispatch(setTags({tags: categoryData})); - }, [categoryData, dispatch, isSuccess, selectedTags]); - - const handleCategorySelection = (category: Category) => { - // Update Redux State - setSelectCategoryList(prevList => { - const isAlreadySelected = prevList.some(p => p._id === category._id); - const updatedList = isAlreadySelected - ? prevList.filter(item => item._id !== category._id) - : [...prevList, category]; - return updatedList; - }); - }; - - const bottomSheetModalRef = useRef(null); - const handlePresentModalPress = useCallback(() => { - bottomSheetModalRef.current?.present(); - }, []); - - const {data: unreadCount, refetch: refetchUnreadCount} = - useGetUnreadNotificationCount(isConnected); - - useFocusEffect( - useCallback(() => { - if (isConnected) { - if (user_token && !isGuest) { - refetchUser(); - refetchUnreadCount(); - } - } else { - Alert.alert( - 'No Internet 😶‍🌫️', - 'Offline mode will be available in the next update.', - ); - } - }, [isConnected, user_token, isGuest, refetchUser, refetchUnreadCount]), - ); - - const handleNoteIconClick = () => { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Sign In Required', - description: 'Please sign in or sign up to write an article.', - iconName: 'pen-nib', - }); - return; - } - navigation.navigate('ArticleDescriptionScreen', { - article: null, - htmlContent: undefined, - }); - }; - - /** - * Toggles the "Saved" filter chip. - * When deactivating, resets page to 1 so the main feed - * pagination starts fresh and onEndReached fires correctly. - */ - /** - * Toggles the "Saved" filter chip. - */ - const handleToggleSavedOnly = () => { - setShowSavedOnly(prev => !prev); - }; - - const handleCategoryClick = (category: Category) => { - // Deactivate Saved chip and update the active category. - // We do NOT clear already-fetched raw articles, allowing instant switching - // and seamless client-side filtering. - setShowSavedOnly(false); - // Reset the sparse-category guard so the newly selected category gets a - // fresh auto-pagination opportunity from the current page. - lastCategoryFilteredCountRef.current = -1; - setSelectedCategory(category); - }; - - - const handleReportAction = (item: ArticleData) => { - navigation.navigate('ReportScreen', { - articleId: item._id, - authorId: item.authorId as string, - commentId: null, - podcastId: null, - }); - }; - const renderItem = ({item}: {item: ArticleData}) => { - return ( - {}} - handleReportAction={handleReportAction} - handleEditRequestAction={(item, index, reason) => { - // submitRequest - requestEdit( - { - articleId: item._id, - reason: reason, - articleRecordId: item.pb_recordId, - }, - { - onSuccess: data => { - Snackbar.show({ - text: data, - duration: Snackbar.LENGTH_SHORT, - }); - }, - onError: err => { - if (__DEV__) console.log(err); - Snackbar.show({ - text: 'Try again!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }, - ); - }} - source="home" - /> - ); - }; - - const handleFilterReset = () => { - // Update Redux State Variables - setSelectCategoryList([]); - setSortingType(''); - setSessionSelectedLanguages([]); - dispatch( - setSelectedTags({ - selectedTags: articleCategories, - }), - ); - dispatch(setSortType({sortType: ''})); - dispatch(setFilteredArticles({filteredArticles: allArticlesRef.current})); - }; - - const handleFilterApply = () => { - // Update Redux State Variables - if (selectCategoryList.length > 0) { - // console.log("enter") - dispatch(setSelectedTags({selectedTags: selectCategoryList})); - } else { - //console.log("enter ele", articleCategories); - - dispatch( - setSelectedTags({ - selectedTags: articleCategories, - }), - ); - } - - if (sortingType && sortingType !== '') { - if (__DEV__) console.log('Sort type', sortType); - dispatch(setSortType({sortType: sortingType})); - } - - updateArticles(allArticlesRef.current); - }; - - const updateArticles = (articleData?: ArticleData[]) => { - setFilterLoading(true); - if (!articleData) { - setFilterLoading(false); - return; - } - - let filtered = articleData; - - // Filter by selected tags (categories) - if (selectedTags.length > 0) { - filtered = filtered.filter(article => - selectedTags.some((tag: Category) => - article.tags.some(category => category.name === tag.name), - ), - ); - } - - // Filter by language preference - // Priority: session-selected languages > preferred languages - const effectiveLanguages = sessionSelectedLanguages.length > 0 - ? sessionSelectedLanguages - : preferredLanguages; - - if (effectiveLanguages.length > 0) { - filtered = filtered.filter(article => - effectiveLanguages.includes(article.language || 'en-IN'), - ); - } - - // Apply sorting - if (sortType && sortType === 'recent' && filtered.length > 1) { - filtered = filtered.sort( - (a, b) => - new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), - ); - } else if (sortType && sortType === 'oldest' && filtered.length > 1) { - filtered.sort( - (a, b) => - new Date(a.lastUpdated).getTime() - new Date(b.lastUpdated).getTime(), - ); - } else if (sortType && sortType === 'popular' && filtered.length > 1) { - filtered.sort((a, b) => b.viewCount - a.viewCount); - } - dispatch(setFilteredArticles({filteredArticles: filtered})); - setFilterLoading(false); - }; - - const { - data: articleData, - isLoading, - isFetching, - isError, - refetch, - } = useGetPaginatedArticle(isConnected, page); - - useEffect(() => { - if (articleData) { - if (Number(page) === 1) { - // Fresh load: replace accumulated articles entirely. - allArticlesRef.current = articleData.articles ?? []; - if (articleData.totalPages) { - setTotalPages(articleData.totalPages); - } - } else { - // Append new page's articles to the accumulated set. - allArticlesRef.current = [ - ...allArticlesRef.current, - ...(articleData.articles ?? []), - ]; - } - // Always re-apply the current filter/sort to the full accumulated list. - updateArticles(allArticlesRef.current); - } - }, [articleData, page]); - - // Keep filteredArticles and articles list updated when selectedTags, sortType, or languages change - useEffect(() => { - updateArticles(allArticlesRef.current); - }, [selectedTags, sortType, sessionSelectedLanguages, preferredLanguages]); - - // Proactively auto-paginate in the background if the client-filtered list is too short - // to ensure that at least a few articles of the selected category are shown - // or we have exhaustively searched all pages from the backend. - useEffect(() => { - if ( - showSavedOnly || - searchMode || - isLoading || - isFetching || - page >= totalPages || - !selectedCategory - ) { - // Reset stale counter whenever the category changes or we stop paginating. - if (!selectedCategory || selectedCategory.name !== prevSelectedCategoryNameRef.current) lastCategoryFilteredCountRef.current = -1; - return; - } - - const currentFiltered = allArticlesRef.current.filter( - (article: ArticleData) => - article.tags && - article.tags.some(tag => tag.name === selectedCategory.name), - ); - - // If we have fewer than 5 articles for the active category, - // fetch the next page in the background to try and find more matches — - // but only if the last fetch actually added new articles for this category. - // This prevents infinite fetching when a category is genuinely sparse. - if ( - currentFiltered.length < 5 && - currentFiltered.length > lastCategoryFilteredCountRef.current - ) { - prevSelectedCategoryNameRef.current = selectedCategory.name; - lastCategoryFilteredCountRef.current = currentFiltered.length; - setPage(prev => prev + 1); - } - }, [ - showSavedOnly, - searchMode, - isLoading, - isFetching, - page, - totalPages, - selectedCategory, - ]); - - - const onRefresh = () => { - if (isConnected) { - setRefreshing(true); - allArticlesRef.current = []; - lastCategoryFilteredCountRef.current = -1; - setPage(1); - refetch(); - if (!isGuest) { - refetchUser(); - refetchUnreadCount(); - } - } else { - Snackbar.show({ - text: 'Please check your network connection', - duration: Snackbar.LENGTH_SHORT, - }); - } - }; - - useEffect(() => { - if (refreshing && !isFetching) { - setRefreshing(false); - } - }, [isFetching, refreshing]); - - const handleSearch = (textInput: string) => { - //console.log('Search Input', textInput); - if (textInput === '' || articleData === undefined) { - dispatch(setSearchedArticles({searchedArticles: []})); - dispatch(setSearchMode({searchMode: false})); - } else { - dispatch(setSearchMode({searchMode: true})); - const matchesSearch = articleData?.articles.filter(article => { - const matchesTitle = article.title && typeof article.title === 'string' - ? article.title.toLowerCase().includes((textInput || '').toLowerCase()) - : false; - const matchesTags = article.tags && Array.isArray(article.tags) - ? article.tags.some(tag => - tag && tag.name && typeof tag.name === 'string' && - tag.name.toLowerCase().includes((textInput || '').toLowerCase()) - ) - : false; - - return matchesTitle || matchesTags; - }); - dispatch(setSearchedArticles({searchedArticles: matchesSearch})); - } - }; - - const listData = useMemo(() => { - if (showSavedOnly) { - const savedArticles = user?.savedArticles || []; - return savedArticles - .slice() - .sort( - (a, b) => - new Date(b.lastUpdated).getTime() - - new Date(a.lastUpdated).getTime(), - ); - } - if (searchMode) return searchedArticles; - - const filtered = filteredArticles.filter( - (article: ArticleData) => - article.tags && - article.tags.some(tag => tag.name === selectedCategory?.name), - ); - - return filtered; - }, [showSavedOnly, searchMode, searchedArticles, filteredArticles, selectedCategory, user]); - - // Check if any filters are active - const hasActiveFilters = useMemo(() => { - const hasCustomCategories = selectedTags.length > 0 && selectedTags.length < articleCategories.length; - const hasSorting = sortType !== ''; - return hasCustomCategories || hasSorting; - }, [selectedTags, sortType, articleCategories]); - - // Quick reset handler for header - const handleQuickReset = () => { - handleFilterReset(); - }; - - if (!articleData || articleData.articles?.length === 0) { - return ( - - { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Notifications', - description: 'Sign in to see your notifications.', - iconName: 'bell', - }); - } else { - navigation.navigate('NotificationScreen'); - } - }} - unreadCount={unreadCount || 0} - hasActiveFilters={hasActiveFilters} - onFilterReset={handleQuickReset} - /> - - - - ); - } - - if (isError) { - return ( - - { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Notifications', - description: 'Sign in to see your notifications.', - iconName: 'bell', - }); - } else { - navigation.navigate('NotificationScreen'); - } - }} - unreadCount={unreadCount || 0} - hasActiveFilters={hasActiveFilters} - onFilterReset={handleQuickReset} - /> - - - - ); - } - - if (isConnected === false) { - return ( - - { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Notifications', - description: 'Sign in to see your notifications.', - iconName: 'bell', - }); - } else { - navigation.navigate('NotificationScreen'); - } - }} - unreadCount={unreadCount || 0} - hasActiveFilters={hasActiveFilters} - onFilterReset={handleQuickReset} - /> - - - - ); - } - - if (isLoading || requestEditPending) { - return ; - } - - if (user && (user.isBlockUser || user.isBannedUser)) { - return ( - - - { - navigation.navigate('NotificationScreen'); - }} - unreadCount={unreadCount ? unreadCount : 0} - hasActiveFilters={hasActiveFilters} - onFilterReset={handleQuickReset} - /> - - - - {selectedTags && - selectedTags.length > 0 && - !searchMode && - selectedTags.map((item: Category, index: number) => { - const isActive = selectedCategory && (selectedCategory._id === item._id || selectedCategory.id === item.id || selectedCategory.name === item.name); - return ( - handleCategoryClick(item)}> - - {item.name} - - - ); - })} - - - - { - //navigation.navigate('ContactAdminScreen'); - }} - reason={ - user.isBlockUser - ? 'blocked' - : user.isBannedUser - ? 'banned' - : undefined - } - /> - - ); - } - - return ( - - { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Notifications', - description: 'Sign in to see your notifications.', - iconName: 'bell', - }); - } else { - navigation.navigate('NotificationScreen'); - } - }} - unreadCount={unreadCount ? unreadCount : 0} - hasActiveFilters={hasActiveFilters} - onFilterReset={handleQuickReset} - /> - - - - {!isGuest && ( - - - Saved - - - )} - {selectedTags && - selectedTags.length > 0 && - !searchMode && - selectedTags.map((item: Category, index: number) => { - // Category chips visually appear inactive when Saved filter is on — - // they are still clickable to switch away from Saved mode. - const isActive = !showSavedOnly && - selectedCategory && - (selectedCategory._id === item._id || selectedCategory.id === item.id || selectedCategory.name === item.name); - return ( - handleCategoryClick(item)} - accessibilityRole="button" - accessibilityLabel={`Filter by ${item.name}${ - isActive ? ', currently active' : '' - }`}> - - {item.name} - - - ); - })} - - - - item._id.toString()} - contentContainerStyle={styles.flatListContentContainer} - refreshing={refreshing} - onRefresh={onRefresh} - ListEmptyComponent={ - showSavedOnly ? : - } - onEndReached={() => { - // Only paginate the main feed — saved articles are a - // finite local list and do not use server-side pagination. - if (!showSavedOnly && page < totalPages) { - setPage(prev => prev + 1); - } - }} - onEndReachedThreshold={0.5} - /> - - - - - - - ); -}; - -export default HomeScreen; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#F0F8FF', - justifyContent: 'flex-start', - alignItems: 'stretch', - }, - - blockContainer: { - flex: 0, - backgroundColor: '#F0F8FF', - justifyContent: 'center', - //alignItems: 'center', - }, - buttonContainer: { - marginTop: wp(3), - flexDirection: 'row', - paddingHorizontal: 6, - }, - button: { - flex: 0, - borderRadius: wp(4), - marginHorizontal: 6, - marginVertical: 4, - padding: wp(3.1), - borderWidth: 1, - justifyContent: 'center', - alignItems: 'center', - }, - labelStyle: { - fontWeight: 'bold', - fontSize: 15, - textTransform: 'capitalize', - }, - articleContainer: { - flex: 1, - width: '100%', - paddingHorizontal: 0, - zIndex: -2, - }, - flatListContentContainer: { - flexGrow: 1, // Allows empty-state components to fill available height - paddingHorizontal: 16, - marginTop: 10, - paddingBottom: 120, - }, - homePlusIconview: { - bottom: 100, - right: 25, - position: 'absolute', - zIndex: -2, - }, - - message: { - fontSize: 16, - color: '#000', - fontFamily: 'bold', - textAlign: 'center', - }, - emptyContainer: { - flex: 1, - backgroundColor: ON_PRIMARY_COLOR, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - top: 30, - }, - emptyImgStyle: { - width: 100, - height: 200, - borderRadius: 8, - marginBottom: 1, - resizeMode: 'contain', - }, - - // New state styles - stateContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 32, - backgroundColor: '#F0F8FF', - }, - iconCircle: { - width: 120, - height: 120, - borderRadius: 60, - backgroundColor: '#E3F2FD', - justifyContent: 'center', - alignItems: 'center', - marginBottom: 24, - shadowColor: PRIMARY_COLOR, - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.15, - shadowRadius: 12, - elevation: 8, - }, - errorCircle: { - backgroundColor: '#FFEBEE', - }, - offlineCircle: { - backgroundColor: '#FFF3E0', - }, - iconEmoji: { - fontSize: 56, - }, - stateTitle: { - fontSize: 24, - fontWeight: 'bold', - color: '#1A1A1A', - marginBottom: 12, - textAlign: 'center', - }, - stateDescription: { - fontSize: 16, - color: '#666', - textAlign: 'center', - lineHeight: 24, - marginBottom: 24, - paddingHorizontal: 16, - }, - dotsContainer: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - marginTop: 16, - }, - dot: { - width: 12, - height: 12, - borderRadius: 6, - backgroundColor: PRIMARY_COLOR, - marginHorizontal: 6, - }, - retryButton: { - backgroundColor: PRIMARY_COLOR, - paddingHorizontal: 32, - paddingVertical: 14, - borderRadius: 25, - marginTop: 8, - shadowColor: PRIMARY_COLOR, - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 6, - }, - retryButtonText: { - color: 'white', - fontSize: 16, - fontWeight: 'bold', - textAlign: 'center', - }, - - // Empty Article State styles (for FlatList empty state) - emptyArticleContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingVertical: 60, - paddingHorizontal: 24, - // minHeight removed: flex:1 + flexGrow:1 on contentContainerStyle fills space - }, - emptyIconCircle: { - width: 100, - height: 100, - borderRadius: 50, - backgroundColor: '#F3E5F5', - justifyContent: 'center', - alignItems: 'center', - marginBottom: 20, - shadowColor: '#9C27B0', - shadowOffset: {width: 0, height: 3}, - shadowOpacity: 0.12, - shadowRadius: 10, - elevation: 5, - }, - emptyIconEmoji: { - fontSize: 48, - }, - emptyArticleTitle: { - fontSize: 22, - fontWeight: 'bold', - color: EMPTY_STATE_TEXT_PRIMARY, - marginBottom: 10, - textAlign: 'center', - }, - emptyArticleDescription: { - fontSize: 15, - color: EMPTY_STATE_TEXT_SECONDARY, - textAlign: 'center', - lineHeight: 22, - marginBottom: 20, - }, - emptyTagsContainer: { - flexDirection: 'row', - justifyContent: 'center', - marginTop: 8, - }, - emptyTag: { - backgroundColor: '#E8EAF6', - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - borderWidth: 1, - borderColor: '#C5CAE9', - }, - emptyTagText: { - color: '#3F51B5', - fontSize: 14, - fontWeight: '600', - }, - savedEmptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingVertical: 60, - paddingHorizontal: 24, - // minHeight removed: flex:1 + flexGrow:1 on contentContainerStyle fills space - backgroundColor: EMPTY_STATE_BACKGROUND, - }, - savedEmptyTitle: { - fontSize: 22, - fontWeight: 'bold', - color: EMPTY_STATE_TEXT_PRIMARY, - marginBottom: 10, - textAlign: 'center', - }, - savedEmptyDescription: { - fontSize: 15, - color: EMPTY_STATE_TEXT_SECONDARY, - textAlign: 'center', - lineHeight: 22, - marginTop: 8, - }, -}); +import { + StyleSheet, + View, + Alert, + Text, + TouchableOpacity, + FlatList, + ScrollView, +} from 'react-native'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import { + ON_PRIMARY_COLOR, + PRIMARY_COLOR, + SAVED_CHIP_ACTIVE_BG, + SAVED_CHIP_INACTIVE_BG, + SAVED_CHIP_INACTIVE_BORDER, + EMPTY_STATE_BACKGROUND, + EMPTY_STATE_TEXT_PRIMARY, + EMPTY_STATE_TEXT_SECONDARY, +} from '../helper/Theme'; +import AddIcon from '../components/AddIcon'; +import ArticleCard from '../components/ArticleCard'; + +import HomeScreenHeader from '../components/HomeScreenHeader'; +import {ArticleData, Category, HomeScreenProps} from '../type'; +import FilterModal from '../components/FilterModal'; +import {BottomSheetModal} from '@gorhom/bottom-sheet'; +import {useSelector, useDispatch} from 'react-redux'; +import Loader from '../components/Loader'; +import {usePreferences} from '../contexts/PreferencesContext'; + +import { + setFilteredArticles, + setSearchedArticles, + setSearchMode, + setSelectedTags, + setSortType, + setTags, +} from '../store/dataSlice'; +import Snackbar from 'react-native-snackbar'; +import {useFocusEffect} from '@react-navigation/native'; +import InactiveUserModal from '../components/InactiveUserModal'; +import {StatusBar} from 'expo-status-bar'; +import {wp} from '../helper/Metric'; +import {useGetCategories} from '../hooks/useGetArticleTags'; +import {useGetProfile} from '../hooks/useGetProfile'; +import {useRequestArticleEdit} from '../hooks/useRequestArticleEdit'; +import {useGetUnreadNotificationCount} from '../hooks/useGetUnreadNotificationCount'; +import {useGetPaginatedArticle} from '../hooks/useGetPaginatedArticles'; +import { import { rf } from '../helper/Metric'; + + OfflineArticleState, + NoArticleState, + BaseEmptyState, +} from '../components/EmptyStates'; + +// Loading State Component with Animation +const LoadingState = () => { + return ( + + ); +}; + +// Error State Component +const ErrorState = ({onRetry}: {onRetry: () => void}) => { + return ( + + ); +}; + +// Offline State Component +const OfflineState = () => { + return ( + + ); +}; + +// Empty Article State Component (for FlatList empty state) +const EmptyArticleState = () => { + return ( + + ); +}; + +const SavedArticleEmptyState = () => ( + + No saved articles yet + + Tap the bookmark icon on any article to save it for later. + + +); + +// Here The purpose of using Redux is to maintain filter state throughout the app session. globally +const HomeScreen = ({navigation}: HomeScreenProps) => { + const dispatch = useDispatch(); + const [articleCategories, setArticleCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(); + const [sortingType, setSortingType] = useState(''); + const {isConnected} = useSelector((state: any) => state.network); + const [selectedCardId, setSelectedCardId] = useState(''); + // const [repostItem, setRepostItem] = useState(null); + const [selectCategoryList, setSelectCategoryList] = useState([]); + const [showSavedOnly, setShowSavedOnly] = useState(false); + const [filterLoading, setFilterLoading] = useState(false); + // Session-level language filter (can override preferences per session) + const [sessionSelectedLanguages, setSessionSelectedLanguages] = useState([]); + const {preferredLanguages, isLoading: preferencesLoading} = usePreferences(); + const {mutate: requestEdit, isPending: requestEditPending} = + useRequestArticleEdit(); + + const { + filteredArticles, + searchedArticles, + searchMode, + selectedTags, + sortType, + } = useSelector((state: any) => state.data); + + const {user_token, isGuest} = useSelector( + (state: any) => state.user, + ); + + const [refreshing, setRefreshing] = useState(false); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + // Accumulates all raw articles fetched across pages so we can + // re-apply the active category/sort filter whenever either changes. + const allArticlesRef = useRef([]); + // Tracks the last known filtered count for the active category so we don't + // keep fetching pages when a niche category yields no new articles per page. + const lastCategoryFilteredCountRef = useRef(-1); + const prevSelectedCategoryNameRef = useRef(undefined); + const {data: user, refetch: refetchUser} = useGetProfile(); + const {data: categoryData, isSuccess} = useGetCategories(isConnected); + + useEffect(() => { + if (!isSuccess || !categoryData) return; + + if (!selectedTags || selectedTags.length === 0) { + dispatch( + setSelectedTags({ + selectedTags: categoryData, + }), + ); + setSelectedCategory(categoryData[0]); + } else { + setSelectedCategory(selectedTags[0]); + } + + setArticleCategories(categoryData); + dispatch(setTags({tags: categoryData})); + }, [categoryData, dispatch, isSuccess, selectedTags]); + + const handleCategorySelection = (category: Category) => { + // Update Redux State + setSelectCategoryList(prevList => { + const isAlreadySelected = prevList.some(p => p._id === category._id); + const updatedList = isAlreadySelected + ? prevList.filter(item => item._id !== category._id) + : [...prevList, category]; + return updatedList; + }); + }; + + const bottomSheetModalRef = useRef(null); + const handlePresentModalPress = useCallback(() => { + bottomSheetModalRef.current?.present(); + }, []); + + const {data: unreadCount, refetch: refetchUnreadCount} = + useGetUnreadNotificationCount(isConnected); + + useFocusEffect( + useCallback(() => { + if (isConnected) { + if (user_token && !isGuest) { + refetchUser(); + refetchUnreadCount(); + } + } else { + Alert.alert( + 'No Internet 😶‍🌫️', + 'Offline mode will be available in the next update.', + ); + } + }, [isConnected, user_token, isGuest, refetchUser, refetchUnreadCount]), + ); + + const handleNoteIconClick = () => { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: 'Please sign in or sign up to write an article.', + iconName: 'pen-nib', + }); + return; + } + navigation.navigate('ArticleDescriptionScreen', { + article: null, + htmlContent: undefined, + }); + }; + + /** + * Toggles the "Saved" filter chip. + * When deactivating, resets page to 1 so the main feed + * pagination starts fresh and onEndReached fires correctly. + */ + /** + * Toggles the "Saved" filter chip. + */ + const handleToggleSavedOnly = () => { + setShowSavedOnly(prev => !prev); + }; + + const handleCategoryClick = (category: Category) => { + // Deactivate Saved chip and update the active category. + // We do NOT clear already-fetched raw articles, allowing instant switching + // and seamless client-side filtering. + setShowSavedOnly(false); + // Reset the sparse-category guard so the newly selected category gets a + // fresh auto-pagination opportunity from the current page. + lastCategoryFilteredCountRef.current = -1; + setSelectedCategory(category); + }; + + + const handleReportAction = (item: ArticleData) => { + navigation.navigate('ReportScreen', { + articleId: item._id, + authorId: item.authorId as string, + commentId: null, + podcastId: null, + }); + }; + const renderItem = ({item}: {item: ArticleData}) => { + return ( + {}} + handleReportAction={handleReportAction} + handleEditRequestAction={(item, index, reason) => { + // submitRequest + requestEdit( + { + articleId: item._id, + reason: reason, + articleRecordId: item.pb_recordId, + }, + { + onSuccess: data => { + Snackbar.show({ + text: data, + duration: Snackbar.LENGTH_SHORT, + }); + }, + onError: err => { + if (__DEV__) console.log(err); + Snackbar.show({ + text: 'Try again!', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }, + ); + }} + source="home" + /> + ); + }; + + const handleFilterReset = () => { + // Update Redux State Variables + setSelectCategoryList([]); + setSortingType(''); + setSessionSelectedLanguages([]); + dispatch( + setSelectedTags({ + selectedTags: articleCategories, + }), + ); + dispatch(setSortType({sortType: ''})); + dispatch(setFilteredArticles({filteredArticles: allArticlesRef.current})); + }; + + const handleFilterApply = () => { + // Update Redux State Variables + if (selectCategoryList.length > 0) { + // console.log("enter") + dispatch(setSelectedTags({selectedTags: selectCategoryList})); + } else { + //console.log("enter ele", articleCategories); + + dispatch( + setSelectedTags({ + selectedTags: articleCategories, + }), + ); + } + + if (sortingType && sortingType !== '') { + if (__DEV__) console.log('Sort type', sortType); + dispatch(setSortType({sortType: sortingType})); + } + + updateArticles(allArticlesRef.current); + }; + + const updateArticles = (articleData?: ArticleData[]) => { + setFilterLoading(true); + if (!articleData) { + setFilterLoading(false); + return; + } + + let filtered = articleData; + + // Filter by selected tags (categories) + if (selectedTags.length > 0) { + filtered = filtered.filter(article => + selectedTags.some((tag: Category) => + article.tags.some(category => category.name === tag.name), + ), + ); + } + + // Filter by language preference + // Priority: session-selected languages > preferred languages + const effectiveLanguages = sessionSelectedLanguages.length > 0 + ? sessionSelectedLanguages + : preferredLanguages; + + if (effectiveLanguages.length > 0) { + filtered = filtered.filter(article => + effectiveLanguages.includes(article.language || 'en-IN'), + ); + } + + // Apply sorting + if (sortType && sortType === 'recent' && filtered.length > 1) { + filtered = filtered.sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); + } else if (sortType && sortType === 'oldest' && filtered.length > 1) { + filtered.sort( + (a, b) => + new Date(a.lastUpdated).getTime() - new Date(b.lastUpdated).getTime(), + ); + } else if (sortType && sortType === 'popular' && filtered.length > 1) { + filtered.sort((a, b) => b.viewCount - a.viewCount); + } + dispatch(setFilteredArticles({filteredArticles: filtered})); + setFilterLoading(false); + }; + + const { + data: articleData, + isLoading, + isFetching, + isError, + refetch, + } = useGetPaginatedArticle(isConnected, page); + + useEffect(() => { + if (articleData) { + if (Number(page) === 1) { + // Fresh load: replace accumulated articles entirely. + allArticlesRef.current = articleData.articles ?? []; + if (articleData.totalPages) { + setTotalPages(articleData.totalPages); + } + } else { + // Append new page's articles to the accumulated set. + allArticlesRef.current = [ + ...allArticlesRef.current, + ...(articleData.articles ?? []), + ]; + } + // Always re-apply the current filter/sort to the full accumulated list. + updateArticles(allArticlesRef.current); + } + }, [articleData, page]); + + // Keep filteredArticles and articles list updated when selectedTags, sortType, or languages change + useEffect(() => { + updateArticles(allArticlesRef.current); + }, [selectedTags, sortType, sessionSelectedLanguages, preferredLanguages]); + + // Proactively auto-paginate in the background if the client-filtered list is too short + // to ensure that at least a few articles of the selected category are shown + // or we have exhaustively searched all pages from the backend. + useEffect(() => { + if ( + showSavedOnly || + searchMode || + isLoading || + isFetching || + page >= totalPages || + !selectedCategory + ) { + // Reset stale counter whenever the category changes or we stop paginating. + if (!selectedCategory || selectedCategory.name !== prevSelectedCategoryNameRef.current) lastCategoryFilteredCountRef.current = -1; + return; + } + + const currentFiltered = allArticlesRef.current.filter( + (article: ArticleData) => + article.tags && + article.tags.some(tag => tag.name === selectedCategory.name), + ); + + // If we have fewer than 5 articles for the active category, + // fetch the next page in the background to try and find more matches — + // but only if the last fetch actually added new articles for this category. + // This prevents infinite fetching when a category is genuinely sparse. + if ( + currentFiltered.length < 5 && + currentFiltered.length > lastCategoryFilteredCountRef.current + ) { + prevSelectedCategoryNameRef.current = selectedCategory.name; + lastCategoryFilteredCountRef.current = currentFiltered.length; + setPage(prev => prev + 1); + } + }, [ + showSavedOnly, + searchMode, + isLoading, + isFetching, + page, + totalPages, + selectedCategory, + ]); + + + const onRefresh = () => { + if (isConnected) { + setRefreshing(true); + allArticlesRef.current = []; + lastCategoryFilteredCountRef.current = -1; + setPage(1); + refetch(); + if (!isGuest) { + refetchUser(); + refetchUnreadCount(); + } + } else { + Snackbar.show({ + text: 'Please check your network connection', + duration: Snackbar.LENGTH_SHORT, + }); + } + }; + + useEffect(() => { + if (refreshing && !isFetching) { + setRefreshing(false); + } + }, [isFetching, refreshing]); + + const handleSearch = (textInput: string) => { + //console.log('Search Input', textInput); + if (textInput === '' || articleData === undefined) { + dispatch(setSearchedArticles({searchedArticles: []})); + dispatch(setSearchMode({searchMode: false})); + } else { + dispatch(setSearchMode({searchMode: true})); + const matchesSearch = articleData?.articles.filter(article => { + const matchesTitle = article.title && typeof article.title === 'string' + ? article.title.toLowerCase().includes((textInput || '').toLowerCase()) + : false; + const matchesTags = article.tags && Array.isArray(article.tags) + ? article.tags.some(tag => + tag && tag.name && typeof tag.name === 'string' && + tag.name.toLowerCase().includes((textInput || '').toLowerCase()) + ) + : false; + + return matchesTitle || matchesTags; + }); + dispatch(setSearchedArticles({searchedArticles: matchesSearch})); + } + }; + + const listData = useMemo(() => { + if (showSavedOnly) { + const savedArticles = user?.savedArticles || []; + return savedArticles + .slice() + .sort( + (a, b) => + new Date(b.lastUpdated).getTime() - + new Date(a.lastUpdated).getTime(), + ); + } + if (searchMode) return searchedArticles; + + const filtered = filteredArticles.filter( + (article: ArticleData) => + article.tags && + article.tags.some(tag => tag.name === selectedCategory?.name), + ); + + return filtered; + }, [showSavedOnly, searchMode, searchedArticles, filteredArticles, selectedCategory, user]); + + // Check if any filters are active + const hasActiveFilters = useMemo(() => { + const hasCustomCategories = selectedTags.length > 0 && selectedTags.length < articleCategories.length; + const hasSorting = sortType !== ''; + return hasCustomCategories || hasSorting; + }, [selectedTags, sortType, articleCategories]); + + // Quick reset handler for header + const handleQuickReset = () => { + handleFilterReset(); + }; + + if (!articleData || articleData.articles?.length === 0) { + return ( + + { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Notifications', + description: 'Sign in to see your notifications.', + iconName: 'bell', + }); + } else { + navigation.navigate('NotificationScreen'); + } + }} + unreadCount={unreadCount || 0} + hasActiveFilters={hasActiveFilters} + onFilterReset={handleQuickReset} + /> + + + + ); + } + + if (isError) { + return ( + + { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Notifications', + description: 'Sign in to see your notifications.', + iconName: 'bell', + }); + } else { + navigation.navigate('NotificationScreen'); + } + }} + unreadCount={unreadCount || 0} + hasActiveFilters={hasActiveFilters} + onFilterReset={handleQuickReset} + /> + + + + ); + } + + if (isConnected === false) { + return ( + + { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Notifications', + description: 'Sign in to see your notifications.', + iconName: 'bell', + }); + } else { + navigation.navigate('NotificationScreen'); + } + }} + unreadCount={unreadCount || 0} + hasActiveFilters={hasActiveFilters} + onFilterReset={handleQuickReset} + /> + + + + ); + } + + if (isLoading || requestEditPending) { + return ; + } + + if (user && (user.isBlockUser || user.isBannedUser)) { + return ( + + + { + navigation.navigate('NotificationScreen'); + }} + unreadCount={unreadCount ? unreadCount : 0} + hasActiveFilters={hasActiveFilters} + onFilterReset={handleQuickReset} + /> + + + + {selectedTags && + selectedTags.length > 0 && + !searchMode && + selectedTags.map((item: Category, index: number) => { + const isActive = selectedCategory && (selectedCategory._id === item._id || selectedCategory.id === item.id || selectedCategory.name === item.name); + return ( + handleCategoryClick(item)}> + + {item.name} + + + ); + })} + + + + { + //navigation.navigate('ContactAdminScreen'); + }} + reason={ + user.isBlockUser + ? 'blocked' + : user.isBannedUser + ? 'banned' + : undefined + } + /> + + ); + } + + return ( + + { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Notifications', + description: 'Sign in to see your notifications.', + iconName: 'bell', + }); + } else { + navigation.navigate('NotificationScreen'); + } + }} + unreadCount={unreadCount ? unreadCount : 0} + hasActiveFilters={hasActiveFilters} + onFilterReset={handleQuickReset} + /> + + + + {!isGuest && ( + + + Saved + + + )} + {selectedTags && + selectedTags.length > 0 && + !searchMode && + selectedTags.map((item: Category, index: number) => { + // Category chips visually appear inactive when Saved filter is on — + // they are still clickable to switch away from Saved mode. + const isActive = !showSavedOnly && + selectedCategory && + (selectedCategory._id === item._id || selectedCategory.id === item.id || selectedCategory.name === item.name); + return ( + handleCategoryClick(item)} + accessibilityRole="button" + accessibilityLabel={`Filter by ${item.name}${ + isActive ? ', currently active' : '' + }`}> + + {item.name} + + + ); + })} + + + + item._id.toString()} + contentContainerStyle={styles.flatListContentContainer} + refreshing={refreshing} + onRefresh={onRefresh} + ListEmptyComponent={ + showSavedOnly ? : + } + onEndReached={() => { + // Only paginate the main feed — saved articles are a + // finite local list and do not use server-side pagination. + if (!showSavedOnly && page < totalPages) { + setPage(prev => prev + 1); + } + }} + onEndReachedThreshold={0.5} + /> + + + + + + + ); +}; + +export default HomeScreen; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F0F8FF', + justifyContent: 'flex-start', + alignItems: 'stretch', + }, + + blockContainer: { + flex: 0, + backgroundColor: '#F0F8FF', + justifyContent: 'center', + //alignItems: 'center', + }, + buttonContainer: { + marginTop: wp(3), + flexDirection: 'row', + paddingHorizontal: 6, + }, + button: { + flex: 0, + borderRadius: wp(4), + marginHorizontal: 6, + marginVertical: 4, + padding: wp(3.1), + borderWidth: 1, + justifyContent: 'center', + alignItems: 'center', + minHeight: 44, + minWidth: 44, + }, + labelStyle: { + fontWeight: 'bold', + fontSize: rf(15), + textTransform: 'capitalize', + }, + articleContainer: { + flex: 1, + width: '100%', + paddingHorizontal: 0, + zIndex: -2, + }, + flatListContentContainer: { + flexGrow: 1, // Allows empty-state components to fill available height + paddingHorizontal: 16, + marginTop: 10, + paddingBottom: 120, + }, + homePlusIconview: { + bottom: 100, + right: 25, + position: 'absolute', + zIndex: -2, + }, + + message: { + fontSize: rf(16), + color: '#000', + fontFamily: 'bold', + textAlign: 'center', + }, + emptyContainer: { + flex: 1, + backgroundColor: ON_PRIMARY_COLOR, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + top: 30, + }, + emptyImgStyle: { + width: 100, + height: 200, + borderRadius: 8, + marginBottom: 1, + resizeMode: 'contain', + }, + + // New state styles + stateContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + backgroundColor: '#F0F8FF', + }, + iconCircle: { + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: '#E3F2FD', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 24, + shadowColor: PRIMARY_COLOR, + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 8, + }, + errorCircle: { + backgroundColor: '#FFEBEE', + }, + offlineCircle: { + backgroundColor: '#FFF3E0', + }, + iconEmoji: { + fontSize: rf(56), + }, + stateTitle: { + fontSize: rf(24), + fontWeight: 'bold', + color: '#1A1A1A', + marginBottom: 12, + textAlign: 'center', + }, + stateDescription: { + fontSize: rf(16), + color: '#666', + textAlign: 'center', + lineHeight: 24, + marginBottom: 24, + paddingHorizontal: 16, + }, + dotsContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginTop: 16, + }, + dot: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: PRIMARY_COLOR, + marginHorizontal: 6, + }, + retryButton: { + backgroundColor: PRIMARY_COLOR, + paddingHorizontal: 32, + paddingVertical: 14, + borderRadius: 25, + marginTop: 8, + shadowColor: PRIMARY_COLOR, + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, + }, + retryButtonText: { + color: 'white', + fontSize: rf(16), + fontWeight: 'bold', + textAlign: 'center', + }, + + // Empty Article State styles (for FlatList empty state) + emptyArticleContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 60, + paddingHorizontal: 24, + // minHeight removed: flex:1 + flexGrow:1 on contentContainerStyle fills space + }, + emptyIconCircle: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: '#F3E5F5', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 20, + shadowColor: '#9C27B0', + shadowOffset: {width: 0, height: 3}, + shadowOpacity: 0.12, + shadowRadius: 10, + elevation: 5, + }, + emptyIconEmoji: { + fontSize: rf(48), + }, + emptyArticleTitle: { + fontSize: rf(22), + fontWeight: 'bold', + color: EMPTY_STATE_TEXT_PRIMARY, + marginBottom: 10, + textAlign: 'center', + }, + emptyArticleDescription: { + fontSize: rf(15), + color: EMPTY_STATE_TEXT_SECONDARY, + textAlign: 'center', + lineHeight: 22, + marginBottom: 20, + }, + emptyTagsContainer: { + flexDirection: 'row', + justifyContent: 'center', + marginTop: 8, + }, + emptyTag: { + backgroundColor: '#E8EAF6', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + borderWidth: 1, + borderColor: '#C5CAE9', + }, + emptyTagText: { + color: '#3F51B5', + fontSize: rf(14), + fontWeight: '600', + }, + savedEmptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 60, + paddingHorizontal: 24, + // minHeight removed: flex:1 + flexGrow:1 on contentContainerStyle fills space + backgroundColor: EMPTY_STATE_BACKGROUND, + }, + savedEmptyTitle: { + fontSize: rf(22), + fontWeight: 'bold', + color: EMPTY_STATE_TEXT_PRIMARY, + marginBottom: 10, + textAlign: 'center', + }, + savedEmptyDescription: { + fontSize: rf(15), + color: EMPTY_STATE_TEXT_SECONDARY, + textAlign: 'center', + lineHeight: 22, + marginTop: 8, + }, +}); diff --git a/frontend/src/screens/NotificationPreferencesScreen.tsx b/frontend/src/screens/NotificationPreferencesScreen.tsx index ddf3acb8..be190fb6 100644 --- a/frontend/src/screens/NotificationPreferencesScreen.tsx +++ b/frontend/src/screens/NotificationPreferencesScreen.tsx @@ -1,366 +1,367 @@ -import React, {useEffect, useState} from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - ScrollView, - Alert, -} from 'react-native'; -import {useQueryClient} from '@tanstack/react-query'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import Snackbar from 'react-native-snackbar'; -import {useSelector} from 'react-redux'; -import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; -import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; -import {fp, hp, wp} from '../helper/Metric'; -import {Category, NotificationPreferencesScreenProp} from '../type'; -import {useGetCategories} from '../hooks/useGetArticleTags'; -import {useGetNotificationPreferences} from '../hooks/useGetNotificationPreferences'; -import {useUpdateNotificationPreferences} from '../hooks/useUpdateNotificationPreferences'; -import LoadingSpinner from '../components/LoadingSpinner'; +import React, {useEffect, useState} from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ScrollView, + Alert, +} from 'react-native'; +import {useQueryClient} from '@tanstack/react-query'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import Snackbar from 'react-native-snackbar'; +import {useSelector} from 'react-redux'; +import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; +import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; +import {fp, hp, wp} from '../helper/Metric'; +import {Category, NotificationPreferencesScreenProp} from '../type'; +import {useGetCategories} from '../hooks/useGetArticleTags'; +import {useGetNotificationPreferences} from '../hooks/useGetNotificationPreferences'; +import {useUpdateNotificationPreferences} from '../hooks/useUpdateNotificationPreferences'; +import LoadingSpinner from '../components/LoadingSpinner'; import { rf } from '../helper/Metric'; -const NotificationPreferencesScreen = ({ - navigation, -}: NotificationPreferencesScreenProp) => { - const queryClient = useQueryClient(); - const {isConnected} = useSelector((state: any) => state.network); - const {isGuest} = useSelector((state: any) => state.user); - const [selectedIds, setSelectedIds] = useState([]); - - // Fetch all article tags - const {data: categories, isLoading: tagsLoading} = - useGetCategories(isConnected); - - // Fetch the user's saved preferences - const {data: preferencesData, isLoading: prefsLoading} = - useGetNotificationPreferences(isConnected); - - console.log("Preference data", preferencesData); - // Mutation to save - const {mutate: updatePreferences, isPending: isSaving} = - useUpdateNotificationPreferences(); - - // Pre-fill selections once both data sets are ready - useEffect(() => { - console.log('Fetched Preferences Data:', preferencesData); - if (preferencesData) { - // Support both { preferences: { contentClusters: [] } } and { contentClusters: [] } - const clusters = - preferencesData.preferences?.contentClusters || - preferencesData.contentClusters; - - console.log("Clusters", clusters); - - if (Array.isArray(clusters)) { - setSelectedIds(clusters.map(cluster => cluster._id)); - } - } - }, [preferencesData]); - - const toggleTag = (id: string) => { - setSelectedIds(prev => - prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id], - ); - }; - - const handleSave = () => { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Sign In Required', - description: 'Please sign in to save your notification preferences.', - iconName: 'bell-cog-outline', - }); - return; - } - - if (!isConnected) { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - return; - } - - const payload = (categories ?? []).filter(cat => - selectedIds.includes(cat._id), - ); - - updatePreferences( - {contentClusters: payload}, - { - onSuccess: () => { - console.log('Preferences saved successfully:', selectedIds); - queryClient.invalidateQueries({ - queryKey: ['notification-preferences'], - }); - Snackbar.show({ - text: '✓ Notification preferences saved!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - onError: err => { - console.error('Preferences update error:', err); - Alert.alert( - 'Save Failed', - 'Could not update your preferences. Please try again.', - ); - }, - }, - ); - }; - - const isLoading = tagsLoading || prefsLoading; - - return ( - - {/* Header banner */} - - - - Content Interests - - Choose topics to receive personalised notifications - - - - - {isLoading ? ( - - - - ) : ( - <> - - Select all that apply - - - {(categories ?? []).map((tag: Category) => { - const isSelected = selectedIds.includes(tag._id); - return ( - toggleTag(tag._id)}> - {isSelected && ( - - )} - - {tag.name} - - - ); - })} - - - {/* Select All / Clear All */} - - - setSelectedIds((categories ?? []).map(t => t._id)) - }> - Select All - - setSelectedIds([])}> - - Clear All - - - - - - You will receive push notifications and emails for new articles - in the topics you select.{'\n'}Health articles are always - broadcast to everyone. - - - - {/* Save button */} - - - {isSaving ? ( - - ) : ( - Save Preferences - )} - - - - )} - - ); -}; - -export default NotificationPreferencesScreen; - -const styles = StyleSheet.create({ - safeArea: { - flex: 1, - backgroundColor: ON_PRIMARY_COLOR, - }, - headerBanner: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#000A60', - paddingHorizontal: wp(5), - paddingVertical: hp(3.5), - gap: wp(3), - }, - headerTextContainer: { - flex: 1, - justifyContent: 'center', - paddingVertical: hp(1), - paddingHorizontal: wp(1), - }, - headerTitle: { - color: 'white', - fontSize: fp(6), - fontWeight: '700', - letterSpacing: 0.3, - paddingTop: wp(2) - }, - headerSubtitle: { - color: 'rgba(255,255,255,0.75)', - fontSize: 12, - marginTop: 2, - }, - loaderContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - scrollContent: { - paddingHorizontal: wp(4), - paddingTop: hp(2), - paddingBottom: hp(4), - }, - sectionLabel: { - fontSize: 13, - fontWeight: '600', - color: '#6b7280', - textTransform: 'uppercase', - letterSpacing: 0.8, - marginBottom: hp(2), - }, - chipsContainer: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: wp(3.5), - }, - chip: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: hp(1.2), - paddingHorizontal: wp(4), - borderRadius: 100, - borderWidth: 1.5, - borderColor: PRIMARY_COLOR, - backgroundColor: 'white', - shadowColor: PRIMARY_COLOR, - shadowOffset: {width: 0, height: 1}, - shadowOpacity: 0.1, - shadowRadius: 3, - elevation: 2, - }, - chipSelected: { - backgroundColor: PRIMARY_COLOR, - borderColor: PRIMARY_COLOR, - elevation: 4, - shadowOpacity: 0.25, - }, - chipIcon: { - marginRight: 5, - }, - chipText: { - fontSize: 14, - fontWeight: '600', - color: PRIMARY_COLOR, - }, - chipTextSelected: { - color: 'white', - }, - bulkActionsRow: { - flexDirection: 'row', - gap: wp(3), - marginTop: hp(3), - }, - bulkBtn: { - borderRadius: 8, - paddingVertical: hp(1), - paddingHorizontal: wp(4), - borderWidth: 1, - borderColor: PRIMARY_COLOR, - backgroundColor: 'white', - }, - bulkBtnClear: { - borderColor: '#d1d5db', - }, - bulkBtnText: { - fontSize: 13, - fontWeight: '600', - color: PRIMARY_COLOR, - }, - helperText: { - marginTop: hp(3), - fontSize: 12.5, - color: '#9ca3af', - lineHeight: 18, - borderLeftWidth: 3, - borderLeftColor: PRIMARY_COLOR, - paddingLeft: wp(3), - }, - saveContainer: { - paddingHorizontal: wp(4), - paddingVertical: hp(2), - borderTopWidth: 1, - borderTopColor: '#e5e7eb', - backgroundColor: ON_PRIMARY_COLOR, - }, - saveBtn: { - backgroundColor: PRIMARY_COLOR, - borderRadius: 12, - paddingVertical: hp(2), - alignItems: 'center', - justifyContent: 'center', - shadowColor: PRIMARY_COLOR, - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.3, - shadowRadius: 6, - elevation: 5, - }, - saveBtnDisabled: { - opacity: 0.65, - }, - saveBtnText: { - color: 'white', - fontSize: 16, - fontWeight: '700', - letterSpacing: 0.4, - }, -}); + +const NotificationPreferencesScreen = ({ + navigation, +}: NotificationPreferencesScreenProp) => { + const queryClient = useQueryClient(); + const {isConnected} = useSelector((state: any) => state.network); + const {isGuest} = useSelector((state: any) => state.user); + const [selectedIds, setSelectedIds] = useState([]); + + // Fetch all article tags + const {data: categories, isLoading: tagsLoading} = + useGetCategories(isConnected); + + // Fetch the user's saved preferences + const {data: preferencesData, isLoading: prefsLoading} = + useGetNotificationPreferences(isConnected); + + console.log("Preference data", preferencesData); + // Mutation to save + const {mutate: updatePreferences, isPending: isSaving} = + useUpdateNotificationPreferences(); + + // Pre-fill selections once both data sets are ready + useEffect(() => { + console.log('Fetched Preferences Data:', preferencesData); + if (preferencesData) { + // Support both { preferences: { contentClusters: [] } } and { contentClusters: [] } + const clusters = + preferencesData.preferences?.contentClusters || + preferencesData.contentClusters; + + console.log("Clusters", clusters); + + if (Array.isArray(clusters)) { + setSelectedIds(clusters.map(cluster => cluster._id)); + } + } + }, [preferencesData]); + + const toggleTag = (id: string) => { + setSelectedIds(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id], + ); + }; + + const handleSave = () => { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: 'Please sign in to save your notification preferences.', + iconName: 'bell-cog-outline', + }); + return; + } + + if (!isConnected) { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + return; + } + + const payload = (categories ?? []).filter(cat => + selectedIds.includes(cat._id), + ); + + updatePreferences( + {contentClusters: payload}, + { + onSuccess: () => { + console.log('Preferences saved successfully:', selectedIds); + queryClient.invalidateQueries({ + queryKey: ['notification-preferences'], + }); + Snackbar.show({ + text: '✓ Notification preferences saved!', + duration: Snackbar.LENGTH_SHORT, + }); + }, + onError: err => { + console.error('Preferences update error:', err); + Alert.alert( + 'Save Failed', + 'Could not update your preferences. Please try again.', + ); + }, + }, + ); + }; + + const isLoading = tagsLoading || prefsLoading; + + return ( + + {/* Header banner */} + + + + Content Interests + + Choose topics to receive personalised notifications + + + + + {isLoading ? ( + + + + ) : ( + <> + + Select all that apply + + + {(categories ?? []).map((tag: Category) => { + const isSelected = selectedIds.includes(tag._id); + return ( + toggleTag(tag._id)}> + {isSelected && ( + + )} + + {tag.name} + + + ); + })} + + + {/* Select All / Clear All */} + + + setSelectedIds((categories ?? []).map(t => t._id)) + }> + Select All + + setSelectedIds([])}> + + Clear All + + + + + + You will receive push notifications and emails for new articles + in the topics you select.{'\n'}Health articles are always + broadcast to everyone. + + + + {/* Save button */} + + + {isSaving ? ( + + ) : ( + Save Preferences + )} + + + + )} + + ); +}; + +export default NotificationPreferencesScreen; + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: ON_PRIMARY_COLOR, + }, + headerBanner: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#000A60', + paddingHorizontal: wp(5), + paddingVertical: hp(3.5), + gap: wp(3), + }, + headerTextContainer: { + flex: 1, + justifyContent: 'center', + paddingVertical: hp(1), + paddingHorizontal: wp(1), + }, + headerTitle: { + color: 'white', + fontSize: fp(6), + fontWeight: '700', + letterSpacing: 0.3, + paddingTop: wp(2) + }, + headerSubtitle: { + color: 'rgba(255,255,255,0.75)', + fontSize: rf(12), + marginTop: 2, + }, + loaderContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + scrollContent: { + paddingHorizontal: wp(4), + paddingTop: hp(2), + paddingBottom: hp(4), + }, + sectionLabel: { + fontSize: rf(13), + fontWeight: '600', + color: '#6b7280', + textTransform: 'uppercase', + letterSpacing: 0.8, + marginBottom: hp(2), + }, + chipsContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: wp(3.5), + }, + chip: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: hp(1.2), + paddingHorizontal: wp(4), + borderRadius: 100, + borderWidth: 1.5, + borderColor: PRIMARY_COLOR, + backgroundColor: 'white', + shadowColor: PRIMARY_COLOR, + shadowOffset: {width: 0, height: 1}, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 2, + }, + chipSelected: { + backgroundColor: PRIMARY_COLOR, + borderColor: PRIMARY_COLOR, + elevation: 4, + shadowOpacity: 0.25, + }, + chipIcon: { + marginRight: 5, + }, + chipText: { + fontSize: rf(14), + fontWeight: '600', + color: PRIMARY_COLOR, + }, + chipTextSelected: { + color: 'white', + }, + bulkActionsRow: { + flexDirection: 'row', + gap: wp(3), + marginTop: hp(3), + }, + bulkBtn: { + borderRadius: 8, + paddingVertical: hp(1), + paddingHorizontal: wp(4), + borderWidth: 1, + borderColor: PRIMARY_COLOR, + backgroundColor: 'white', + }, + bulkBtnClear: { + borderColor: '#d1d5db', + }, + bulkBtnText: { + fontSize: rf(13), + fontWeight: '600', + color: PRIMARY_COLOR, + }, + helperText: { + marginTop: hp(3), + fontSize: rf(12).5, + color: '#9ca3af', + lineHeight: 18, + borderLeftWidth: 3, + borderLeftColor: PRIMARY_COLOR, + paddingLeft: wp(3), + }, + saveContainer: { + paddingHorizontal: wp(4), + paddingVertical: hp(2), + borderTopWidth: 1, + borderTopColor: '#e5e7eb', + backgroundColor: ON_PRIMARY_COLOR, + }, + saveBtn: { + backgroundColor: PRIMARY_COLOR, + borderRadius: 12, + paddingVertical: hp(2), + alignItems: 'center', + justifyContent: 'center', + shadowColor: PRIMARY_COLOR, + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.3, + shadowRadius: 6, + elevation: 5, + }, + saveBtnDisabled: { + opacity: 0.65, + }, + saveBtnText: { + color: 'white', + fontSize: rf(16), + fontWeight: '700', + letterSpacing: 0.4, + }, +}); diff --git a/frontend/src/screens/NotificationScreen.tsx b/frontend/src/screens/NotificationScreen.tsx index 906df0d2..bc69b150 100644 --- a/frontend/src/screens/NotificationScreen.tsx +++ b/frontend/src/screens/NotificationScreen.tsx @@ -1,418 +1,419 @@ -import {FlatList, StyleSheet, Text, View, Image} from 'react-native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {FlatList, StyleSheet, View} from 'react-native'; -import React, {useEffect} from 'react'; -import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; -import NotificationItem from '../components/NotificationItem'; -import {useDispatch, useSelector} from 'react-redux'; -import {Notification, NotificationType} from '../type'; -import Loader from '../components/Loader'; -import Snackbar from 'react-native-snackbar'; -import {hp} from '../helper/Metric'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import {useGetAllNotifications} from '../hooks/useGetAllNotifications'; -import {useMarkNotificationAsRead} from '../hooks/useMarkNoticationAsRead'; -import {useDeleteNotification} from '../hooks/useDeleteNotification'; -import {NoNotificationState} from '../components/EmptyStates'; - -type PendingDelete = { - item: Notification; - index: number; - timer: ReturnType; -}; - -const UNDO_TIMEOUT_MS = 3500; - -// PodcastsScreen component displays the list of podcasts and includes a PodcastPlayer -const NotificationScreen = ({navigation}: any) => { - //const notifications = []; - const {user_token} = useSelector((state: any) => state.user); - const [refreshing, setRefreshing] = React.useState(false); - const [page, setPage] = React.useState(1); - const [totalPages, setTotalPages] = React.useState(0); - const {isConnected} = useSelector((state: any) => state.network); - const [notificationsData, setNotificationsData] = - React.useState([]); - const [openSwipeItemId, setOpenSwipeItemId] = useState(null); - const pendingDeletesRef = useRef>(new Map()); - const isMountedRef = useRef(true); - - const dispatch = useDispatch(); - const {mutate: markNotification} = useMarkNotificationAsRead(); - const {mutate: deleteNotification} = useDeleteNotification(); - - const { - data: notificationsRes, - isLoading, - refetch, - } = useGetAllNotifications(page, isConnected); - - useEffect(() => { - if (notificationsRes) { - if (Number(page) === 1) { - if (notificationsRes.totalPages) { - const totalPage = notificationsRes.totalPages; - setTotalPages(totalPage); - } - setNotificationsData(notificationsRes.notifications); - } else { - if (notificationsRes.notifications) { - const oldNotif = notificationsData ?? []; - setNotificationsData([ - ...oldNotif, - ...notificationsRes.notifications, - ]); - } - } - } - }, [notificationsRes, page]); - - useEffect(() => { - return () => { - isMountedRef.current = false; - pendingDeletesRef.current.forEach(pendingDelete => { - clearTimeout(pendingDelete.timer); - deleteNotification(pendingDelete.item._id); - }); - pendingDeletesRef.current.clear(); - }; - }, [deleteNotification]); - - useEffect(() => { - if (isConnected) { - markNotification( - {}, - { - onSuccess: async () => { - Snackbar.show({ - text: 'All notifications marked as read', - duration: Snackbar.LENGTH_SHORT, - }); - }, - - onError: error => { - console.log(error); - Snackbar.show({ - text: 'Internal server error, cannot mark the notification as read!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }, - ); - } - - return () => {}; - }, []); - - const onRefresh = () => { - setRefreshing(true); - refetch(); - setRefreshing(false); - }; - - const restoreDeletedNotification = useCallback( - (snapshot: PendingDelete) => { - setNotificationsData(previous => { - const current = previous ?? []; - - if (current.some(notification => notification._id === snapshot.item._id)) { - return current; - } - - const nextNotifications = [...current]; - const insertionIndex = Math.min(snapshot.index, nextNotifications.length); - nextNotifications.splice(insertionIndex, 0, snapshot.item); - return nextNotifications; - }); - }, - [], - ); - - const clearPendingDelete = useCallback((id: string) => { - const pendingDelete = pendingDeletesRef.current.get(id); - - if (pendingDelete) { - clearTimeout(pendingDelete.timer); - pendingDeletesRef.current.delete(id); - } - }, []); - - const commitDeleteNotification = useCallback( - (snapshot: PendingDelete) => { - deleteNotification(snapshot.item._id, { - onSuccess: () => { - pendingDeletesRef.current.delete(snapshot.item._id); - - if (isMountedRef.current) { - refetch(); - } - }, - - onError: error => { - console.log(error); - pendingDeletesRef.current.delete(snapshot.item._id); - - if (isMountedRef.current) { - restoreDeletedNotification(snapshot); - Snackbar.show({ - text: 'Internal server error, failed to delete notification!', - duration: Snackbar.LENGTH_SHORT, - }); - } - }, - }); - }, - [deleteNotification, refetch, restoreDeletedNotification], - ); - - const handleDeleteAction = useCallback( - (item: Notification) => { - console.log('Notification ID', item?._id); - - if (!isConnected) { - Snackbar.show({ - text: 'Please check your internet connection', - duration: Snackbar.LENGTH_SHORT, - }); - return; - } - - if (pendingDeletesRef.current.has(item._id)) { - return; - } - - setOpenSwipeItemId(previous => (previous === item._id ? null : previous)); - - let snapshot: Omit | null = null; - - setNotificationsData(previous => { - const current = previous ?? []; - const index = current.findIndex(notification => notification._id === item._id); - - if (index === -1) { - return current; - } - - snapshot = { - item, - index, - }; - - return current.filter(notification => notification._id !== item._id); - }); - - if (!snapshot) { - return; - } - - const timer = setTimeout(() => { - const pendingDelete = pendingDeletesRef.current.get(item._id); - - if (!pendingDelete) { - return; - } - - commitDeleteNotification(pendingDelete); - }, UNDO_TIMEOUT_MS); - - pendingDeletesRef.current.set(item._id, { - ...snapshot, - timer, - }); - - Snackbar.show({ - text: 'Notification deleted', - duration: Snackbar.LENGTH_LONG, - action: { - text: 'UNDO', - textColor: '#ffffff', - onPress: () => { - const pendingDelete = pendingDeletesRef.current.get(item._id); - - if (!pendingDelete) { - return; - } - - clearPendingDelete(item._id); - restoreDeletedNotification(pendingDelete); - Snackbar.show({ - text: 'Deletion undone', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }, - }); - }, - [clearPendingDelete, commitDeleteNotification, isConnected, restoreDeletedNotification], - ); - - const handleNotificationClick = (item: Notification) => { - if ( - (item.type === NotificationType.PodcastCommentMention || - item.type === NotificationType.PodcastComment || - item.type === NotificationType.PodcastCommentLike) && - item.podcastId - ) { - navigation.navigate('PodcastDiscussion', { - podcastId: item.podcastId._id, - mentionedUsers: item.podcastId.mentionedUsers, - }); - } else if ( - (item.type === NotificationType.ArticleCommentMention || - item.type === NotificationType.ArticleRepost || - item.type === NotificationType.Article || - item.type === NotificationType.EditRequest || - item.type === NotificationType.ArticleLike || - item.type === NotificationType.ArticleComment) && - item.articleId - ) { - navigation.navigate('ArticleScreen', { - articleId: Number(item.articleId._id), - authorId: item.articleId.authorId, - recordId: item.articleId.pb_recordId, - }); - } else if (item.type === NotificationType.UserFollow && item.userId) { - navigation.navigate('UserProfileScreen', { - authorId: item.userId._id, - }); - } else if (item.type === NotificationType.CommentLike) { - if (item.podcastId) { - navigation.navigate('PodcastDiscussion', { - podcastId: item.podcastId._id, - mentionedUsers: item.podcastId.mentionedUsers, - }); - } else if (item.articleId) { - navigation.navigate('CommentScreen', { - articleId: item.articleId._id, - mentionedUsers: item.articleId.mentionedUsers, - article: item.articleId, - }); - } - } else if ( - (item.type === NotificationType.Podcast || - item.type === NotificationType.PodcastLike) && - item.podcastId - ) { - navigation.navigate('PodcastDetail', { - trackId: item.podcastId._id, - audioUrl: item.podcastId.audio_url, - }); - } else if (item.type === NotificationType.ArticleCommentLike) { - if (item.articleId) { - navigation.navigate('CommentScreen', { - articleId: item.articleId._id, - mentionedUsers: item.articleId.mentionedUsers, - }); - } - } else if (item.type === NotificationType.ArticleReview) { - if (item.articleId) { - navigation.navigate('ReviewScreen', { - articleId: item.articleId._id, - authorId: item.articleId.authorId, - recordId: item.articleId.pb_recordId, - }); - } - } else if (item.type === NotificationType.ArticleRevisionReview) { - if (item.revisonId) { - navigation.navigate('ImprovementReviewScreen', { - requestId: item.revisonId._id, - authorId: item.revisonId.user_id, - recordId: item.revisonId.pb_recordId, - articleRecordId: item.revisonId.article_recordId, - }); - } - } - }; - - const renderItem = ({item}: {item: Notification}) => { - return ( - { - setOpenSwipeItemId(previous => (previous === id ? null : previous)); - }} - /> - ); - }; - - if (isLoading && notificationsData.length === 0) { - return ; - } - - return ( - // Main container - - item._id.toString()} - contentContainerStyle={[ - styles.flatListContentContainer, - (!notificationsData || notificationsData.length === 0) && { - flexGrow: 1, - justifyContent: 'center', - }, - ]} - refreshing={refreshing} - onRefresh={onRefresh} - ListEmptyComponent={ - - } - onEndReached={() => { - if (page < totalPages) { - setPage(prev => prev + 1); - } - }} - onEndReachedThreshold={0.5} - /> - - ); -}; - -export default NotificationScreen; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: ON_PRIMARY_COLOR, - justifyContent: 'center', - //marginTop: 16, - }, - header: { - backgroundColor: PRIMARY_COLOR, - paddingHorizontal: 16, - borderBottomLeftRadius: 20, - borderBottomRightRadius: 20, - // paddingBottom: hp(3), - }, - content: { - marginTop: hp(3), - paddingHorizontal: 16, - }, - recentPodcastsHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 15, - }, - recentPodcastsTitle: { - fontSize: 25, - fontWeight: 'bold', - color: 'white', - alignSelf: 'center', - }, - seeMoreText: { - fontSize: 16, - fontWeight: '600', - }, - - flatListContentContainer: { - paddingHorizontal: 16, - marginTop: 4, - paddingBottom: 120, - }, -}); +import {FlatList, StyleSheet, Text, View, Image} from 'react-native'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {FlatList, StyleSheet, View} from 'react-native'; +import React, {useEffect} from 'react'; +import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; +import NotificationItem from '../components/NotificationItem'; +import {useDispatch, useSelector} from 'react-redux'; +import {Notification, NotificationType} from '../type'; +import Loader from '../components/Loader'; +import Snackbar from 'react-native-snackbar'; +import {hp} from '../helper/Metric'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {useGetAllNotifications} from '../hooks/useGetAllNotifications'; +import {useMarkNotificationAsRead} from '../hooks/useMarkNoticationAsRead'; +import {useDeleteNotification} from '../hooks/useDeleteNotification'; +import {NoNotificationState} from '../components/EmptyStates'; import { rf } from '../helper/Metric'; + + +type PendingDelete = { + item: Notification; + index: number; + timer: ReturnType; +}; + +const UNDO_TIMEOUT_MS = 3500; + +// PodcastsScreen component displays the list of podcasts and includes a PodcastPlayer +const NotificationScreen = ({navigation}: any) => { + //const notifications = []; + const {user_token} = useSelector((state: any) => state.user); + const [refreshing, setRefreshing] = React.useState(false); + const [page, setPage] = React.useState(1); + const [totalPages, setTotalPages] = React.useState(0); + const {isConnected} = useSelector((state: any) => state.network); + const [notificationsData, setNotificationsData] = + React.useState([]); + const [openSwipeItemId, setOpenSwipeItemId] = useState(null); + const pendingDeletesRef = useRef>(new Map()); + const isMountedRef = useRef(true); + + const dispatch = useDispatch(); + const {mutate: markNotification} = useMarkNotificationAsRead(); + const {mutate: deleteNotification} = useDeleteNotification(); + + const { + data: notificationsRes, + isLoading, + refetch, + } = useGetAllNotifications(page, isConnected); + + useEffect(() => { + if (notificationsRes) { + if (Number(page) === 1) { + if (notificationsRes.totalPages) { + const totalPage = notificationsRes.totalPages; + setTotalPages(totalPage); + } + setNotificationsData(notificationsRes.notifications); + } else { + if (notificationsRes.notifications) { + const oldNotif = notificationsData ?? []; + setNotificationsData([ + ...oldNotif, + ...notificationsRes.notifications, + ]); + } + } + } + }, [notificationsRes, page]); + + useEffect(() => { + return () => { + isMountedRef.current = false; + pendingDeletesRef.current.forEach(pendingDelete => { + clearTimeout(pendingDelete.timer); + deleteNotification(pendingDelete.item._id); + }); + pendingDeletesRef.current.clear(); + }; + }, [deleteNotification]); + + useEffect(() => { + if (isConnected) { + markNotification( + {}, + { + onSuccess: async () => { + Snackbar.show({ + text: 'All notifications marked as read', + duration: Snackbar.LENGTH_SHORT, + }); + }, + + onError: error => { + console.log(error); + Snackbar.show({ + text: 'Internal server error, cannot mark the notification as read!', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }, + ); + } + + return () => {}; + }, []); + + const onRefresh = () => { + setRefreshing(true); + refetch(); + setRefreshing(false); + }; + + const restoreDeletedNotification = useCallback( + (snapshot: PendingDelete) => { + setNotificationsData(previous => { + const current = previous ?? []; + + if (current.some(notification => notification._id === snapshot.item._id)) { + return current; + } + + const nextNotifications = [...current]; + const insertionIndex = Math.min(snapshot.index, nextNotifications.length); + nextNotifications.splice(insertionIndex, 0, snapshot.item); + return nextNotifications; + }); + }, + [], + ); + + const clearPendingDelete = useCallback((id: string) => { + const pendingDelete = pendingDeletesRef.current.get(id); + + if (pendingDelete) { + clearTimeout(pendingDelete.timer); + pendingDeletesRef.current.delete(id); + } + }, []); + + const commitDeleteNotification = useCallback( + (snapshot: PendingDelete) => { + deleteNotification(snapshot.item._id, { + onSuccess: () => { + pendingDeletesRef.current.delete(snapshot.item._id); + + if (isMountedRef.current) { + refetch(); + } + }, + + onError: error => { + console.log(error); + pendingDeletesRef.current.delete(snapshot.item._id); + + if (isMountedRef.current) { + restoreDeletedNotification(snapshot); + Snackbar.show({ + text: 'Internal server error, failed to delete notification!', + duration: Snackbar.LENGTH_SHORT, + }); + } + }, + }); + }, + [deleteNotification, refetch, restoreDeletedNotification], + ); + + const handleDeleteAction = useCallback( + (item: Notification) => { + console.log('Notification ID', item?._id); + + if (!isConnected) { + Snackbar.show({ + text: 'Please check your internet connection', + duration: Snackbar.LENGTH_SHORT, + }); + return; + } + + if (pendingDeletesRef.current.has(item._id)) { + return; + } + + setOpenSwipeItemId(previous => (previous === item._id ? null : previous)); + + let snapshot: Omit | null = null; + + setNotificationsData(previous => { + const current = previous ?? []; + const index = current.findIndex(notification => notification._id === item._id); + + if (index === -1) { + return current; + } + + snapshot = { + item, + index, + }; + + return current.filter(notification => notification._id !== item._id); + }); + + if (!snapshot) { + return; + } + + const timer = setTimeout(() => { + const pendingDelete = pendingDeletesRef.current.get(item._id); + + if (!pendingDelete) { + return; + } + + commitDeleteNotification(pendingDelete); + }, UNDO_TIMEOUT_MS); + + pendingDeletesRef.current.set(item._id, { + ...snapshot, + timer, + }); + + Snackbar.show({ + text: 'Notification deleted', + duration: Snackbar.LENGTH_LONG, + action: { + text: 'UNDO', + textColor: '#ffffff', + onPress: () => { + const pendingDelete = pendingDeletesRef.current.get(item._id); + + if (!pendingDelete) { + return; + } + + clearPendingDelete(item._id); + restoreDeletedNotification(pendingDelete); + Snackbar.show({ + text: 'Deletion undone', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }, + }); + }, + [clearPendingDelete, commitDeleteNotification, isConnected, restoreDeletedNotification], + ); + + const handleNotificationClick = (item: Notification) => { + if ( + (item.type === NotificationType.PodcastCommentMention || + item.type === NotificationType.PodcastComment || + item.type === NotificationType.PodcastCommentLike) && + item.podcastId + ) { + navigation.navigate('PodcastDiscussion', { + podcastId: item.podcastId._id, + mentionedUsers: item.podcastId.mentionedUsers, + }); + } else if ( + (item.type === NotificationType.ArticleCommentMention || + item.type === NotificationType.ArticleRepost || + item.type === NotificationType.Article || + item.type === NotificationType.EditRequest || + item.type === NotificationType.ArticleLike || + item.type === NotificationType.ArticleComment) && + item.articleId + ) { + navigation.navigate('ArticleScreen', { + articleId: Number(item.articleId._id), + authorId: item.articleId.authorId, + recordId: item.articleId.pb_recordId, + }); + } else if (item.type === NotificationType.UserFollow && item.userId) { + navigation.navigate('UserProfileScreen', { + authorId: item.userId._id, + }); + } else if (item.type === NotificationType.CommentLike) { + if (item.podcastId) { + navigation.navigate('PodcastDiscussion', { + podcastId: item.podcastId._id, + mentionedUsers: item.podcastId.mentionedUsers, + }); + } else if (item.articleId) { + navigation.navigate('CommentScreen', { + articleId: item.articleId._id, + mentionedUsers: item.articleId.mentionedUsers, + article: item.articleId, + }); + } + } else if ( + (item.type === NotificationType.Podcast || + item.type === NotificationType.PodcastLike) && + item.podcastId + ) { + navigation.navigate('PodcastDetail', { + trackId: item.podcastId._id, + audioUrl: item.podcastId.audio_url, + }); + } else if (item.type === NotificationType.ArticleCommentLike) { + if (item.articleId) { + navigation.navigate('CommentScreen', { + articleId: item.articleId._id, + mentionedUsers: item.articleId.mentionedUsers, + }); + } + } else if (item.type === NotificationType.ArticleReview) { + if (item.articleId) { + navigation.navigate('ReviewScreen', { + articleId: item.articleId._id, + authorId: item.articleId.authorId, + recordId: item.articleId.pb_recordId, + }); + } + } else if (item.type === NotificationType.ArticleRevisionReview) { + if (item.revisonId) { + navigation.navigate('ImprovementReviewScreen', { + requestId: item.revisonId._id, + authorId: item.revisonId.user_id, + recordId: item.revisonId.pb_recordId, + articleRecordId: item.revisonId.article_recordId, + }); + } + } + }; + + const renderItem = ({item}: {item: Notification}) => { + return ( + { + setOpenSwipeItemId(previous => (previous === id ? null : previous)); + }} + /> + ); + }; + + if (isLoading && notificationsData.length === 0) { + return ; + } + + return ( + // Main container + + item._id.toString()} + contentContainerStyle={[ + styles.flatListContentContainer, + (!notificationsData || notificationsData.length === 0) && { + flexGrow: 1, + justifyContent: 'center', + }, + ]} + refreshing={refreshing} + onRefresh={onRefresh} + ListEmptyComponent={ + + } + onEndReached={() => { + if (page < totalPages) { + setPage(prev => prev + 1); + } + }} + onEndReachedThreshold={0.5} + /> + + ); +}; + +export default NotificationScreen; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: ON_PRIMARY_COLOR, + justifyContent: 'center', + //marginTop: 16, + }, + header: { + backgroundColor: PRIMARY_COLOR, + paddingHorizontal: 16, + borderBottomLeftRadius: 20, + borderBottomRightRadius: 20, + // paddingBottom: hp(3), + }, + content: { + marginTop: hp(3), + paddingHorizontal: 16, + }, + recentPodcastsHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 15, + }, + recentPodcastsTitle: { + fontSize: rf(25), + fontWeight: 'bold', + color: 'white', + alignSelf: 'center', + }, + seeMoreText: { + fontSize: rf(16), + fontWeight: '600', + }, + + flatListContentContainer: { + paddingHorizontal: 16, + marginTop: 4, + paddingBottom: 120, + }, +}); diff --git a/frontend/src/screens/OfflinePodcastDetails.tsx b/frontend/src/screens/OfflinePodcastDetails.tsx index 2d49be9b..a846fe84 100644 --- a/frontend/src/screens/OfflinePodcastDetails.tsx +++ b/frontend/src/screens/OfflinePodcastDetails.tsx @@ -1,541 +1,542 @@ -import { - View, - StyleSheet, - ScrollView, - TouchableOpacity, - Text, - Image, - Alert, - Platform, -} from 'react-native'; -import {OfflinePodcastDetailProp, PodcastData} from '../type'; -import {hp} from '../helper/Metric'; -import {ON_PRIMARY_COLOR, BUTTON_COLOR, PRIMARY_COLOR} from '../helper/Theme'; -import Slider from '@react-native-community/slider'; -import { formatDateWithTime } from '../helper/dateUtils'; -import Ionicons from '@expo/vector-icons/Ionicons'; -// eslint-disable-next-line import/no-duplicates -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import AntDesign from '@expo/vector-icons/AntDesign'; +import { + View, + StyleSheet, + ScrollView, + TouchableOpacity, + Text, + Image, + Alert, + Platform, +} from 'react-native'; +import {OfflinePodcastDetailProp, PodcastData} from '../type'; +import {hp} from '../helper/Metric'; +import {ON_PRIMARY_COLOR, BUTTON_COLOR, PRIMARY_COLOR} from '../helper/Theme'; +import Slider from '@react-native-community/slider'; +import { formatDateWithTime } from '../helper/dateUtils'; +import Ionicons from '@expo/vector-icons/Ionicons'; +// eslint-disable-next-line import/no-duplicates +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import AntDesign from '@expo/vector-icons/AntDesign'; + +import {formatCount, updateOfflinePodcastLikeStatus} from '../helper/Utils'; +import {useDispatch, useSelector} from 'react-redux'; +import {useEffect, useState} from 'react'; +import Snackbar from 'react-native-snackbar'; +import {GET_STORAGE_DATA} from '../helper/APIUtils'; +import Share from 'react-native-share'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import Icon from '@expo/vector-icons/MaterialIcons'; +import {useSocket} from '../contexts/SocketContext'; +import {Feather} from '@expo/vector-icons'; +import {useAudioPlayer} from 'expo-audio'; +import {useLikePodcast} from '../hooks/useLikePodcast'; import { rf } from '../helper/Metric'; -import {formatCount, updateOfflinePodcastLikeStatus} from '../helper/Utils'; -import {useDispatch, useSelector} from 'react-redux'; -import {useEffect, useState} from 'react'; -import Snackbar from 'react-native-snackbar'; -import {GET_STORAGE_DATA} from '../helper/APIUtils'; -import Share from 'react-native-share'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import Icon from '@expo/vector-icons/MaterialIcons'; -import {useSocket} from '../contexts/SocketContext'; -import {Feather} from '@expo/vector-icons'; -import {useAudioPlayer} from 'expo-audio'; -import {useLikePodcast} from '../hooks/useLikePodcast'; - -export default function OfflinePodcastDetail({ - route, -}: OfflinePodcastDetailProp) { - const {podcast} = route.params; - const socket = useSocket(); - const insets = useSafeAreaInsets(); - - const [position, setPosition] = useState(0); - const [duration, setDuration] = useState(0); - - //const playbackState = usePlaybackState(); - //const progress = useProgress(); - const {user_id, user_token, user_handle} = useSelector( - (state: any) => state.user, - ); - const {isConnected} = useSelector((state: any) => state.network); - //const [isLoading, setLoading] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - const [currentPodcast, setCurrentPodcast] = useState(podcast); - const dispatch = useDispatch(); - - const player = useAudioPlayer(`file://${podcast.filePath}`); - - const {mutate: likePodcast, isPending: likePodcastPending} = useLikePodcast(); - - useEffect(() => { - //console.log("File path", `${filePath}`); - console.log('Player time', player.currentTime); - console.log('Player status', player.currentStatus.currentTime); - }, [player.currentStatus.currentTime, player.currentTime]); - - useEffect(() => { - if (!player) return; - - const interval = setInterval(() => { - const status = player.currentStatus; - if (status) { - setPosition(status.currentTime); - setDuration(player.duration || status.duration || 0); - } - }, 500); - - return () => clearInterval(interval); - }, [player]); - - const handleListenPress = async () => { - const currentState = player.currentStatus; - - if (currentState.playing) { - player.pause(); - } else { - player.play(); - } - }; - - const handleShare = async () => { - try { - const url = `https://uhsocial.in/api/share/podcast?trackId=${podcast._id}&audioUrl=${podcast.audio_url}`; - await Share.open({ - title: podcast?.title, - message: `${podcast?.title} : Check out this awesome podcast on UltimateHealth app!`, - // Most Recent APK: 0.7.4 - url: url, - subject: 'Podcast Sharing', - }); - //console.log(result); - } catch (error) { - console.log('Error sharing:', error); - Alert.alert('Error', 'Something went wrong while sharing.'); - // dispatch( - // showAlert({ - // title: 'Error!', - // message: 'Something went wrong while sharing.', - // }), - // ); - } - }; - - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - if (hours > 0) { - return `${hours}:${mins < 10 ? '0' : ''}${mins}:${ - secs < 10 ? '0' : '' - }${secs}`; - } else { - return `${mins}:${secs < 10 ? '0' : ''}${secs}`; - } - }; - return ( - - - - - - { - // if (article && article?.authorId) { - //navigation.navigate('UserProfileScreen', { - // authorId: authorId, - // }); - }}> - {podcast?.user_id.Profile_image && isConnected ? ( - - ) : isConnected ? ( - - ) : ( - - - - )} - - - - {podcast ? podcast?.user_id.user_name : ''} - - - {podcast?.user_id.followers - ? podcast?.user_id.followers.length > 1 - ? `${podcast?.user_id.followers.length} followers` - : `${podcast?.user_id.followers.length} follower` - : '0 follower'} - - - - - - - { - if (isConnected) { - likePodcast(podcast._id, { - onSuccess: async data => { - if (data.likeStatus === false) { - podcast.likedUsers = podcast.likedUsers.filter( - id => id !== user_id, - ); - setCurrentPodcast(podcast); - await updateOfflinePodcastLikeStatus(podcast); - } else { - podcast.likedUsers.push(user_id); - setCurrentPodcast(podcast); - if (data?.likeStatus) { - // data.userId, data.articleId, data.podcastId, data.articleRecordId, data.title, data.message - if (socket) { - socket.emit('notification', { - type: 'likePost', - userId: user_id, - articleId: null, - podcastId: podcast._id, - articleRecordId: null, - title: `${user_handle} liked your post`, - message: podcast.title, - }); - } - } - await updateOfflinePodcastLikeStatus(podcast); - } - }, - onError: err => { - console.log('Update like count err', err); - Snackbar.show({ - text: 'Something went wrong!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }); - } else { - Snackbar.show({ - text: 'You are currently offline', - duration: Snackbar.LENGTH_SHORT, - }); - } - }}> - {currentPodcast?.likedUsers.includes(user_id) ? ( - - ) : ( - - )} - - {currentPodcast?.likedUsers?.length - ? formatCount(currentPodcast?.likedUsers?.length) - : 0} - - - - - - - { - if (isConnected) { - // handleDiscussion(); // You need to define this - } else { - Snackbar.show({ - text: 'You are currently offline', - duration: Snackbar.LENGTH_SHORT, - }); - } - }}> - - - {podcast?.commentCount ? formatCount(podcast?.commentCount) : 0} - - - - { - { - if (isConnected) { - handleShare(); - } else { - Snackbar.show({ - text: 'You are currently offline', - duration: Snackbar.LENGTH_SHORT, - }); - } - }}> - - - } - - {podcast?.title} - - - {podcast?.description} - - {podcast && - podcast.description && - podcast?.description?.length > 100 && ( - setIsExpanded(!isExpanded)}> - - {isExpanded ? 'Read Less ' : 'Read More '} - - - )} - - - - {podcast?.tags?.map((tag, index) => ( - - #{tag.name} - - ))} - - - - - {formatDateWithTime(podcast?.updated_at)} - - {podcast && ( - - - {podcast?.viewUsers.length <= 1 - ? `${podcast?.viewUsers.length} view` - : `${formatCount(podcast?.viewUsers.length ?? 0)} views`} - - )} - - - { - // seek to selected time - await player.seekTo(value); - }} - /> - - - - {formatTime( - player.currentStatus ? player.currentStatus.currentTime : 0, - )} - - {formatTime(player.duration || 1)} - - - {player.currentStatus.isBuffering && ( - ⏳ Buffering... please wait - )} - - - - {player.currentStatus.playing ? '⏸️Pause' : '🎧 Listen Now'} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: ON_PRIMARY_COLOR, - paddingHorizontal: 12, - paddingTop: Platform.OS === 'android' ? 12 : 0, - }, - header: { - fontSize: 20, - textAlign: 'center', - marginBottom: 6, - fontWeight: '600', - color: '#444', - }, - podcastImage: { - width: '100%', - height: 160, - alignSelf: 'center', - borderRadius: hp(2), - marginBottom: 12, - resizeMode: 'cover', - }, - episodeTitle: { - fontSize: 19, - fontWeight: 'bold', - textAlign: 'center', - color: '#1E1E1E', - marginBottom: 8, - paddingHorizontal: 6, - }, - podcastTitle: { - fontSize: 16, - textAlign: 'justify', - color: '#555', - marginBottom: 2, - paddingHorizontal: 6, - }, - readMoreText: { - color: '#007AFF', - marginTop: 2, - fontSize: 15, - fontWeight: '500', - paddingHorizontal: 6, - }, - tagsContainer: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - marginVertical: 6, - paddingHorizontal: 6, - }, - tagText: { - color: PRIMARY_COLOR, - fontSize: 15, - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 10, - backgroundColor: '#F0F0F0', - }, - metaInfo: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 6, - marginBottom: 10, - }, - metaText: { - fontSize: 14, - color: '#666', - fontWeight: '500', - }, - slider: { - width: '100%', - height: 36, - marginTop: 6, - marginBottom: 2, - }, - timeRow: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 4, - marginBottom: 12, - }, - time: { - fontSize: 13, - color: '#777', - }, - bufferingText: { - textAlign: 'center', - color: '#888', - marginBottom: 6, - fontStyle: 'italic', - fontSize: 14, - }, - listenButton: { - backgroundColor: BUTTON_COLOR, - paddingVertical: 14, - borderRadius: 24, - alignItems: 'center', - marginVertical: 16, - }, - listenButtonDisabled: { - backgroundColor: '#ccc', - }, - listenText: { - color: '#fff', - fontSize: 17, - fontWeight: '600', - }, - footerOptions: { - flexDirection: 'row', - justifyContent: 'space-evenly', - alignItems: 'center', - marginTop: 10, - marginBottom: 12, - paddingHorizontal: 12, - }, - footer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - borderTopWidth: 1, - borderTopColor: '#E0E0E0', - paddingTop: 12, - paddingBottom: 16, - paddingHorizontal: 12, - marginTop: 16, - gap: 10, - }, - authorContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - flex: 1, - }, - authorImage: { - height: 45, - width: 45, - borderRadius: 45, - }, - authorName: { - fontWeight: '700', - fontSize: 15, - }, - authorFollowers: { - fontWeight: '400', - fontSize: 13, - }, - followButton: { - backgroundColor: PRIMARY_COLOR, - paddingHorizontal: 12, - borderRadius: 20, - paddingVertical: 8, - }, - followButtonText: { - color: 'white', - fontSize: 14, - fontWeight: '600', - }, - iconContainer: { - alignItems: 'center', - justifyContent: 'center', - padding: 10, - }, - footerItem: { - flexDirection: 'row', - alignItems: 'center', - marginHorizontal: 10, - }, - - likeCount: { - marginLeft: 4, - fontSize: 14, - color: '#333', - }, -}); + +export default function OfflinePodcastDetail({ + route, +}: OfflinePodcastDetailProp) { + const {podcast} = route.params; + const socket = useSocket(); + const insets = useSafeAreaInsets(); + + const [position, setPosition] = useState(0); + const [duration, setDuration] = useState(0); + + //const playbackState = usePlaybackState(); + //const progress = useProgress(); + const {user_id, user_token, user_handle} = useSelector( + (state: any) => state.user, + ); + const {isConnected} = useSelector((state: any) => state.network); + //const [isLoading, setLoading] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [currentPodcast, setCurrentPodcast] = useState(podcast); + const dispatch = useDispatch(); + + const player = useAudioPlayer(`file://${podcast.filePath}`); + + const {mutate: likePodcast, isPending: likePodcastPending} = useLikePodcast(); + + useEffect(() => { + //console.log("File path", `${filePath}`); + console.log('Player time', player.currentTime); + console.log('Player status', player.currentStatus.currentTime); + }, [player.currentStatus.currentTime, player.currentTime]); + + useEffect(() => { + if (!player) return; + + const interval = setInterval(() => { + const status = player.currentStatus; + if (status) { + setPosition(status.currentTime); + setDuration(player.duration || status.duration || 0); + } + }, 500); + + return () => clearInterval(interval); + }, [player]); + + const handleListenPress = async () => { + const currentState = player.currentStatus; + + if (currentState.playing) { + player.pause(); + } else { + player.play(); + } + }; + + const handleShare = async () => { + try { + const url = `https://uhsocial.in/api/share/podcast?trackId=${podcast._id}&audioUrl=${podcast.audio_url}`; + await Share.open({ + title: podcast?.title, + message: `${podcast?.title} : Check out this awesome podcast on UltimateHealth app!`, + // Most Recent APK: 0.7.4 + url: url, + subject: 'Podcast Sharing', + }); + //console.log(result); + } catch (error) { + console.log('Error sharing:', error); + Alert.alert('Error', 'Something went wrong while sharing.'); + // dispatch( + // showAlert({ + // title: 'Error!', + // message: 'Something went wrong while sharing.', + // }), + // ); + } + }; + + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}:${mins < 10 ? '0' : ''}${mins}:${ + secs < 10 ? '0' : '' + }${secs}`; + } else { + return `${mins}:${secs < 10 ? '0' : ''}${secs}`; + } + }; + return ( + + + + + + { + // if (article && article?.authorId) { + //navigation.navigate('UserProfileScreen', { + // authorId: authorId, + // }); + }}> + {podcast?.user_id.Profile_image && isConnected ? ( + + ) : isConnected ? ( + + ) : ( + + + + )} + + + + {podcast ? podcast?.user_id.user_name : ''} + + + {podcast?.user_id.followers + ? podcast?.user_id.followers.length > 1 + ? `${podcast?.user_id.followers.length} followers` + : `${podcast?.user_id.followers.length} follower` + : '0 follower'} + + + + + + + { + if (isConnected) { + likePodcast(podcast._id, { + onSuccess: async data => { + if (data.likeStatus === false) { + podcast.likedUsers = podcast.likedUsers.filter( + id => id !== user_id, + ); + setCurrentPodcast(podcast); + await updateOfflinePodcastLikeStatus(podcast); + } else { + podcast.likedUsers.push(user_id); + setCurrentPodcast(podcast); + if (data?.likeStatus) { + // data.userId, data.articleId, data.podcastId, data.articleRecordId, data.title, data.message + if (socket) { + socket.emit('notification', { + type: 'likePost', + userId: user_id, + articleId: null, + podcastId: podcast._id, + articleRecordId: null, + title: `${user_handle} liked your post`, + message: podcast.title, + }); + } + } + await updateOfflinePodcastLikeStatus(podcast); + } + }, + onError: err => { + console.log('Update like count err', err); + Snackbar.show({ + text: 'Something went wrong!', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }); + } else { + Snackbar.show({ + text: 'You are currently offline', + duration: Snackbar.LENGTH_SHORT, + }); + } + }}> + {currentPodcast?.likedUsers.includes(user_id) ? ( + + ) : ( + + )} + + {currentPodcast?.likedUsers?.length + ? formatCount(currentPodcast?.likedUsers?.length) + : 0} + + + + + + + { + if (isConnected) { + // handleDiscussion(); // You need to define this + } else { + Snackbar.show({ + text: 'You are currently offline', + duration: Snackbar.LENGTH_SHORT, + }); + } + }}> + + + {podcast?.commentCount ? formatCount(podcast?.commentCount) : 0} + + + + { + { + if (isConnected) { + handleShare(); + } else { + Snackbar.show({ + text: 'You are currently offline', + duration: Snackbar.LENGTH_SHORT, + }); + } + }}> + + + } + + {podcast?.title} + + + {podcast?.description} + + {podcast && + podcast.description && + podcast?.description?.length > 100 && ( + setIsExpanded(!isExpanded)}> + + {isExpanded ? 'Read Less ' : 'Read More '} + + + )} + + + + {podcast?.tags?.map((tag, index) => ( + + #{tag.name} + + ))} + + + + + {formatDateWithTime(podcast?.updated_at)} + + {podcast && ( + + + {podcast?.viewUsers.length <= 1 + ? `${podcast?.viewUsers.length} view` + : `${formatCount(podcast?.viewUsers.length ?? 0)} views`} + + )} + + + { + // seek to selected time + await player.seekTo(value); + }} + /> + + + + {formatTime( + player.currentStatus ? player.currentStatus.currentTime : 0, + )} + + {formatTime(player.duration || 1)} + + + {player.currentStatus.isBuffering && ( + ⏳ Buffering... please wait + )} + + + + {player.currentStatus.playing ? '⏸️Pause' : '🎧 Listen Now'} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: ON_PRIMARY_COLOR, + paddingHorizontal: 12, + paddingTop: Platform.OS === 'android' ? 12 : 0, + }, + header: { + fontSize: rf(20), + textAlign: 'center', + marginBottom: 6, + fontWeight: '600', + color: '#444', + }, + podcastImage: { + width: '100%', + height: 160, + alignSelf: 'center', + borderRadius: hp(2), + marginBottom: 12, + resizeMode: 'cover', + }, + episodeTitle: { + fontSize: rf(19), + fontWeight: 'bold', + textAlign: 'center', + color: '#1E1E1E', + marginBottom: 8, + paddingHorizontal: 6, + }, + podcastTitle: { + fontSize: rf(16), + textAlign: 'justify', + color: '#555', + marginBottom: 2, + paddingHorizontal: 6, + }, + readMoreText: { + color: '#007AFF', + marginTop: 2, + fontSize: rf(15), + fontWeight: '500', + paddingHorizontal: 6, + }, + tagsContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginVertical: 6, + paddingHorizontal: 6, + }, + tagText: { + color: PRIMARY_COLOR, + fontSize: rf(15), + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 10, + backgroundColor: '#F0F0F0', + }, + metaInfo: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 6, + marginBottom: 10, + }, + metaText: { + fontSize: rf(14), + color: '#666', + fontWeight: '500', + }, + slider: { + width: '100%', + height: 36, + marginTop: 6, + marginBottom: 2, + }, + timeRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 4, + marginBottom: 12, + }, + time: { + fontSize: rf(13), + color: '#777', + }, + bufferingText: { + textAlign: 'center', + color: '#888', + marginBottom: 6, + fontStyle: 'italic', + fontSize: rf(14), + }, + listenButton: { + backgroundColor: BUTTON_COLOR, + paddingVertical: 14, + borderRadius: 24, + alignItems: 'center', + marginVertical: 16, + }, + listenButtonDisabled: { + backgroundColor: '#ccc', + }, + listenText: { + color: '#fff', + fontSize: rf(17), + fontWeight: '600', + }, + footerOptions: { + flexDirection: 'row', + justifyContent: 'space-evenly', + alignItems: 'center', + marginTop: 10, + marginBottom: 12, + paddingHorizontal: 12, + }, + footer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderTopWidth: 1, + borderTopColor: '#E0E0E0', + paddingTop: 12, + paddingBottom: 16, + paddingHorizontal: 12, + marginTop: 16, + gap: 10, + }, + authorContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + flex: 1, + }, + authorImage: { + height: 45, + width: 45, + borderRadius: 45, + }, + authorName: { + fontWeight: '700', + fontSize: rf(15), + }, + authorFollowers: { + fontWeight: '400', + fontSize: rf(13), + }, + followButton: { + backgroundColor: PRIMARY_COLOR, + paddingHorizontal: 12, + borderRadius: 20, + paddingVertical: 8, + }, + followButtonText: { + color: 'white', + fontSize: rf(14), + fontWeight: '600', + }, + iconContainer: { + alignItems: 'center', + justifyContent: 'center', + padding: 10, + }, + footerItem: { + flexDirection: 'row', + alignItems: 'center', + marginHorizontal: 10, + }, + + likeCount: { + marginLeft: 4, + fontSize: rf(14), + color: '#333', + }, +}); diff --git a/frontend/src/screens/OfflinePodcastList.tsx b/frontend/src/screens/OfflinePodcastList.tsx index 4e140b35..492356d3 100644 --- a/frontend/src/screens/OfflinePodcastList.tsx +++ b/frontend/src/screens/OfflinePodcastList.tsx @@ -1,153 +1,154 @@ -import React, {useEffect, useState} from 'react'; -import {FlatList, Pressable, View, StyleSheet} from 'react-native'; -import {OfflinePodcastListProp, PodcastData} from '../type'; -import {deleteFromDownloads, msToTime, readDownloadedPodcasts} from '../helper/Utils'; -import PodcastCard from '../components/PodcastCard'; -import PodcastEmptyComponent from '../components/PodcastEmptyComponent'; -import {hp} from '../helper/Metric'; -import {ON_PRIMARY_COLOR} from '../helper/Theme'; -import Snackbar from 'react-native-snackbar'; -import {useDispatch, useSelector} from 'react-redux'; -import CreatePlaylist from '../components/CreatePlaylist'; -import { setaddedPodcastId, setRemovePlaylistId } from '../store/dataSlice'; +import React, {useEffect, useState} from 'react'; +import {FlatList, Pressable, View, StyleSheet} from 'react-native'; +import {OfflinePodcastListProp, PodcastData} from '../type'; +import {deleteFromDownloads, msToTime, readDownloadedPodcasts} from '../helper/Utils'; +import PodcastCard from '../components/PodcastCard'; +import PodcastEmptyComponent from '../components/PodcastEmptyComponent'; +import {hp} from '../helper/Metric'; +import {ON_PRIMARY_COLOR} from '../helper/Theme'; +import Snackbar from 'react-native-snackbar'; +import {useDispatch, useSelector} from 'react-redux'; +import CreatePlaylist from '../components/CreatePlaylist'; +import { setaddedPodcastId, setRemovePlaylistId } from '../store/dataSlice'; import { rf } from '../helper/Metric'; -export default function OfflinePodcastList({ - navigation, -}: OfflinePodcastListProp) { - const [podcasts, setPodcasts] = useState([]); - const {user_id} = useSelector((state: any) => state.user); - const [playlistModalOpen, setPlaylistModalOpen] = useState(false); - //const [playlistIds, setPlaylistIds] = useState([]); - const dispatch = useDispatch(); - - const openPlaylist = (id: string) => { - //setPlaylistIds([id]); - setPlaylistModalOpen(true); - dispatch(setaddedPodcastId(id)); - }; - const closePlaylist = () => { - setPlaylistModalOpen(false); - // setPlaylistIds([]); - dispatch(setRemovePlaylistId('')); - }; - - - - useEffect(() => { - loadPodcasts(); - return () => {}; - }, []); - - const loadPodcasts = async () => { - try { - const data = await readDownloadedPodcasts(); - - if (!Array.isArray(data)) return; - setPodcasts(data); - } catch (err) {} - }; - - const navigateToDetail = (podcast: PodcastData) => { - navigation.navigate('OfflinePodcastDetail', { - podcast: podcast, - }); - }; - - const navigateToReport = (podcastId: string)=> { - navigation.navigate('ReportScreen', { - articleId: '', - authorId: user_id, - commentId: null, - podcastId: podcastId, - }); - }; - - - - const renderItem = ({item}: {item: any}) => ( - { - //playPodcast(item); - navigateToDetail(item); - }}> - { - // delete from downloads - const res = await deleteFromDownloads(item); - - if (res) { - Snackbar.show({ - text: 'Podcast has been removed from offline', - duration: Snackbar.LENGTH_SHORT, - }); - } else { - Snackbar.show({ - text: 'Failed to removed podcast from offline', - duration: Snackbar.LENGTH_SHORT, - }); - } - }} - handleClick={() => { - navigateToDetail(item); - }} - imageUri={''} - handleReport={() => { - navigateToReport(item._id); - }} - playlistAct={openPlaylist} - /> - - ); - return ( - - item._id.toString()} - renderItem={renderItem} - ListEmptyComponent={} - /> - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - marginTop: hp(5), - paddingTop: hp(10), - paddingHorizontal: hp(3), - backgroundColor: ON_PRIMARY_COLOR, - // backgroundColor:'#ffffff' - }, - header: { - fontSize: 24, - fontWeight: 'bold', - marginBottom: 12, - }, - item: { - paddingVertical: 12, - borderBottomColor: '#ccc', - borderBottomWidth: 1, - }, - title: { - fontSize: 16, - fontWeight: '600', - }, - artist: { - fontSize: 14, - color: '#666', - }, -}); + +export default function OfflinePodcastList({ + navigation, +}: OfflinePodcastListProp) { + const [podcasts, setPodcasts] = useState([]); + const {user_id} = useSelector((state: any) => state.user); + const [playlistModalOpen, setPlaylistModalOpen] = useState(false); + //const [playlistIds, setPlaylistIds] = useState([]); + const dispatch = useDispatch(); + + const openPlaylist = (id: string) => { + //setPlaylistIds([id]); + setPlaylistModalOpen(true); + dispatch(setaddedPodcastId(id)); + }; + const closePlaylist = () => { + setPlaylistModalOpen(false); + // setPlaylistIds([]); + dispatch(setRemovePlaylistId('')); + }; + + + + useEffect(() => { + loadPodcasts(); + return () => {}; + }, []); + + const loadPodcasts = async () => { + try { + const data = await readDownloadedPodcasts(); + + if (!Array.isArray(data)) return; + setPodcasts(data); + } catch (err) {} + }; + + const navigateToDetail = (podcast: PodcastData) => { + navigation.navigate('OfflinePodcastDetail', { + podcast: podcast, + }); + }; + + const navigateToReport = (podcastId: string)=> { + navigation.navigate('ReportScreen', { + articleId: '', + authorId: user_id, + commentId: null, + podcastId: podcastId, + }); + }; + + + + const renderItem = ({item}: {item: any}) => ( + { + //playPodcast(item); + navigateToDetail(item); + }}> + { + // delete from downloads + const res = await deleteFromDownloads(item); + + if (res) { + Snackbar.show({ + text: 'Podcast has been removed from offline', + duration: Snackbar.LENGTH_SHORT, + }); + } else { + Snackbar.show({ + text: 'Failed to removed podcast from offline', + duration: Snackbar.LENGTH_SHORT, + }); + } + }} + handleClick={() => { + navigateToDetail(item); + }} + imageUri={''} + handleReport={() => { + navigateToReport(item._id); + }} + playlistAct={openPlaylist} + /> + + ); + return ( + + item._id.toString()} + renderItem={renderItem} + ListEmptyComponent={} + /> + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + marginTop: hp(5), + paddingTop: hp(10), + paddingHorizontal: hp(3), + backgroundColor: ON_PRIMARY_COLOR, + // backgroundColor:'#ffffff' + }, + header: { + fontSize: rf(24), + fontWeight: 'bold', + marginBottom: 12, + }, + item: { + paddingVertical: 12, + borderBottomColor: '#ccc', + borderBottomWidth: 1, + }, + title: { + fontSize: rf(16), + fontWeight: '600', + }, + artist: { + fontSize: rf(14), + color: '#666', + }, +}); diff --git a/frontend/src/screens/OpenSourcePage.tsx b/frontend/src/screens/OpenSourcePage.tsx index d2b9eaaa..9ada4b58 100644 --- a/frontend/src/screens/OpenSourcePage.tsx +++ b/frontend/src/screens/OpenSourcePage.tsx @@ -1,165 +1,166 @@ -import React from 'react'; -import { ScrollView, Image, Linking } from 'react-native'; -import { - YStack, - XStack, - Text, - Card, - H3, - Separator, - Theme, - - Paragraph, - Button -} from 'tamagui'; -import { SafeAreaView } from 'react-native-safe-area-context'; -// Corrected Icon Imports -import { FontAwesome5, Ionicons, Entypo, MaterialIcons } from '@expo/vector-icons'; +import React from 'react'; +import { ScrollView, Image, Linking } from 'react-native'; +import { + YStack, + XStack, + Text, + Card, + H3, + Separator, + Theme, + + Paragraph, + Button +} from 'tamagui'; +import { SafeAreaView } from 'react-native-safe-area-context'; +// Corrected Icon Imports +import { FontAwesome5, Ionicons, Entypo, MaterialIcons } from '@expo/vector-icons'; import { rf } from '../helper/Metric'; -const PROGRAMS = [ - { - id: 4, - name: 'GirlScript Summer of Code 2026', - logo: 'https://gssoc.girlscript.org/logo.png', - description: 'A nationwide open-source program focused on helping developers contribute to real-world projects through mentorship, collaboration, and community-driven learning.', - date: 'May 2026 - Aug 2026', - type: 'Open Source Program', - link: 'https://gssoc.girlscript.org/' - }, - { - id: 1, - name: 'IEEE IGDTUW Open Source Week', - logo: 'https://github.com/user-attachments/assets/e0a40d06-f5b8-42a7-a5a0-033280f842be', - description: 'A week-long event aimed at fostering collaboration and skill-building in open-source.', - date: 'Nov 12,2025 - Nov 18, 2025', - type: 'Community Event', - link: 'https://github.com/UltimateHealth' - }, - { - id: 2, - name: 'Vultr Cloud Innovate Hackathon', - logo: 'https://github.com/user-attachments/assets/2b03167c-a598-48be-9f93-66130e58ec00', - description: "Challenges participants to harness the power of Vultr's cloud infrastructure to develop creative solutions.", - date: '', - type: 'Hackathon', - link: 'https://github.com/UltimateHealth' - }, - { - id: 3, - name: 'GirlScript Summer of Code 2024', - logo: 'https://user-images.githubusercontent.com/63473496/153487849-4f094c16-d21c-463e-9971-98a8af7ba372.png', - description: 'A three-month-long program to bring beginners into Open-Source Software Development.', - date: 'May 2024 - Aug 2024', - type: 'Open Source', - link: 'https://gssoc.girlscript.tech/' - } -]; - -const ProgramsPage = () => { - const handlePress = (url: string) => Linking.openURL(url); - - return ( - - - - - {/* Header Section */} - - - - Programs - - - Participated Open Source Programs & Hackathons - - - - - {/* Programs Cards List */} - - {PROGRAMS.map((program) => ( - handlePress(program.link)} - > - {/* Logo Section */} - - - - - {/* Content Section */} - - - -

- {program.name} -

- - - - {program.type} - - -
-
- - - {program.description} - - - {/* Info Row */} - - - - - {program.date} - - - - Online - - - - - - {/* Forward Button */} - - - - - - {/* Footer Info */} - - {filePath} - -
-
- ); -}; - -export default PodcastPlayer; - -const styles = StyleSheet.create({ - slider: { - width: '100%', - height: 36, - marginTop: 6, - marginBottom: 2, - }, - timeRow: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 4, - marginBottom: 12, - }, - time: { - fontSize: 13, - color: '#777', - }, -}); - +import {useCallback, useEffect, useState} from 'react'; +import {Alert, StyleSheet} from 'react-native'; +import {PodcastPlayerScreenProps} from '../type'; +import RNFS from 'react-native-fs'; +import {useSelector} from 'react-redux'; +import Snackbar from 'react-native-snackbar'; + +import useUploadImage from '../hooks/useUploadImage'; +import ImageResizer from '@bam.tech/react-native-image-resizer'; +import useUploadAudio from '../hooks/useUploadAudio'; +import Slider from '@react-native-community/slider'; + +import {useAudioPlayer} from 'expo-audio'; +import {Button, Circle, Theme, XStack, YStack, Text, useTheme} from 'tamagui'; +import {AntDesign, Ionicons} from '@expo/vector-icons'; +import AudioWaveform from '../components/AudioWaveform'; +import {useUploadPodcast} from '../hooks/useUploadPodcast'; +import Loader from '../components/Loader'; import { rf } from '../helper/Metric'; + + +const PLAYBACK_SPEEDS = [1, 1.25, 1.5, 2]; + +const PodcastPlayer = ({navigation, route}: PodcastPlayerScreenProps) => { + const {uploadImage, loading, error: imageError} = useUploadImage(); + const {uploadAudio, loading: audioLoading, error} = useUploadAudio(); + const [isPlaying, setIsPlaying] = useState(false); + const [position, setPosition] = useState(0); + const [speed, setSpeed] = useState(1); + + const [duration, setDuration] = useState(0); + const theme = useTheme(); + const {title, description, selectedGenres, imageUtils, filePath} = + route.params; + + //const [elapsedMs, setElapsedMs] = useState(0); + const {user_token} = useSelector((state: any) => state.user); + const {isConnected} = useSelector((state: any) => state.network); + const [amplitudes, setAmplitudes] = useState([]); + //const [currentAmplitude, setCurrentAmplitude] = useState(0); + //const timerRef = useRef(null); + + const {mutate: uploadPodcast, isPending: uploadPodcastPending} = + useUploadPodcast(); + + const player = useAudioPlayer( + filePath + ? `file://${filePath}` + : require('../../assets/sounds/funny-cartoon-sound-397415.mp3'), + ); + + const formatPlaybackSpeed = (playbackSpeed: number) => + Number.isInteger(playbackSpeed) + ? `${playbackSpeed}x` + : `${playbackSpeed}x`; + + const formatSecTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}:${mins < 10 ? '0' : ''}${mins}:${ + secs < 10 ? '0' : '' + }${secs}`; + } else { + return `${mins}:${secs < 10 ? '0' : ''}${secs}`; + } + }; + + // UI State: 'idle' | 'recording' | 'review' | 'playing' | 'paused' | 'uploading' + const [uiState, setUiState] = useState< + 'idle' | 'recording' | 'review' | 'playing' | 'paused' | 'uploading' + >('idle'); + const [uploading, setUploading] = useState(false); + + // Handle transitions + + const handlePlay = async () => { + if (!player) return; + // If the track has fully finished, restart from the beginning. + // Otherwise resume from the current paused position. + if (duration > 0 && !isNaN(duration) && position >= duration - 0.5){ + await player.seekTo(0); + setPosition(0); + } + player.play(); + setUiState('playing'); + setIsPlaying(true); + }; + + const handlePause = async () => { + console.log('Pause called'); + if (!player) return; + + player.pause(); + setUiState('paused'); + setIsPlaying(false); + }; + + const handleCycleSpeed = () => { + if (!player) return; + + const currentIndex = PLAYBACK_SPEEDS.indexOf(speed); + const nextSpeed = + PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length]; + + player.setPlaybackRate(nextSpeed, 'high'); + setSpeed(nextSpeed); + }; + + const SKIP_TIME = 5; // seconds + + const handleForward = async () => { + if (!player) return; + + let next = position + SKIP_TIME; + + if (next > duration) { + next = duration; + } + + await player.seekTo(next); + setPosition(next); + }; + + const handleBackward = async () => { + if (!player) return; + + let next = position - SKIP_TIME; + + if (next < 0) { + next = 0; + } + + await player.seekTo(next); + setPosition(next); + }; + + const unlinkFile = useCallback(async () => { + if (filePath) { + try { + const exists = await RNFS.exists(filePath); + if (exists) { + await RNFS.unlink(filePath); + console.log('File deleted:', filePath); + } + } catch (err) { + console.warn('Error deleting file:', err); + } + } + }, [filePath]); + + const handleUpload = useCallback(async () => { + setUploading(false); + setUiState('idle'); + setAmplitudes([]); + await unlinkFile(); + }, [unlinkFile]); + + const stopPlay = () => { + try { + player.remove(); + } catch (e) { + console.error('Error stopping playback:', e); + } + }; + + + const handlePostSubmit = async () => { + if (!isConnected) { + Alert.alert('Please check your internet and try again!'); + return; + } + + if (!filePath || !imageUtils) { + Alert.alert( + 'Error', + 'Please record a podcast and select an image before uploading.', + ); + return; + } + + try { + // Show confirmation alert + const confirmation = await showConfirmationAlert(); + if (!confirmation) { + //Alert.alert('Post discarded'); + await unlinkFile(); + navigation.navigate('TabNavigation'); + return; + } + + setUploading(true); + setUiState('uploading'); + + // Resize the image and handle the upload + const resizedImageUri = await resizeImage(imageUtils); + + let uploadedUrl = await uploadImage(resizedImageUri?.uri as string); + let audioUrl = await uploadAudio(filePath); + + console.log('audio', audioUrl); + console.log('Image', uploadedUrl); + + if (uploadedUrl && audioUrl) { + + uploadPodcast( + { + title: title, + description: description, + tags: selectedGenres, + article_id: null, + audio_url: audioUrl, + cover_image: uploadedUrl, + duration: player.duration as number, + }, + { + onSuccess: async data => { + await handleUpload(); + + Snackbar.show({ + text: 'Podcast submitted for review', + duration: Snackbar.LENGTH_SHORT, + }); + navigation.navigate('TabNavigation'); + }, + onError: async error => { + // Handle upload error + await handleUpload(); + Snackbar.show({ + text: 'Upload failed', + duration: Snackbar.LENGTH_SHORT, + }); + console.error('Upload error:', error); + }, + }, + ); + } else { + Alert.alert('Error', 'Could not upload the podcast. Please try again.'); + } + } catch (err) { + console.error('Image processing failed:', err); + Alert.alert('Error', 'Could not process the images.'); + await handleUpload(); + } + }; + + // Helper function to show confirmation alert + const showConfirmationAlert = () => { + return new Promise(resolve => { + Alert.alert( + 'Create Podcast', + 'Please confirm you want to upload this podcast.', + [ + { + text: 'Cancel', + onPress: () => resolve(false), + style: 'cancel', + }, + { + text: 'OK', + onPress: () => resolve(true), + }, + ], + {cancelable: false}, + ); + }); + }; + + // Helper function to resize an image + const resizeImage = async (localImage: string) => { + try { + const resizedImageUri = await ImageResizer.createResizedImage( + localImage, + 1000, // Width + 1000, // Height + 'JPEG', // Format + 100, // Quality + ); + return resizedImageUri; + } catch (err) { + console.error('Failed to resize image:', err); + // throw new Error('Image resizing failed'); + } + }; + + useEffect(() => { + if (!player) return; + + const interval = setInterval(() => { + const status = player.currentStatus; + if (status) { + setPosition(status.currentTime); + setDuration(player.duration || status.duration || 0); + } + }, 500); + + return () => clearInterval(interval); + }, [player]); + + useEffect(() => { + if (error || imageError) { + handleUpload(); + } + }, [error, handleUpload, imageError]); + + + // if(uploadPodcastPending){ + // return + // } + return ( + + + {/* Header Section */} + + + NOW PLAYING + + + {title} + + + {description} + + + + {/* Upload Button Section */} + + + + + + UPLOAD PODCAST + + + + {/* Waveform Visualization */} + {/* height={80} gives 16px of breathing room around the waveform's internal MAX_HEIGHT+16 (64px) container */} + + + + + {/* Progress Slider Section */} + + { + if (player) { + await player.seekTo(value); + setPosition(value); + } + }} + /> + + + + {formatSecTime(position)} + + + {formatSecTime(duration)} + + + + + {/* Playback Controls */} + + {/* Backward Button */} + + + + + {/* Play/Pause Button */} + { + if (player.currentStatus.playing) { + handlePause(); + } else { + handlePlay(); + } + }} + justifyContent="center" + alignItems="center"> + {player?.currentStatus.playing ? ( + + ) : ( + + )} + + + {/* Playback Speed Button */} + + + {/* Forward Button */} + + + + + + {/* Footer Info */} + + {filePath} + + + + ); +}; + +export default PodcastPlayer; + +const styles = StyleSheet.create({ + slider: { + width: '100%', + height: 36, + marginTop: 6, + marginBottom: 2, + }, + timeRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 4, + marginBottom: 12, + }, + time: { + fontSize: rf(13), + color: '#777', + }, +}); + diff --git a/frontend/src/screens/PodcastProfile.tsx b/frontend/src/screens/PodcastProfile.tsx index 0ac33779..3011f789 100644 --- a/frontend/src/screens/PodcastProfile.tsx +++ b/frontend/src/screens/PodcastProfile.tsx @@ -1,541 +1,542 @@ -import React, {useCallback, useEffect, useState} from 'react'; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, - Image, - FlatList, - RefreshControl, - Dimensions, -} from 'react-native'; -import {PodcastProfileProp, PodcastData, PlayList} from '../type'; -import {MaterialCommunityIcons, Feather, Ionicons} from '@expo/vector-icons'; -import {PRIMARY_COLOR} from '../helper/Theme'; -import {useSelector} from 'react-redux'; -import {useFocusEffect} from '@react-navigation/native'; -import {useGetPlaylists} from '../hooks/useGetPlaylists'; -import {useGetUserPublishedPodcasts} from '../hooks/useGetUserPublishedPodcasts'; -import Loader from '../components/Loader'; -import PodcastCard from '../components/PodcastCard'; -import {GET_STORAGE_DATA} from '../helper/APIUtils'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import {NoPodcastState} from '../components/EmptyStates'; -import {useGetProfile} from '../hooks/useGetProfile'; -import {downloadAudio, msToTime} from '../helper/Utils'; -import Snackbar from 'react-native-snackbar'; +import React, {useCallback, useEffect, useState} from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Image, + FlatList, + RefreshControl, + Dimensions, +} from 'react-native'; +import {PodcastProfileProp, PodcastData, PlayList} from '../type'; +import {MaterialCommunityIcons, Feather, Ionicons} from '@expo/vector-icons'; +import {PRIMARY_COLOR} from '../helper/Theme'; +import {useSelector} from 'react-redux'; +import {useFocusEffect} from '@react-navigation/native'; +import {useGetPlaylists} from '../hooks/useGetPlaylists'; +import {useGetUserPublishedPodcasts} from '../hooks/useGetUserPublishedPodcasts'; +import Loader from '../components/Loader'; +import PodcastCard from '../components/PodcastCard'; +import {GET_STORAGE_DATA} from '../helper/APIUtils'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {NoPodcastState} from '../components/EmptyStates'; +import {useGetProfile} from '../hooks/useGetProfile'; +import {downloadAudio, msToTime} from '../helper/Utils'; +import Snackbar from 'react-native-snackbar'; import { rf } from '../helper/Metric'; -const {width} = Dimensions.get('window'); - -export default function PodcastProfile({navigation}: PodcastProfileProp) { - const insets = useSafeAreaInsets(); - const [selectedTab, setSelectedTab] = useState<'podcasts' | 'playlists'>( - 'podcasts', - ); - const [refreshing, setRefreshing] = useState(false); - const {user_name, Profile_image, user_handle} = useSelector( - (state: any) => state.user, - ); - const {isConnected} = useSelector((state: any) => state.network); - - const [publishedPage, setPublishedPage] = useState(1); - const [totalPublishPages, setTotalPublishPages] = useState(0); - const [podcasts, setPublishedPodcasts] = useState([]); - - const { - data: playlistsData, - refetch: refetchPlaylists, - isLoading: playlistsLoading, - } = useGetPlaylists(); - - const { - data: podcastsData, - refetch: refetchPodcasts, - isLoading: podcastsLoading, - } = useGetUserPublishedPodcasts(publishedPage, isConnected); - - const {data: user} = useGetProfile(); - - useEffect(() => { - if (podcastsData) { - if (Number(publishedPage) === 1 && podcastsData.totalPages) { - let totalPage = podcastsData.totalPages; - setTotalPublishPages(totalPage); - - setPublishedPodcasts(podcastsData.publishedPodcasts); - } else { - if (podcastsData.publishedPodcasts) { - let oldPodcasts = - (podcasts as PodcastData[]) ?? ([] as PodcastData[]); - let newPodcasts = podcastsData.publishedPodcasts as PodcastData[]; - setPublishedPodcasts([...oldPodcasts, ...newPodcasts]); - } - } - } - }, [podcastsData, publishedPage]); - - const playlists = playlistsData || []; - - const totalPlaylists = playlists.length; - - useFocusEffect( - useCallback(() => { - refetchPlaylists(); - refetchPodcasts(); - }, [refetchPlaylists, refetchPodcasts]), - ); - - const onRefresh = async () => { - setRefreshing(true); - setPublishedPage(1); - await Promise.all([refetchPlaylists(), refetchPodcasts()]); - setRefreshing(false); - }; - - const renderPlaylistItem = ({item}: {item: PlayList}) => ( - { - - }}> - - - - - - {item.title} - - - {Array.isArray(item.podcasts) ? item.podcasts.length : 0} podcasts - - - - - ); - - const renderPodcastItem = ({item}: {item: PodcastData}) => ( - - { - if (isConnected) { - await downloadAudio(item); - } else { - Snackbar.show({ - text: 'Internet connection required', - duration: Snackbar.LENGTH_SHORT, - }); - } - }} - handleClick={() => { - navigation.navigate('PodcastDetail', { - trackId: item._id, - audioUrl: item.audio_url, - }); - }} - imageUri={item.cover_image} - handleReport={() => {}} - playlistAct={() => { - setSelectedTab('playlists'); - }} - /> - - ); - - if (playlistsLoading && podcastsLoading) { - return ; - } - - return ( - - - } - contentContainerStyle={[ - styles.scrollContent, - {paddingBottom: insets.bottom + 20}, - ]}> - {/* Profile Header */} - - - {Profile_image ? ( - - ) : ( - - - - )} - - {user?.user_name || 'User'} - - @{user?.user_handle || 'user'} - - - {/* Stats */} - - - {podcasts?.length} - Podcasts - - - - {totalPlaylists} - Playlists - - - - navigation.navigate('OverviewScreen')}> - - Visit Workspace - - {/* Create Podcast Button */} - navigation.navigate('PodcastForm')}> - - Create Podcast - - - - {/* Tab Selector */} - - setSelectedTab('podcasts')} - activeOpacity={0.7}> - - - Podcasts - - - - setSelectedTab('playlists')} - activeOpacity={0.7}> - - - Playlists - - - - - {/* Content */} - - {selectedTab === 'podcasts' ? ( - podcasts && podcasts.length > 0 ? ( - `podcast-${item._id}-${index}`} - scrollEnabled={false} - showsVerticalScrollIndicator={false} - refreshing={refreshing} - onRefresh={onRefresh} - ListEmptyComponent={} - onEndReached={() => { - if (publishedPage < totalPublishPages) { - setPublishedPage(prev => prev + 1); - } - }} - onEndReachedThreshold={0.5} - /> - ) : ( - - - No podcasts yet - - Create your first podcast to share with the world - - navigation.navigate('PodcastForm')}> - Create Podcast - - - ) - ) : playlists.length > 0 ? ( - `playlist-${item._id}-${index}`} - scrollEnabled={false} - showsVerticalScrollIndicator={false} - /> - ) : ( - - - No playlists yet - - Save podcasts to create your first playlist - - - )} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#f5f5f5', - }, - scrollContent: { - flexGrow: 1, - }, - profileHeader: { - backgroundColor: '#ffffff', - paddingTop: 32, - paddingBottom: 24, - paddingHorizontal: 20, - alignItems: 'center', - borderBottomLeftRadius: 24, - borderBottomRightRadius: 24, - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.08, - shadowRadius: 8, - elevation: 4, - }, - profileImageContainer: { - marginBottom: 16, - }, - profileImage: { - width: 100, - height: 100, - borderRadius: 50, - borderWidth: 4, - borderColor: PRIMARY_COLOR, - }, - profileImagePlaceholder: { - backgroundColor: '#e5e7eb', - justifyContent: 'center', - alignItems: 'center', - }, - profileName: { - fontSize: 24, - fontWeight: '700', - color: '#1a1a1a', - marginBottom: 4, - }, - profileHandle: { - fontSize: 15, - color: '#6b7280', - marginBottom: 20, - }, - statsContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginBottom: 20, - paddingHorizontal: 40, - }, - statItem: { - alignItems: 'center', - paddingHorizontal: 24, - }, - statValue: { - fontSize: 22, - fontWeight: '700', - color: PRIMARY_COLOR, - marginBottom: 4, - }, - statLabel: { - fontSize: 13, - color: '#6b7280', - fontWeight: '500', - }, - statDivider: { - width: 1, - height: 40, - backgroundColor: '#e5e7eb', - }, - createButton: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: PRIMARY_COLOR, - paddingVertical: 12, - paddingHorizontal: 24, - borderRadius: 24, - shadowColor: PRIMARY_COLOR, - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 4, - gap: 8, - }, - createButtonText: { - color: '#ffffff', - fontSize: 15, - fontWeight: '700', - }, - tabContainer: { - flexDirection: 'row', - marginHorizontal: 16, - marginTop: 20, - marginBottom: 16, - backgroundColor: '#ffffff', - borderRadius: 12, - padding: 4, - shadowColor: '#000', - shadowOffset: {width: 0, height: 1}, - shadowOpacity: 0.05, - shadowRadius: 4, - elevation: 2, - }, - tab: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 10, - gap: 8, - }, - activeTab: { - backgroundColor: `${PRIMARY_COLOR}15`, - }, - tabText: { - fontSize: 15, - fontWeight: '600', - color: '#6b7280', - }, - activeTabText: { - color: PRIMARY_COLOR, - }, - contentContainer: { - paddingHorizontal: 16, - }, - playlistCard: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#ffffff', - borderRadius: 12, - padding: 16, - marginBottom: 12, - shadowColor: '#000', - shadowOffset: {width: 0, height: 1}, - shadowOpacity: 0.08, - shadowRadius: 4, - elevation: 2, - }, - playlistIconContainer: { - width: 56, - height: 56, - borderRadius: 12, - backgroundColor: `${PRIMARY_COLOR}10`, - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }, - playlistInfo: { - flex: 1, - marginRight: 8, - }, - playlistTitle: { - fontSize: 16, - fontWeight: '600', - color: '#1a1a1a', - marginBottom: 4, - }, - playlistCount: { - fontSize: 13, - color: '#6b7280', - }, - podcastCardWrapper: { - marginBottom: 12, - }, - emptyState: { - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 60, - paddingHorizontal: 32, - }, - emptyText: { - fontSize: 18, - fontWeight: '600', - color: '#374151', - marginTop: 16, - marginBottom: 8, - }, - emptySubText: { - fontSize: 14, - color: '#6b7280', - textAlign: 'center', - lineHeight: 20, - marginBottom: 24, - }, - emptyButton: { - backgroundColor: PRIMARY_COLOR, - paddingVertical: 12, - paddingHorizontal: 24, - borderRadius: 20, - }, - emptyButtonText: { - color: '#ffffff', - fontSize: 14, - fontWeight: '600', - }, -}); + +const {width} = Dimensions.get('window'); + +export default function PodcastProfile({navigation}: PodcastProfileProp) { + const insets = useSafeAreaInsets(); + const [selectedTab, setSelectedTab] = useState<'podcasts' | 'playlists'>( + 'podcasts', + ); + const [refreshing, setRefreshing] = useState(false); + const {user_name, Profile_image, user_handle} = useSelector( + (state: any) => state.user, + ); + const {isConnected} = useSelector((state: any) => state.network); + + const [publishedPage, setPublishedPage] = useState(1); + const [totalPublishPages, setTotalPublishPages] = useState(0); + const [podcasts, setPublishedPodcasts] = useState([]); + + const { + data: playlistsData, + refetch: refetchPlaylists, + isLoading: playlistsLoading, + } = useGetPlaylists(); + + const { + data: podcastsData, + refetch: refetchPodcasts, + isLoading: podcastsLoading, + } = useGetUserPublishedPodcasts(publishedPage, isConnected); + + const {data: user} = useGetProfile(); + + useEffect(() => { + if (podcastsData) { + if (Number(publishedPage) === 1 && podcastsData.totalPages) { + let totalPage = podcastsData.totalPages; + setTotalPublishPages(totalPage); + + setPublishedPodcasts(podcastsData.publishedPodcasts); + } else { + if (podcastsData.publishedPodcasts) { + let oldPodcasts = + (podcasts as PodcastData[]) ?? ([] as PodcastData[]); + let newPodcasts = podcastsData.publishedPodcasts as PodcastData[]; + setPublishedPodcasts([...oldPodcasts, ...newPodcasts]); + } + } + } + }, [podcastsData, publishedPage]); + + const playlists = playlistsData || []; + + const totalPlaylists = playlists.length; + + useFocusEffect( + useCallback(() => { + refetchPlaylists(); + refetchPodcasts(); + }, [refetchPlaylists, refetchPodcasts]), + ); + + const onRefresh = async () => { + setRefreshing(true); + setPublishedPage(1); + await Promise.all([refetchPlaylists(), refetchPodcasts()]); + setRefreshing(false); + }; + + const renderPlaylistItem = ({item}: {item: PlayList}) => ( + { + + }}> + + + + + + {item.title} + + + {Array.isArray(item.podcasts) ? item.podcasts.length : 0} podcasts + + + + + ); + + const renderPodcastItem = ({item}: {item: PodcastData}) => ( + + { + if (isConnected) { + await downloadAudio(item); + } else { + Snackbar.show({ + text: 'Internet connection required', + duration: Snackbar.LENGTH_SHORT, + }); + } + }} + handleClick={() => { + navigation.navigate('PodcastDetail', { + trackId: item._id, + audioUrl: item.audio_url, + }); + }} + imageUri={item.cover_image} + handleReport={() => {}} + playlistAct={() => { + setSelectedTab('playlists'); + }} + /> + + ); + + if (playlistsLoading && podcastsLoading) { + return ; + } + + return ( + + + } + contentContainerStyle={[ + styles.scrollContent, + {paddingBottom: insets.bottom + 20}, + ]}> + {/* Profile Header */} + + + {Profile_image ? ( + + ) : ( + + + + )} + + {user?.user_name || 'User'} + + @{user?.user_handle || 'user'} + + + {/* Stats */} + + + {podcasts?.length} + Podcasts + + + + {totalPlaylists} + Playlists + + + + navigation.navigate('OverviewScreen')}> + + Visit Workspace + + {/* Create Podcast Button */} + navigation.navigate('PodcastForm')}> + + Create Podcast + + + + {/* Tab Selector */} + + setSelectedTab('podcasts')} + activeOpacity={0.7}> + + + Podcasts + + + + setSelectedTab('playlists')} + activeOpacity={0.7}> + + + Playlists + + + + + {/* Content */} + + {selectedTab === 'podcasts' ? ( + podcasts && podcasts.length > 0 ? ( + `podcast-${item._id}-${index}`} + scrollEnabled={false} + showsVerticalScrollIndicator={false} + refreshing={refreshing} + onRefresh={onRefresh} + ListEmptyComponent={} + onEndReached={() => { + if (publishedPage < totalPublishPages) { + setPublishedPage(prev => prev + 1); + } + }} + onEndReachedThreshold={0.5} + /> + ) : ( + + + No podcasts yet + + Create your first podcast to share with the world + + navigation.navigate('PodcastForm')}> + Create Podcast + + + ) + ) : playlists.length > 0 ? ( + `playlist-${item._id}-${index}`} + scrollEnabled={false} + showsVerticalScrollIndicator={false} + /> + ) : ( + + + No playlists yet + + Save podcasts to create your first playlist + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + scrollContent: { + flexGrow: 1, + }, + profileHeader: { + backgroundColor: '#ffffff', + paddingTop: 32, + paddingBottom: 24, + paddingHorizontal: 20, + alignItems: 'center', + borderBottomLeftRadius: 24, + borderBottomRightRadius: 24, + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.08, + shadowRadius: 8, + elevation: 4, + }, + profileImageContainer: { + marginBottom: 16, + }, + profileImage: { + width: 100, + height: 100, + borderRadius: 50, + borderWidth: 4, + borderColor: PRIMARY_COLOR, + }, + profileImagePlaceholder: { + backgroundColor: '#e5e7eb', + justifyContent: 'center', + alignItems: 'center', + }, + profileName: { + fontSize: rf(24), + fontWeight: '700', + color: '#1a1a1a', + marginBottom: 4, + }, + profileHandle: { + fontSize: rf(15), + color: '#6b7280', + marginBottom: 20, + }, + statsContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 20, + paddingHorizontal: 40, + }, + statItem: { + alignItems: 'center', + paddingHorizontal: 24, + }, + statValue: { + fontSize: rf(22), + fontWeight: '700', + color: PRIMARY_COLOR, + marginBottom: 4, + }, + statLabel: { + fontSize: rf(13), + color: '#6b7280', + fontWeight: '500', + }, + statDivider: { + width: 1, + height: 40, + backgroundColor: '#e5e7eb', + }, + createButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: PRIMARY_COLOR, + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 24, + shadowColor: PRIMARY_COLOR, + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 4, + gap: 8, + }, + createButtonText: { + color: '#ffffff', + fontSize: rf(15), + fontWeight: '700', + }, + tabContainer: { + flexDirection: 'row', + marginHorizontal: 16, + marginTop: 20, + marginBottom: 16, + backgroundColor: '#ffffff', + borderRadius: 12, + padding: 4, + shadowColor: '#000', + shadowOffset: {width: 0, height: 1}, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 2, + }, + tab: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 10, + gap: 8, + }, + activeTab: { + backgroundColor: `${PRIMARY_COLOR}15`, + }, + tabText: { + fontSize: rf(15), + fontWeight: '600', + color: '#6b7280', + }, + activeTabText: { + color: PRIMARY_COLOR, + }, + contentContainer: { + paddingHorizontal: 16, + }, + playlistCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#ffffff', + borderRadius: 12, + padding: 16, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: {width: 0, height: 1}, + shadowOpacity: 0.08, + shadowRadius: 4, + elevation: 2, + }, + playlistIconContainer: { + width: 56, + height: 56, + borderRadius: 12, + backgroundColor: `${PRIMARY_COLOR}10`, + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + playlistInfo: { + flex: 1, + marginRight: 8, + }, + playlistTitle: { + fontSize: rf(16), + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 4, + }, + playlistCount: { + fontSize: rf(13), + color: '#6b7280', + }, + podcastCardWrapper: { + marginBottom: 12, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + paddingHorizontal: 32, + }, + emptyText: { + fontSize: rf(18), + fontWeight: '600', + color: '#374151', + marginTop: 16, + marginBottom: 8, + }, + emptySubText: { + fontSize: rf(14), + color: '#6b7280', + textAlign: 'center', + lineHeight: 20, + marginBottom: 24, + }, + emptyButton: { + backgroundColor: PRIMARY_COLOR, + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 20, + }, + emptyButtonText: { + color: '#ffffff', + fontSize: rf(14), + fontWeight: '600', + }, +}); diff --git a/frontend/src/screens/PodcastRecorder.tsx b/frontend/src/screens/PodcastRecorder.tsx index 2d76b965..c2f4a974 100644 --- a/frontend/src/screens/PodcastRecorder.tsx +++ b/frontend/src/screens/PodcastRecorder.tsx @@ -1,589 +1,590 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {StyleSheet, Alert} from 'react-native'; - -import {PodcastRecorderScreenProps} from '../type'; -import RNFS from 'react-native-fs'; - -import Icon from '@expo/vector-icons/MaterialCommunityIcons'; - -//const {AudioModule} = NativeModules; -//const AudioModule = NativeModules.AudioModule; -//const emitter = new NativeEventEmitter(AudioModule); - -import { - useAudioRecorder, - AudioModule, - RecordingPresets, - setAudioModeAsync, - useAudioRecorderState, -} from 'expo-audio'; -import audioModule from '@/modules/audio-module'; -import {useFocusEffect} from '@react-navigation/native'; -import {Circle, Theme, XStack, YStack, Text} from 'tamagui'; -import LottieView from 'lottie-react-native'; -import {useDispatch} from 'react-redux'; -import {requestStoragePermissions} from '../helper/Utils'; - -//const AudioModule = requireNativeModule('AudioModule'); - -const PodcastRecorder = ({navigation, route}: PodcastRecorderScreenProps) => { - const [recording, setRecording] = useState(false); - const dispatch = useDispatch(); - - const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); - const recorderState = useAudioRecorderState(audioRecorder); - const [recordTime, setRecordTime] = useState('00:00:00'); - const {title, description, selectedGenres, imageUtils} = route.params; - const [filePath, setFilePath] = useState(null); - - const [amplitudes, setAmplitudes] = useState([]); - - const timerRef = useRef(null); - const recordStartTimeRef = useRef(null); - - useFocusEffect( - useCallback(() => { - handleUpload(); - }, []), - ); - - const record = async () => { - await audioRecorder.prepareToRecordAsync(); - audioRecorder.record(); - startTimer(); - }; - - const stopRecording = async () => { - // The recording will be available on `audioRecorder.uri`. - await audioRecorder.stop(); - setRecording(false); - stopTimer(); - setFilePath(audioModule.uri); - }; - - useEffect(() => { - (async () => { - const status = await AudioModule.requestRecordingPermissionsAsync(); - if (!status.granted) { - Alert.alert('Permission to access microphone was denied'); - - } - - const storageGranted = await requestStoragePermissions(); - if (!storageGranted) { - return; - } - - setAudioModeAsync({ - playsInSilentMode: true, - allowsRecording: true, - }); - })(); - }, []); - - const formatTime = (ms: number) => { - const totalSec = Math.floor(ms / 1000); - const hours = String(Math.floor(totalSec / 3600)).padStart(2, '0'); - const minutes = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0'); - const seconds = String(totalSec % 60).padStart(2, '0'); - return `${hours}:${minutes}:${seconds}`; - }; - - const startTimer = () => { - recordStartTimeRef.current = Date.now(); - - timerRef.current = setInterval(() => { - if (!recordStartTimeRef.current) return; - - const elapsed = Date.now() - recordStartTimeRef.current; - setRecordTime(formatTime(elapsed)); - }, 1000) as any; - }; - - const stopTimer = () => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - recordStartTimeRef.current = null; - }; - - // const startRecording = async () => { - // if (Platform.OS === 'android') { - // ]); - // if ( - // ) { - // return; - // } - // } - - // try { - // const path: string = await AudioModule.startRecording(); - // console.log('File path', path); - // setFilePath(path); - // setRecording(true); - // //setElapsedMs(0); - // startTimer(); - // } catch (e) { - // console.error('Failed to start recording:', e); - // } - // }; - - // UI State: 'idle' | 'recording' | 'review' | 'playing' | 'paused' | 'uploading' - - const [uiState, setUiState] = useState< - 'idle' | 'recording' | 'review' | 'playing' | 'paused' | 'uploading' - >('idle'); - const [uploading, setUploading] = useState(false); - - // Handle transitions - const handleStartRecording = async () => { - await record(); - setUiState('recording'); - setRecording(true); - }; - - const handleStopRecording = async () => { - await stopRecording(); - setUiState('review'); - }; - - const handleReRecord = async () => { - if (filePath) { - try { - const exists = await RNFS.exists(filePath); - if (exists) { - await RNFS.unlink(filePath); - console.log('File deleted:', filePath); - } - } catch (err) { - console.warn('Error deleting file:', err); - } - } - setFilePath(null); - // unlink file path - setAmplitudes([]); - setRecordTime('00:00:00'); - setUiState('idle'); - }; - - const unlinkFile = useCallback(async () => { - if (filePath) { - try { - const exists = await RNFS.exists(filePath); - if (exists) { - await RNFS.unlink(filePath); - console.log('File deleted:', filePath); - } - } catch (err) { - console.warn('Error deleting file:', err); - } - } - }, [filePath]); - - const handleUpload = useCallback(async () => { - setUploading(false); - setUiState('idle'); - setFilePath(null); - setAmplitudes([]); - setRecordTime('00:00:00'); - await unlinkFile(); - }, [unlinkFile]); - - // useEffect(() => { - // // const stopSub = AudioModule.addListener('recStop', (data:any) => { - // // console.log('File saved at:', data.filePath); - // // setFilePath(data.filePath); - // // setRecording(false); - // // }); - - // const updateSub = DeviceEventEmitter.addListener('recUpdate', data => { - // //setElapsedMs(Math.floor(data.elapsedMs / 1000)); - // }); - - // const audioWaveSubscription = AudioModule.addListener( - // 'onAudioWaveform', - // event => { - // /* - // const amplitude = event.amplitude; - // // setCurrentAmplitude(amplitude); - // //console.log('event',event); - // const scaled = Math.min(1, amplitude * 6); - // if (scaled >= 1) { - // setAmplitudes(prev => { - // const updated = [...prev, scaled]; - // if (updated.length > 100) { - // // To maintained wave array length 100 - // updated.shift(); - // } - // return updated; - // }); - // } - // */ - // //console.log('amplitudes', amplitudes); - // }, - // ); - - // return () => { - - // updateSub.remove(); - // audioWaveSubscription.remove(); - // }; - // }, []); - - useEffect(() => { - return () => { - stopTimer(); - }; - }, []); - - return ( - - - {/* Header Section */} - - - Podcast Studio - - - Record your voice with studio quality - - - - {/* Microphone Icon with Animation */} - - - - - - - {/* Timer Display */} - - - {recordTime} - - - {uiState === 'recording' ? 'RECORDING IN PROGRESS' : uiState === 'review' ? 'RECORDING COMPLETE' : 'READY TO RECORD'} - - - - {/* Waveform Animation */} - {recording && ( - - - - )} - - {/* Action Controls */} - - {/* Play Preview Button */} - {uiState === 'review' && ( - - - navigation.navigate('PodcastPlayer', { - title, - description, - imageUtils, - selectedGenres, - filePath: audioRecorder.uri || '', - }) - }> - - - - PLAY - - - )} - - {/* Main Record/Stop Button */} - - - - - - - - {uiState === 'recording' ? 'STOP' : 'RECORD'} - - - - {/* Re-record Button */} - {uiState === 'review' && ( - - - - - - RETRY - - - )} - - - {/* Article Title Display */} - - - PODCAST TITLE - - - {title} - - - - - ); -}; - -export default PodcastRecorder; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#0f172a', - alignItems: 'center', - justifyContent: 'center', - padding: 24, - }, - title: { - fontSize: 28, - color: '#f8fafc', - marginBottom: 20, - fontWeight: 'bold', - textAlign: 'center', - }, - timer: { - fontSize: 42, - color: '#38bdf8', - marginVertical: 20, - fontVariant: ['tabular-nums'], - letterSpacing: 1, - }, - waveContainer: { - height: 80, - width: '100%', - alignSelf: 'center', - marginVertical: 16, - }, - actionRow: { - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'center', - gap: 16, - marginTop: 24, - }, - - // Shared circular style - circularButton: { - width: 64, - height: 64, - borderRadius: 32, - justifyContent: 'center', - alignItems: 'center', - padding: 10, - elevation: 3, - shadowColor: '#000', - shadowOffset: {width: 0, height: 1}, - shadowOpacity: 0.2, - shadowRadius: 3, - }, - - // Rectangular style for stop/pause - rectButton: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 14, - paddingHorizontal: 28, - borderRadius: 12, - elevation: 3, - shadowColor: '#000', - shadowOffset: {width: 0, height: 1}, - shadowOpacity: 0.2, - shadowRadius: 3, - }, - - // Colors - record: {backgroundColor: '#16a34a'}, - stop: {backgroundColor: '#dc2626'}, - play: {backgroundColor: '#3b82f6'}, - pause: {backgroundColor: '#f59e0b'}, - rerecord: {backgroundColor: '#0284c7'}, - upload: {backgroundColor: '#7c3aed'}, - - buttonText: { - color: '#f8fafc', - fontSize: 12, - fontWeight: '600', - textAlign: 'center', - marginTop: 4, - }, - - pathText: { - marginTop: 20, - fontSize: 14, - color: '#cbd5e1', - textAlign: 'center', - }, - - // Mic styles - iconContainer: { - alignItems: 'center', - justifyContent: 'center', - marginVertical: 16, - }, - micButton: { - width: 120, - height: 120, - borderRadius: 60, - backgroundColor: '#1e293b', - alignItems: 'center', - justifyContent: 'center', - elevation: 6, - shadowColor: '#000', - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.25, - shadowRadius: 6, - }, - micButtonActive: { - backgroundColor: '#38bdf8', - }, - micOuterCircle: { - width: 100, - height: 100, - borderRadius: 50, - backgroundColor: '#334155', - alignItems: 'center', - justifyContent: 'center', - }, - micInnerCircle: { - width: 60, - height: 60, - borderRadius: 30, - backgroundColor: '#f8fafc', - alignItems: 'center', - justifyContent: 'center', - }, - micBody: { - width: 18, - height: 28, - borderRadius: 9, - backgroundColor: '#38bdf8', - marginBottom: 2, - }, - micStem: { - width: 4, - height: 10, - borderRadius: 2, - backgroundColor: '#38bdf8', - marginTop: 2, - }, - micPulse: { - position: 'absolute', - top: -15, - left: -15, - width: 150, - height: 150, - borderRadius: 75, - borderWidth: 2, - borderColor: '#38bdf8', - opacity: 0.4, - }, - - actionButtonText: { - color: '#f8fafc', - fontSize: 10, - fontWeight: '600', - textAlign: 'center', - //marginTop: 2, - letterSpacing: 0.5, - }, - - slider: { - width: '100%', - height: 36, - marginTop: 6, - marginBottom: 2, - }, - - timeRow: { - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 4, - marginBottom: 12, - }, - time: { - fontSize: 13, - color: '#777', - }, -}); +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {StyleSheet, Alert} from 'react-native'; + +import {PodcastRecorderScreenProps} from '../type'; +import RNFS from 'react-native-fs'; + +import Icon from '@expo/vector-icons/MaterialCommunityIcons'; + +//const {AudioModule} = NativeModules; +//const AudioModule = NativeModules.AudioModule; +//const emitter = new NativeEventEmitter(AudioModule); + +import { + useAudioRecorder, + AudioModule, + RecordingPresets, + setAudioModeAsync, + useAudioRecorderState, +} from 'expo-audio'; +import audioModule from '@/modules/audio-module'; +import {useFocusEffect} from '@react-navigation/native'; +import {Circle, Theme, XStack, YStack, Text} from 'tamagui'; +import LottieView from 'lottie-react-native'; +import {useDispatch} from 'react-redux'; +import {requestStoragePermissions} from '../helper/Utils'; import { rf } from '../helper/Metric'; + + +//const AudioModule = requireNativeModule('AudioModule'); + +const PodcastRecorder = ({navigation, route}: PodcastRecorderScreenProps) => { + const [recording, setRecording] = useState(false); + const dispatch = useDispatch(); + + const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); + const recorderState = useAudioRecorderState(audioRecorder); + const [recordTime, setRecordTime] = useState('00:00:00'); + const {title, description, selectedGenres, imageUtils} = route.params; + const [filePath, setFilePath] = useState(null); + + const [amplitudes, setAmplitudes] = useState([]); + + const timerRef = useRef(null); + const recordStartTimeRef = useRef(null); + + useFocusEffect( + useCallback(() => { + handleUpload(); + }, []), + ); + + const record = async () => { + await audioRecorder.prepareToRecordAsync(); + audioRecorder.record(); + startTimer(); + }; + + const stopRecording = async () => { + // The recording will be available on `audioRecorder.uri`. + await audioRecorder.stop(); + setRecording(false); + stopTimer(); + setFilePath(audioModule.uri); + }; + + useEffect(() => { + (async () => { + const status = await AudioModule.requestRecordingPermissionsAsync(); + if (!status.granted) { + Alert.alert('Permission to access microphone was denied'); + + } + + const storageGranted = await requestStoragePermissions(); + if (!storageGranted) { + return; + } + + setAudioModeAsync({ + playsInSilentMode: true, + allowsRecording: true, + }); + })(); + }, []); + + const formatTime = (ms: number) => { + const totalSec = Math.floor(ms / 1000); + const hours = String(Math.floor(totalSec / 3600)).padStart(2, '0'); + const minutes = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0'); + const seconds = String(totalSec % 60).padStart(2, '0'); + return `${hours}:${minutes}:${seconds}`; + }; + + const startTimer = () => { + recordStartTimeRef.current = Date.now(); + + timerRef.current = setInterval(() => { + if (!recordStartTimeRef.current) return; + + const elapsed = Date.now() - recordStartTimeRef.current; + setRecordTime(formatTime(elapsed)); + }, 1000) as any; + }; + + const stopTimer = () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + recordStartTimeRef.current = null; + }; + + // const startRecording = async () => { + // if (Platform.OS === 'android') { + // ]); + // if ( + // ) { + // return; + // } + // } + + // try { + // const path: string = await AudioModule.startRecording(); + // console.log('File path', path); + // setFilePath(path); + // setRecording(true); + // //setElapsedMs(0); + // startTimer(); + // } catch (e) { + // console.error('Failed to start recording:', e); + // } + // }; + + // UI State: 'idle' | 'recording' | 'review' | 'playing' | 'paused' | 'uploading' + + const [uiState, setUiState] = useState< + 'idle' | 'recording' | 'review' | 'playing' | 'paused' | 'uploading' + >('idle'); + const [uploading, setUploading] = useState(false); + + // Handle transitions + const handleStartRecording = async () => { + await record(); + setUiState('recording'); + setRecording(true); + }; + + const handleStopRecording = async () => { + await stopRecording(); + setUiState('review'); + }; + + const handleReRecord = async () => { + if (filePath) { + try { + const exists = await RNFS.exists(filePath); + if (exists) { + await RNFS.unlink(filePath); + console.log('File deleted:', filePath); + } + } catch (err) { + console.warn('Error deleting file:', err); + } + } + setFilePath(null); + // unlink file path + setAmplitudes([]); + setRecordTime('00:00:00'); + setUiState('idle'); + }; + + const unlinkFile = useCallback(async () => { + if (filePath) { + try { + const exists = await RNFS.exists(filePath); + if (exists) { + await RNFS.unlink(filePath); + console.log('File deleted:', filePath); + } + } catch (err) { + console.warn('Error deleting file:', err); + } + } + }, [filePath]); + + const handleUpload = useCallback(async () => { + setUploading(false); + setUiState('idle'); + setFilePath(null); + setAmplitudes([]); + setRecordTime('00:00:00'); + await unlinkFile(); + }, [unlinkFile]); + + // useEffect(() => { + // // const stopSub = AudioModule.addListener('recStop', (data:any) => { + // // console.log('File saved at:', data.filePath); + // // setFilePath(data.filePath); + // // setRecording(false); + // // }); + + // const updateSub = DeviceEventEmitter.addListener('recUpdate', data => { + // //setElapsedMs(Math.floor(data.elapsedMs / 1000)); + // }); + + // const audioWaveSubscription = AudioModule.addListener( + // 'onAudioWaveform', + // event => { + // /* + // const amplitude = event.amplitude; + // // setCurrentAmplitude(amplitude); + // //console.log('event',event); + // const scaled = Math.min(1, amplitude * 6); + // if (scaled >= 1) { + // setAmplitudes(prev => { + // const updated = [...prev, scaled]; + // if (updated.length > 100) { + // // To maintained wave array length 100 + // updated.shift(); + // } + // return updated; + // }); + // } + // */ + // //console.log('amplitudes', amplitudes); + // }, + // ); + + // return () => { + + // updateSub.remove(); + // audioWaveSubscription.remove(); + // }; + // }, []); + + useEffect(() => { + return () => { + stopTimer(); + }; + }, []); + + return ( + + + {/* Header Section */} + + + Podcast Studio + + + Record your voice with studio quality + + + + {/* Microphone Icon with Animation */} + + + + + + + {/* Timer Display */} + + + {recordTime} + + + {uiState === 'recording' ? 'RECORDING IN PROGRESS' : uiState === 'review' ? 'RECORDING COMPLETE' : 'READY TO RECORD'} + + + + {/* Waveform Animation */} + {recording && ( + + + + )} + + {/* Action Controls */} + + {/* Play Preview Button */} + {uiState === 'review' && ( + + + navigation.navigate('PodcastPlayer', { + title, + description, + imageUtils, + selectedGenres, + filePath: audioRecorder.uri || '', + }) + }> + + + + PLAY + + + )} + + {/* Main Record/Stop Button */} + + + + + + + + {uiState === 'recording' ? 'STOP' : 'RECORD'} + + + + {/* Re-record Button */} + {uiState === 'review' && ( + + + + + + RETRY + + + )} + + + {/* Article Title Display */} + + + PODCAST TITLE + + + {title} + + + + + ); +}; + +export default PodcastRecorder; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0f172a', + alignItems: 'center', + justifyContent: 'center', + padding: 24, + }, + title: { + fontSize: rf(28), + color: '#f8fafc', + marginBottom: 20, + fontWeight: 'bold', + textAlign: 'center', + }, + timer: { + fontSize: rf(42), + color: '#38bdf8', + marginVertical: 20, + fontVariant: ['tabular-nums'], + letterSpacing: 1, + }, + waveContainer: { + height: 80, + width: '100%', + alignSelf: 'center', + marginVertical: 16, + }, + actionRow: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: 16, + marginTop: 24, + }, + + // Shared circular style + circularButton: { + width: 64, + height: 64, + borderRadius: 32, + justifyContent: 'center', + alignItems: 'center', + padding: 10, + elevation: 3, + shadowColor: '#000', + shadowOffset: {width: 0, height: 1}, + shadowOpacity: 0.2, + shadowRadius: 3, + }, + + // Rectangular style for stop/pause + rectButton: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 28, + borderRadius: 12, + elevation: 3, + shadowColor: '#000', + shadowOffset: {width: 0, height: 1}, + shadowOpacity: 0.2, + shadowRadius: 3, + }, + + // Colors + record: {backgroundColor: '#16a34a'}, + stop: {backgroundColor: '#dc2626'}, + play: {backgroundColor: '#3b82f6'}, + pause: {backgroundColor: '#f59e0b'}, + rerecord: {backgroundColor: '#0284c7'}, + upload: {backgroundColor: '#7c3aed'}, + + buttonText: { + color: '#f8fafc', + fontSize: rf(12), + fontWeight: '600', + textAlign: 'center', + marginTop: 4, + }, + + pathText: { + marginTop: 20, + fontSize: rf(14), + color: '#cbd5e1', + textAlign: 'center', + }, + + // Mic styles + iconContainer: { + alignItems: 'center', + justifyContent: 'center', + marginVertical: 16, + }, + micButton: { + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: '#1e293b', + alignItems: 'center', + justifyContent: 'center', + elevation: 6, + shadowColor: '#000', + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.25, + shadowRadius: 6, + }, + micButtonActive: { + backgroundColor: '#38bdf8', + }, + micOuterCircle: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: '#334155', + alignItems: 'center', + justifyContent: 'center', + }, + micInnerCircle: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#f8fafc', + alignItems: 'center', + justifyContent: 'center', + }, + micBody: { + width: 18, + height: 28, + borderRadius: 9, + backgroundColor: '#38bdf8', + marginBottom: 2, + }, + micStem: { + width: 4, + height: 10, + borderRadius: 2, + backgroundColor: '#38bdf8', + marginTop: 2, + }, + micPulse: { + position: 'absolute', + top: -15, + left: -15, + width: 150, + height: 150, + borderRadius: 75, + borderWidth: 2, + borderColor: '#38bdf8', + opacity: 0.4, + }, + + actionButtonText: { + color: '#f8fafc', + fontSize: rf(10), + fontWeight: '600', + textAlign: 'center', + //marginTop: 2, + letterSpacing: 0.5, + }, + + slider: { + width: '100%', + height: 36, + marginTop: 6, + marginBottom: 2, + }, + + timeRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 4, + marginBottom: 12, + }, + time: { + fontSize: rf(13), + color: '#777', + }, +}); diff --git a/frontend/src/screens/PodcastSearch.tsx b/frontend/src/screens/PodcastSearch.tsx index 3c3cd27b..c57fd149 100644 --- a/frontend/src/screens/PodcastSearch.tsx +++ b/frontend/src/screens/PodcastSearch.tsx @@ -1,280 +1,281 @@ -// PodcastSearch.tsx -import React, {useEffect, useState, useCallback} from 'react'; -import {Pressable, FlatList, AccessibilityInfo} from 'react-native'; -import {useFocusEffect} from '@react-navigation/native'; -import {PodcastData, PodcastSearchProp} from '../type'; -import {AxiosError} from 'axios'; -import {useSelector} from 'react-redux'; -import PodcastCard from '../components/PodcastCard'; -import PodcastSkeletonCard from '../components/PodcastSkeletonCard'; -import {msToTime} from '../helper/Utils'; -import Snackbar from 'react-native-snackbar'; -import NoResults from '../components/NoResult'; -import {PRIMARY_COLOR} from '../helper/Theme'; -import {XStack, YStack, Input, Separator, Text, useTheme} from 'tamagui'; -import {Feather} from '@expo/vector-icons'; -import {useUpdatePodcastViewcount} from '../hooks/useUpdatePodcastViewcount'; -import {useGetSearchPodcasts} from '../hooks/useGetSearchPodcasts'; +// PodcastSearch.tsx +import React, {useEffect, useState, useCallback} from 'react'; +import {Pressable, FlatList, AccessibilityInfo} from 'react-native'; +import {useFocusEffect} from '@react-navigation/native'; +import {PodcastData, PodcastSearchProp} from '../type'; +import {AxiosError} from 'axios'; +import {useSelector} from 'react-redux'; +import PodcastCard from '../components/PodcastCard'; +import PodcastSkeletonCard from '../components/PodcastSkeletonCard'; +import {msToTime} from '../helper/Utils'; +import Snackbar from 'react-native-snackbar'; +import NoResults from '../components/NoResult'; +import {PRIMARY_COLOR} from '../helper/Theme'; +import {XStack, YStack, Input, Separator, Text, useTheme} from 'tamagui'; +import {Feather} from '@expo/vector-icons'; +import {useUpdatePodcastViewcount} from '../hooks/useUpdatePodcastViewcount'; +import {useGetSearchPodcasts} from '../hooks/useGetSearchPodcasts'; import { rf } from '../helper/Metric'; -const SKELETON_COUNT = 3; - -export default function PodcastSearch({navigation}: PodcastSearchProp) { - const [query, setQuery] = useState(''); - const {isConnected} = useSelector((state: any) => state.network); - const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(0); - const [searchData, setSearchData] = useState([]); - const theme = useTheme(); - - useFocusEffect( - useCallback(() => { - // Reset local search state when entering the screen to avoid stale queries. - setQuery(''); - setPage(1); - setTotalPages(0); - setSearchData([]); - }, []), - ); - - - - const {mutate: updateViewCount} = useUpdatePodcastViewcount(); - - const {data: searchPodcasts, isLoading} = useGetSearchPodcasts( - isConnected, - page, - query, - ); - - useEffect(() => { - if (Number(page) === 1) { - if (searchPodcasts && searchPodcasts.totalPages) { - setTotalPages(searchPodcasts.totalPages); - } - if (searchPodcasts && searchPodcasts.matchPodcasts) { - setSearchData(searchPodcasts.matchPodcasts); - } - } else { - if (searchPodcasts && searchPodcasts.matchPodcasts) { - setSearchData(prev => [...prev, ...searchPodcasts.matchPodcasts]); - } - } - }, [page, searchPodcasts]); - - useEffect(() => { - if (isLoading && query !== '') { - AccessibilityInfo.announceForAccessibility('Loading podcast results'); - } - }, [isLoading, query]); - - const renderItem = ({item}: {item: PodcastData}) => ( - { - if (item) { - updateViewCount(item._id, { - onSuccess: (data: PodcastData) => { - navigation.navigate('PodcastDetail', { - trackId: data._id, - audioUrl: data.audio_url, - }); - }, - onError: (err: AxiosError) => { - console.log('Update view count err', err); - Snackbar.show({ - text: 'Something went wrong!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }); - } - }}> - {}} - handleClick={() => { - updateViewCount(item._id, { - onSuccess: (data: PodcastData) => { - navigation.navigate('PodcastDetail', { - trackId: data._id, - audioUrl: data.audio_url, - }); - }, - onError: (err: AxiosError) => { - console.log('Update view count err', err); - Snackbar.show({ - text: 'Something went wrong!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }); - }} - handleReport={() => {}} - playlistAct={() => {}} - /> - - ); - - // Consistent list padding used by both the skeleton and results FlatList. - const listContentStyle = {paddingTop: 12, paddingBottom: 20}; - const SKELETON_COUNT = 5; - - return ( - - - {/* Header */} - - - - - - - Discover Podcasts - - - {searchData.length > 0 - ? `${searchData.length} results found` - : 'Search for your favorite content'} - - - - - - {/* Search Bar */} - - - - {query ? ( - { - setQuery(''); - setSearchData([]); - }}> - - - ) : null} - - - - - - {/* Results / Skeleton Section */} - - {isLoading && query !== '' ? ( - i)} - keyExtractor={(_, index) => `skeleton-${index}`} - renderItem={() => } - scrollEnabled={false} - contentContainerStyle={listContentStyle} - /> - ) : ( - item._id.toString()} - renderItem={renderItem} - ListHeaderComponent={ - query !== '' && searchData.length > 0 ? ( - - - SEARCH RESULTS - - - ) : null - } - ListEmptyComponent={ - query === '' ? ( - - - - Start Your Search - - - Type in the search bar to discover amazing podcasts - - - ) : ( - - - - ) - } - onEndReached={() => { - if (page < totalPages) setPage(prev => prev + 1); - }} - onEndReachedThreshold={0.5} - showsVerticalScrollIndicator={false} - contentContainerStyle={listContentStyle} - /> - )} - - - ); + +const SKELETON_COUNT = 3; + +export default function PodcastSearch({navigation}: PodcastSearchProp) { + const [query, setQuery] = useState(''); + const {isConnected} = useSelector((state: any) => state.network); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [searchData, setSearchData] = useState([]); + const theme = useTheme(); + + useFocusEffect( + useCallback(() => { + // Reset local search state when entering the screen to avoid stale queries. + setQuery(''); + setPage(1); + setTotalPages(0); + setSearchData([]); + }, []), + ); + + + + const {mutate: updateViewCount} = useUpdatePodcastViewcount(); + + const {data: searchPodcasts, isLoading} = useGetSearchPodcasts( + isConnected, + page, + query, + ); + + useEffect(() => { + if (Number(page) === 1) { + if (searchPodcasts && searchPodcasts.totalPages) { + setTotalPages(searchPodcasts.totalPages); + } + if (searchPodcasts && searchPodcasts.matchPodcasts) { + setSearchData(searchPodcasts.matchPodcasts); + } + } else { + if (searchPodcasts && searchPodcasts.matchPodcasts) { + setSearchData(prev => [...prev, ...searchPodcasts.matchPodcasts]); + } + } + }, [page, searchPodcasts]); + + useEffect(() => { + if (isLoading && query !== '') { + AccessibilityInfo.announceForAccessibility('Loading podcast results'); + } + }, [isLoading, query]); + + const renderItem = ({item}: {item: PodcastData}) => ( + { + if (item) { + updateViewCount(item._id, { + onSuccess: (data: PodcastData) => { + navigation.navigate('PodcastDetail', { + trackId: data._id, + audioUrl: data.audio_url, + }); + }, + onError: (err: AxiosError) => { + console.log('Update view count err', err); + Snackbar.show({ + text: 'Something went wrong!', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }); + } + }}> + {}} + handleClick={() => { + updateViewCount(item._id, { + onSuccess: (data: PodcastData) => { + navigation.navigate('PodcastDetail', { + trackId: data._id, + audioUrl: data.audio_url, + }); + }, + onError: (err: AxiosError) => { + console.log('Update view count err', err); + Snackbar.show({ + text: 'Something went wrong!', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }); + }} + handleReport={() => {}} + playlistAct={() => {}} + /> + + ); + + // Consistent list padding used by both the skeleton and results FlatList. + const listContentStyle = {paddingTop: 12, paddingBottom: 20}; + const SKELETON_COUNT = 5; + + return ( + + + {/* Header */} + + + + + + + Discover Podcasts + + + {searchData.length > 0 + ? `${searchData.length} results found` + : 'Search for your favorite content'} + + + + + + {/* Search Bar */} + + + + {query ? ( + { + setQuery(''); + setSearchData([]); + }}> + + + ) : null} + + + + + + {/* Results / Skeleton Section */} + + {isLoading && query !== '' ? ( + i)} + keyExtractor={(_, index) => `skeleton-${index}`} + renderItem={() => } + scrollEnabled={false} + contentContainerStyle={listContentStyle} + /> + ) : ( + item._id.toString()} + renderItem={renderItem} + ListHeaderComponent={ + query !== '' && searchData.length > 0 ? ( + + + SEARCH RESULTS + + + ) : null + } + ListEmptyComponent={ + query === '' ? ( + + + + Start Your Search + + + Type in the search bar to discover amazing podcasts + + + ) : ( + + + + ) + } + onEndReached={() => { + if (page < totalPages) setPage(prev => prev + 1); + }} + onEndReachedThreshold={0.5} + showsVerticalScrollIndicator={false} + contentContainerStyle={listContentStyle} + /> + )} + + + ); } \ No newline at end of file diff --git a/frontend/src/screens/PodcastsScreen.tsx b/frontend/src/screens/PodcastsScreen.tsx index 8d7e2fa7..05bb5c60 100644 --- a/frontend/src/screens/PodcastsScreen.tsx +++ b/frontend/src/screens/PodcastsScreen.tsx @@ -1,395 +1,396 @@ -import {useEffect, useState, useMemo} from 'react'; -import { - StyleSheet, - TouchableOpacity, - NativeModules, - Text, - FlatList, -} from 'react-native'; - -import {YStack, View, XStack} from 'tamagui'; -import PodcastCard from '../components/PodcastCard'; -import {hp} from '../helper/Metric'; -import {PodcastData, PodcastScreenProps} from '../type'; -import {useDispatch, useSelector} from 'react-redux'; -import {downloadAudio, msToTime} from '../helper/Utils'; -import Snackbar from 'react-native-snackbar'; -import {setaddedPodcastId, setPodcasts, appendPodcasts} from '../store/dataSlice'; -import CreatePlaylist from '../components/CreatePlaylist'; - -import {SafeAreaView} from 'react-native-safe-area-context'; -import {StatusBar} from 'expo-status-bar'; -import {Ionicons} from '@expo/vector-icons'; -import {GlassStyles, ProfessionalColors} from '../styles/GlassStyles'; -import CreateIcon from '../components/CreateIcon'; -import {useGetAllPodcasts} from '../hooks/useGetAllPodcasts'; -import {useUpdatePodcastViewcount} from '../hooks/useUpdatePodcastViewcount'; -import { PodcastLoadingState, NoPodcastState } from '../components/EmptyStates'; -import LoadingSpinner from '../components/LoadingSpinner'; -import {usePreferences} from '../contexts/PreferencesContext'; - -const {WavAudioRecorder} = NativeModules; -//const recorderEvents = new NativeEventEmitter(WavAudioRecorder); - -const PodcastsScreen = ({navigation}: PodcastScreenProps) => { - const dispatch = useDispatch(); - const {user_id, isGuest} = useSelector((state: any) => state.user); - // const {selectedTags, sortType} = useSelector((state: any) => state.data); - const [refreshing, setRefreshing] = useState(false); - const {podcasts} = useSelector((state: any) => state.data); - const {isConnected} = useSelector((state: any) => state.network); - const [playlistModalOpen, setPlaylistModalOpen] = useState(false); - // const [selectedCategory, setSelectedCategory] = useState(); - const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(0); - // Session-level language filter (can override preferences per session) - const [sessionSelectedLanguages, setSessionSelectedLanguages] = useState([]); - const {preferredLanguages} = usePreferences(); - - const { - data: podcastData, - isLoading, - refetch, - } = useGetAllPodcasts(isConnected, page); - - const {mutate: updateViewCount} = useUpdatePodcastViewcount(); - - useEffect(() => { - if (podcastData) { - if (Number(page) === 1) { - if (podcastData.totalPages) { - const total = podcastData.totalPages; - setTotalPages(total); - } - if (podcastData.allPodcasts) { - let data = podcastData.allPodcasts as PodcastData[]; - dispatch(setPodcasts(data)); - } - } else { - if (podcastData.allPodcasts) { - let data = podcastData.allPodcasts as PodcastData[]; - dispatch(appendPodcasts(data)); - } - } - } - }, [podcastData, page]); - - const openPlaylist = (id: string) => { - dispatch(setaddedPodcastId(id)); - setPlaylistModalOpen(true); - }; - - const closePlaylist = () => { - setPlaylistModalOpen(false); - dispatch(setaddedPodcastId('')); - }; - - const onRefresh = () => { - setRefreshing(true); - setPage(1); - refetch(); - setRefreshing(false); - }; - - - const navigateToReport = (podcastId: string) => { - navigation.navigate('ReportScreen', { - articleId: '', - authorId: user_id, - commentId: null, - podcastId: podcastId, - }); - }; - - // Filter podcasts by language preference - const filteredPodcasts = useMemo(() => { - if (!podcasts || podcasts.length === 0) { - return []; - } - - // Priority: session-selected languages > preferred languages - const effectiveLanguages = sessionSelectedLanguages.length > 0 - ? sessionSelectedLanguages - : preferredLanguages; - - // If no language preference set, show all podcasts - if (effectiveLanguages.length === 0) { - return podcasts; - } - - return podcasts.filter((podcast: PodcastData) => - effectiveLanguages.includes(podcast.language || 'en-IN'), - ); - }, [podcasts, sessionSelectedLanguages, preferredLanguages]); - - - const renderItem = ({item}: {item: PodcastData}) => ( - { - if (isConnected) { - await downloadAudio(item); - } else { - Snackbar.show({ - text: 'Internet connection required', - duration: Snackbar.LENGTH_SHORT, - }); - } - }} - handleClick={() => { - if (isGuest) { - navigation.navigate('PodcastDetail', { - trackId: item._id, - audioUrl: item.audio_url, - }); - return; - } - updateViewCount(item._id, { - onSuccess: data => { - navigation.navigate('PodcastDetail', { - trackId: data._id, - audioUrl: data.audio_url, - }); - }, - onError: err => { - console.log('Update view count err', err); - Snackbar.show({ - text: 'Something went wrong!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }); - }} - imageUri={item.cover_image} - handleReport={() => { - navigateToReport(item._id); - }} - playlistAct={openPlaylist} - /> - ); - - const renderLoadingState = () => ( - - - - ); - - const renderEmptyState = () => ( - - ); - - return ( - - - - {/* Header Section */} - - - - - - Podcasts - - {filteredPodcasts?.length || 0} episodes available - - - - - - - - {isLoading && !podcasts?.length ? ( - renderLoadingState() - ) : ( - item._id.toString()} - contentContainerStyle={styles.flatListContentContainer} - refreshing={refreshing} - onRefresh={onRefresh} - showsVerticalScrollIndicator={false} - ListEmptyComponent={renderEmptyState} - onEndReached={() => { - if (page < totalPages) { - setPage(prev => prev + 1); - } - }} - onEndReachedThreshold={0.5} - ListFooterComponent={ - isLoading && podcasts?.length > 0 ? ( - - - - ) : null - } - /> - )} - - - - - {/* Floating Action Button */} - {!isGuest && ( - { - console.log('Add icon clicked'); - navigation.navigate('PodcastForm'); - }}> - { - console.log('Add icon clicked'); - navigation.navigate('PodcastForm'); - }} - /> - - )} - - ); -}; - -export default PodcastsScreen; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: ProfessionalColors.gray50, - paddingTop: hp(1), - }, - - header: { - marginHorizontal: 16, - marginTop: hp(10), - marginBottom: 16, - padding: 16, - borderRadius: hp(2), - }, - - headerTitle: { - fontSize: 24, - fontWeight: '700', - color: ProfessionalColors.gray900, - }, - - headerSubtitle: { - fontSize: 13, - fontWeight: '500', - color: ProfessionalColors.gray600, - marginTop: 2, - }, - - flatListContentContainer: { - paddingTop: 8, - paddingBottom: 120, - }, - - loadingContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 60, - paddingHorizontal: 20, - }, - - loadingIconContainer: { - width: 120, - height: 120, - borderRadius: 60, - backgroundColor: ProfessionalColors.glassWhiteMedium, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 20, - }, - - loadingText: { - fontSize: 18, - fontWeight: '700', - color: ProfessionalColors.gray900, - textAlign: 'center', - marginTop: 8, - }, - - loadingSubText: { - marginTop: 8, - fontSize: 14, - fontWeight: '500', - color: ProfessionalColors.gray600, - textAlign: 'center', - }, - - emptyContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 60, - paddingHorizontal: 20, - }, - - emptyIconContainer: { - width: 120, - height: 120, - borderRadius: 60, - backgroundColor: ProfessionalColors.glassWhiteMedium, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 20, - }, - - message: { - fontSize: 18, - fontWeight: '700', - color: ProfessionalColors.gray900, - textAlign: 'center', - marginTop: 8, - }, - - subMessage: { - marginTop: 8, - fontSize: 14, - fontWeight: '500', - color: ProfessionalColors.gray600, - textAlign: 'center', - }, - - footerLoading: { - paddingVertical: 20, - alignItems: 'center', - justifyContent: 'center', - }, - - fab: { - position: 'absolute', - bottom: hp(10), - right: 20, - zIndex: 10, - shadowColor: ProfessionalColors.primary, - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.3, - shadowRadius: 12, - elevation: 8, - }, - - fabInner: { - width: 60, - height: 60, - borderRadius: 30, - justifyContent: 'center', - alignItems: 'center', - }, -}); +import {useEffect, useState, useMemo} from 'react'; +import { + StyleSheet, + TouchableOpacity, + NativeModules, + Text, + FlatList, +} from 'react-native'; + +import {YStack, View, XStack} from 'tamagui'; +import PodcastCard from '../components/PodcastCard'; +import {hp} from '../helper/Metric'; +import {PodcastData, PodcastScreenProps} from '../type'; +import {useDispatch, useSelector} from 'react-redux'; +import {downloadAudio, msToTime} from '../helper/Utils'; +import Snackbar from 'react-native-snackbar'; +import {setaddedPodcastId, setPodcasts, appendPodcasts} from '../store/dataSlice'; +import CreatePlaylist from '../components/CreatePlaylist'; + +import {SafeAreaView} from 'react-native-safe-area-context'; +import {StatusBar} from 'expo-status-bar'; +import {Ionicons} from '@expo/vector-icons'; +import {GlassStyles, ProfessionalColors} from '../styles/GlassStyles'; +import CreateIcon from '../components/CreateIcon'; +import {useGetAllPodcasts} from '../hooks/useGetAllPodcasts'; +import {useUpdatePodcastViewcount} from '../hooks/useUpdatePodcastViewcount'; +import { PodcastLoadingState, NoPodcastState } from '../components/EmptyStates'; +import LoadingSpinner from '../components/LoadingSpinner'; +import {usePreferences} from '../contexts/PreferencesContext'; import { rf } from '../helper/Metric'; + + +const {WavAudioRecorder} = NativeModules; +//const recorderEvents = new NativeEventEmitter(WavAudioRecorder); + +const PodcastsScreen = ({navigation}: PodcastScreenProps) => { + const dispatch = useDispatch(); + const {user_id, isGuest} = useSelector((state: any) => state.user); + // const {selectedTags, sortType} = useSelector((state: any) => state.data); + const [refreshing, setRefreshing] = useState(false); + const {podcasts} = useSelector((state: any) => state.data); + const {isConnected} = useSelector((state: any) => state.network); + const [playlistModalOpen, setPlaylistModalOpen] = useState(false); + // const [selectedCategory, setSelectedCategory] = useState(); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + // Session-level language filter (can override preferences per session) + const [sessionSelectedLanguages, setSessionSelectedLanguages] = useState([]); + const {preferredLanguages} = usePreferences(); + + const { + data: podcastData, + isLoading, + refetch, + } = useGetAllPodcasts(isConnected, page); + + const {mutate: updateViewCount} = useUpdatePodcastViewcount(); + + useEffect(() => { + if (podcastData) { + if (Number(page) === 1) { + if (podcastData.totalPages) { + const total = podcastData.totalPages; + setTotalPages(total); + } + if (podcastData.allPodcasts) { + let data = podcastData.allPodcasts as PodcastData[]; + dispatch(setPodcasts(data)); + } + } else { + if (podcastData.allPodcasts) { + let data = podcastData.allPodcasts as PodcastData[]; + dispatch(appendPodcasts(data)); + } + } + } + }, [podcastData, page]); + + const openPlaylist = (id: string) => { + dispatch(setaddedPodcastId(id)); + setPlaylistModalOpen(true); + }; + + const closePlaylist = () => { + setPlaylistModalOpen(false); + dispatch(setaddedPodcastId('')); + }; + + const onRefresh = () => { + setRefreshing(true); + setPage(1); + refetch(); + setRefreshing(false); + }; + + + const navigateToReport = (podcastId: string) => { + navigation.navigate('ReportScreen', { + articleId: '', + authorId: user_id, + commentId: null, + podcastId: podcastId, + }); + }; + + // Filter podcasts by language preference + const filteredPodcasts = useMemo(() => { + if (!podcasts || podcasts.length === 0) { + return []; + } + + // Priority: session-selected languages > preferred languages + const effectiveLanguages = sessionSelectedLanguages.length > 0 + ? sessionSelectedLanguages + : preferredLanguages; + + // If no language preference set, show all podcasts + if (effectiveLanguages.length === 0) { + return podcasts; + } + + return podcasts.filter((podcast: PodcastData) => + effectiveLanguages.includes(podcast.language || 'en-IN'), + ); + }, [podcasts, sessionSelectedLanguages, preferredLanguages]); + + + const renderItem = ({item}: {item: PodcastData}) => ( + { + if (isConnected) { + await downloadAudio(item); + } else { + Snackbar.show({ + text: 'Internet connection required', + duration: Snackbar.LENGTH_SHORT, + }); + } + }} + handleClick={() => { + if (isGuest) { + navigation.navigate('PodcastDetail', { + trackId: item._id, + audioUrl: item.audio_url, + }); + return; + } + updateViewCount(item._id, { + onSuccess: data => { + navigation.navigate('PodcastDetail', { + trackId: data._id, + audioUrl: data.audio_url, + }); + }, + onError: err => { + console.log('Update view count err', err); + Snackbar.show({ + text: 'Something went wrong!', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }); + }} + imageUri={item.cover_image} + handleReport={() => { + navigateToReport(item._id); + }} + playlistAct={openPlaylist} + /> + ); + + const renderLoadingState = () => ( + + + + ); + + const renderEmptyState = () => ( + + ); + + return ( + + + + {/* Header Section */} + + + + + + Podcasts + + {filteredPodcasts?.length || 0} episodes available + + + + + + + + {isLoading && !podcasts?.length ? ( + renderLoadingState() + ) : ( + item._id.toString()} + contentContainerStyle={styles.flatListContentContainer} + refreshing={refreshing} + onRefresh={onRefresh} + showsVerticalScrollIndicator={false} + ListEmptyComponent={renderEmptyState} + onEndReached={() => { + if (page < totalPages) { + setPage(prev => prev + 1); + } + }} + onEndReachedThreshold={0.5} + ListFooterComponent={ + isLoading && podcasts?.length > 0 ? ( + + + + ) : null + } + /> + )} + + + + + {/* Floating Action Button */} + {!isGuest && ( + { + console.log('Add icon clicked'); + navigation.navigate('PodcastForm'); + }}> + { + console.log('Add icon clicked'); + navigation.navigate('PodcastForm'); + }} + /> + + )} + + ); +}; + +export default PodcastsScreen; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: ProfessionalColors.gray50, + paddingTop: hp(1), + }, + + header: { + marginHorizontal: 16, + marginTop: hp(10), + marginBottom: 16, + padding: 16, + borderRadius: hp(2), + }, + + headerTitle: { + fontSize: rf(24), + fontWeight: '700', + color: ProfessionalColors.gray900, + }, + + headerSubtitle: { + fontSize: rf(13), + fontWeight: '500', + color: ProfessionalColors.gray600, + marginTop: 2, + }, + + flatListContentContainer: { + paddingTop: 8, + paddingBottom: 120, + }, + + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + paddingHorizontal: 20, + }, + + loadingIconContainer: { + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: ProfessionalColors.glassWhiteMedium, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 20, + }, + + loadingText: { + fontSize: rf(18), + fontWeight: '700', + color: ProfessionalColors.gray900, + textAlign: 'center', + marginTop: 8, + }, + + loadingSubText: { + marginTop: 8, + fontSize: rf(14), + fontWeight: '500', + color: ProfessionalColors.gray600, + textAlign: 'center', + }, + + emptyContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + paddingHorizontal: 20, + }, + + emptyIconContainer: { + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: ProfessionalColors.glassWhiteMedium, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 20, + }, + + message: { + fontSize: rf(18), + fontWeight: '700', + color: ProfessionalColors.gray900, + textAlign: 'center', + marginTop: 8, + }, + + subMessage: { + marginTop: 8, + fontSize: rf(14), + fontWeight: '500', + color: ProfessionalColors.gray600, + textAlign: 'center', + }, + + footerLoading: { + paddingVertical: 20, + alignItems: 'center', + justifyContent: 'center', + }, + + fab: { + position: 'absolute', + bottom: hp(10), + right: 20, + zIndex: 10, + shadowColor: ProfessionalColors.primary, + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 8, + }, + + fabInner: { + width: 60, + height: 60, + borderRadius: 30, + justifyContent: 'center', + alignItems: 'center', + }, +}); diff --git a/frontend/src/screens/ProfileEditScreen.tsx b/frontend/src/screens/ProfileEditScreen.tsx index c7da87ba..94a99498 100644 --- a/frontend/src/screens/ProfileEditScreen.tsx +++ b/frontend/src/screens/ProfileEditScreen.tsx @@ -1,699 +1,702 @@ -import { - ScrollView, - Text, - TouchableOpacity, - View, - StyleSheet, - Alert, -} from 'react-native'; -import React, {useEffect, useState} from 'react'; -import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; -import GeneralTab, { GeneralFormData } from '../components/GeneralTab'; -import ContactTab, { ContactFormData } from '../components/ContactTab'; -import ProfessionalTab, { ProfFormData } from '../components/ProfessionalTab'; -import PasswordTab, { PasswordFormData } from '../components/PasswordTab'; -import LanguagePreferenceSelector from '../components/LanguagePreferenceSelector'; -import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; -import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'; -import {useDispatch, useSelector} from 'react-redux'; -import {AxiosError} from 'axios'; -import {ProfileEditScreenProp} from '../type'; -import { - GET_STORAGE_DATA, -} from '../helper/APIUtils'; -import { - ImageLibraryOptions, - launchImageLibrary, - ImagePickerResponse, -} from 'react-native-image-picker'; -import ImageResizer from '@bam.tech/react-native-image-resizer'; -import useUploadImage from '../hooks/useUploadImage'; -import Snackbar from 'react-native-snackbar'; -import {useGetUserDetails} from '../hooks/useGetUserDetails'; -import {useUpdatePassword} from '../hooks/useUpdatePassword'; -import {useUpdateProfileImage} from '../hooks/useUpdateProfileImage'; -import {useUpdateUserContactDetail} from '../hooks/useUpdateUserContactDetail'; -import {useUpdateUserGeneralDetails} from '../hooks/useUpdateUserGeneralDetails'; -import {useUpdateUserProfDetails} from '../hooks/useUpdateUserProfDetails'; -import LoadingSpinner from '../components/LoadingSpinner'; -// eslint-disable-next-line @typescript-eslint/no-require-imports -// let validator = require('email-validator'); -// let expr = /^(0|91)?[6-9][0-9]{9}$/; - -const ProfileEditScreen = ({navigation}: ProfileEditScreenProp) => { - const {uploadImage, loading} = useUploadImage(); - - const dispatch = useDispatch(); - - // Get safe area insets for handling notches and status bars on device - const insets = useSafeAreaInsets(); - - // Define the tabs available in the profile edit screen - const tabs: string[] = ['General', 'Professional', 'Contact', 'Password', 'Language']; - - // State to keep track of the currently selected tab - const [currentTab, setcurrentTab] = useState(tabs[0]); - - const {mutate: updatePassword, isPending: passwordMutationPending} = - useUpdatePassword(); - - const {mutate: updateProfileImage, isPending: profileImagePending} = - useUpdateProfileImage(); - - const {mutate: updateContactDetails, isPending: contactDetailsPending} = - useUpdateUserContactDetail(); - - const {mutate: updateGeneralDetails, isPending: generalDetailsPending} = - useUpdateUserGeneralDetails(); - const { - mutate: updateProfessionalDetails, - isPending: professionalDetailPending, - } = useUpdateUserProfDetails(); - - // Initialize state variables - const [user_profile_image, setUserProfileImage] = useState(''); - const {user_token} = useSelector((state: any) => state.user); - const {isConnected} = useSelector((state: any) => state.network); - - const {data: user} = useGetUserDetails(isConnected); - - - - useEffect(() => { - if (user) { - setUserProfileImage( - user.Profile_image ? - user.Profile_image.startsWith("http") ? user.Profile_image : - `${GET_STORAGE_DATA}/${user.Profile_image}` : '', - ); - } - }, [user]); - // Boolean to check if the user is a doctor - const isDoctor = user ? user?.isDoctor! : false; - // Function to handle tab selection - const handleTab = (tab: string) => { - setcurrentTab(tab); - }; - const handleSubmitGeneralDetails = (data: GeneralFormData) => { - if (!isConnected) { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - return; - } - - updateGeneralDetails( - { - username: data.username, - about: data.about, - userHandle: data.userHandle, - email: data.email, - }, - { - onSuccess: _data => { - Alert.alert('Success', 'Details submitted successfully'); - //navigation.goBack(); - }, - - onError: (err: AxiosError) => { - if (err.response) { - const statusCode = err.response.status; - switch (statusCode) { - case 400: - // Handle bad request errors (missing fields or email/user handle already in use) - Alert.alert( - 'Update Failed', - (err?.response?.data as any)?.error || - 'Please fill in all fields correctly.', - ); - - break; - case 404: - // Handle user not found - Alert.alert( - 'Update Failed', - 'User not found. Please check your information.', - ); - - break; - case 409: - // Handle conflict errors (duplicate email/user handle) - Alert.alert( - 'Update Failed', - 'Email or user handle already exists.', - ); - - break; - case 500: - // Handle internal server errors - Alert.alert( - 'Update Failed', - 'Internal server error. Please try again later.', - ); - - break; - default: - // Handle any other errors - Alert.alert( - 'Update Failed', - 'Something went wrong. Please try again later.', - ); - } - } else { - // Handle network errors - console.log('General Update Error', err); - Alert.alert( - 'Update Failed', - 'Network error. Please check your connection.', - ); - } - }, - }, - ); - }; - const handleSubmitContactDetails = (data: ContactFormData) => { - if (!isConnected) { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - return; - } - - updateContactDetails( - { - phone: data.phone_number, - email: data.contact_email, - }, - { - onSuccess: _data => { - Alert.alert('Success', 'Details submitted successfully'); - - // navigation.goBack(); - }, - - onError: (err: AxiosError) => { - if (err.response) { - const statusCode = err.response.status; - switch (statusCode) { - case 400: - // Handle bad request errors (missing fields or email/user handle already in use) - Alert.alert( - 'Update Failed', - (err?.response?.data as any)?.error || - 'Please fill in all fields correctly.', - ); - - break; - case 404: - // Handle user not found - Alert.alert( - 'Update Failed', - 'User not found. Please check your information.', - ); - - break; - case 409: - // Handle conflict errors (duplicate email/user handle) - Alert.alert( - 'Update Failed', - 'Email or user handle already exists.', - ); - break; - case 500: - // Handle internal server errors - Alert.alert( - 'Update Failed', - 'Internal server error. Please try again later.', - ); - break; - default: - // Handle any other errors - Alert.alert( - 'Update Failed', - 'Something went wrong. Please try again later.', - ); - } - } else { - // Handle network errors - console.log('General Update Error', err); - Alert.alert( - 'Update Failed', - 'Network error. Please check your connection.', - ); - } - }, - }, - ); - }; - const handleSubmitProfessionalDetails = (data: ProfFormData) => { - if (!isConnected) { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - return; - } - - updateProfessionalDetails( - { - specialization: data.specialization, - qualification: data.qualification, - experience: data.experience, - }, - { - onSuccess: _data => { - Alert.alert('Success', 'Details submitted successfully'); - // navigation.goBack(); - }, - - onError: (err: AxiosError) => { - if (err.response) { - const statusCode = err.response.status; - switch (statusCode) { - case 400: - // Handle bad request errors (missing fields or email/user handle already in use) - Alert.alert( - 'Update Failed', - (err?.response?.data as any)?.error || - 'Please fill in all fields correctly.', - ); - break; - case 404: - // Handle user not found - Alert.alert( - 'Update Failed', - 'User not found. Please check your information.', - ); - break; - case 500: - // Handle internal server errors - Alert.alert( - 'Update Failed', - 'Internal server error. Please try again later.', - ); - break; - default: - // Handle any other errors - Alert.alert( - 'Update Failed', - 'Something went wrong. Please try again later.', - ); - } - } else { - // Handle network errors - console.log('General Update Error', err); - Alert.alert( - 'Update Failed', - 'Network error. Please check your connection.', - ); - } - }, - }, - ); - }; - const handleSubmitPassword = (data: PasswordFormData) => { - if (!isConnected) { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - return; - } - - updatePassword( - { - old_password: data.old_password, - new_password: data.new_password, - }, - { - onSuccess: _data => { - Snackbar.show({ - text: 'Password updated sucessfully', - duration: Snackbar.LENGTH_SHORT, - }); - }, - - onError: (err: AxiosError) => { - if (err.response) { - const statusCode = err.response.status; - switch (statusCode) { - case 400: - Alert.alert( - 'Password Update Failed', - (err?.response?.data as any)?.error, - ); - break; - case 401: - Alert.alert( - 'Password Update Failed', - 'Old password is incorrect.', - ); - break; - case 404: - Alert.alert('Password Update Failed', 'User not found.'); - - break; - case 500: - Alert.alert( - 'Password Update Failed', - 'Internal server error. Please try again later.', - ); - - break; - default: - Alert.alert( - 'Password Update Failed', - 'Something went wrong. Please try again later.', - ); - } - } else { - // console.log('Password Update Error', err); - Alert.alert( - 'Password Update Failed', - 'Network error. Please check your connection.', - ); - } - }, - }, - ); - // userPasswordUpdateMutation.mutate(); - }; - const selectImage = async () => { - const options: ImageLibraryOptions = { - mediaType: 'photo', - }; - - launchImageLibrary(options, async (response: ImagePickerResponse) => { - if (response.didCancel) { - //console.log('User cancelled image picker'); - } else if (response.errorMessage) { - console.log('ImagePicker Error: ', response.errorMessage); - } else if (response.assets) { - const {uri, fileSize} = response.assets[0]; - - // Check file size (1 MB limit) - if (fileSize && fileSize > 1024 * 1024) { - Alert.alert('Error', 'File size exceeds 1 MB.'); - return; - } - - if (uri) { - ImageResizer.createResizedImage(uri, 1000, 1000, 'JPEG', 100) - .then(async resizedImageUri => { - setUserProfileImage(resizedImageUri.uri); - Alert.alert( - '', - 'Are you sure you want to use this image?', - [ - { - text: 'Cancel', - onPress: () => { - setUserProfileImage(user?.Profile_image || ''); - }, - style: 'cancel', - }, - { - text: 'OK', - onPress: async () => { - try { - if (isConnected) { - const result = await uploadImage(resizedImageUri.uri); - - updateProfileImage(result as string, { - onSuccess: _data => { - Alert.alert( - 'Success', - 'Profile updated successfully', - ); - }, - onError: (err: AxiosError) => { - if (err.response) { - const statusCode = err.response.status; - const errorMessage = (err?.response?.data as any)?.error; - - switch (statusCode) { - case 400: - // Handle validation errors (like invalid image format, file too large) - if ( - errorMessage?.includes('File too large') - ) { - Alert.alert( - 'Update Failed', - 'The image file exceeds the size limit of 1 MB. Please select a smaller image.', - ); - } else if ( - errorMessage?.includes( - 'Invalid image format', - ) - ) { - Alert.alert( - 'Update Failed', - 'The image format is invalid. Please upload a valid image (JPEG, PNG, etc.).', - ); - } else { - Alert.alert( - 'Update Failed', - errorMessage || - 'Please ensure the image is valid and all fields are filled correctly.', - ); - } - break; - - case 401: - // Handle unauthorized access (e.g., expired token) - Alert.alert( - 'Update Failed', - 'You are not authorized to update the profile image. Please log in again.', - ); - - break; - - case 404: - // Handle user not found errors - Alert.alert( - 'Update Failed', - 'User not found. Please ensure your account information is correct.', - ); - - break; - - case 413: - // Handle payload too large errors (typically for image uploads) - Alert.alert( - 'Update Failed', - 'The uploaded image is too large. Please select an image under 1 MB.', - ); - - break; - - case 500: - // Handle server-side errors - Alert.alert( - 'Update Failed', - 'An internal server error occurred. Please try again later.', - ); - - break; - - default: - // Handle unexpected errors - Alert.alert( - 'Update Failed', - 'An unexpected error occurred. Please try again later.', - ); - } - } else { - // Handle network errors or other Axios-related issues - if (err.message === 'Network Error') { - Alert.alert( - 'Update Failed', - 'A network error occurred. Please check your internet connection and try again.', - ); - } else { - console.log('General Update Error:', err); - Alert.alert( - 'Update Failed', - 'Something went wrong. Please try again.', - ); - } - } - }, - }); - } else { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - return; - } - } catch (err: any) { - console.error('Upload failed'); - Alert.alert('Error', 'Upload failed'); - } - }, - }, - ], - {cancelable: false}, - ); - }) - .catch(err => { - //console.log(err); - Alert.alert('Error', 'Could not resize the image.'); - }); - } - } - }); - }; - - const Loading = - loading || - generalDetailsPending || - contactDetailsPending || - professionalDetailPending || - profileImagePending || - passwordMutationPending; - return ( - - - {/* Horizontal scroll view for the tabs */} - - {tabs.map((tab: string) => { - if (isDoctor || tab !== 'Professional') { - return ( - { - handleTab(tab); - }}> - - {tab} - - - ); - } - })} - - - {/* Content of the selected tab */} - - {currentTab === 'General' && ( - - )} - {currentTab === 'Professional' && ( - - )} - {currentTab === 'Contact' && ( - - )} - {currentTab === 'Password' && ( - - )} - {currentTab === 'Language' && ( - - )} - - - {Loading && ( - - - - )} - - ); -}; - -const styles = StyleSheet.create({ - safeAreaView: { - flex: 1, - backgroundColor: ON_PRIMARY_COLOR, - }, - container: { - flex: 1, - backgroundColor: ON_PRIMARY_COLOR, - paddingHorizontal: 16, - }, - contentContainer: { - paddingBottom: 0, // Will be adjusted dynamically based on insets - }, - horizontalScroll: { - marginTop: 10, - }, - horizontalScrollContent: { - columnGap: 2, // Space between tabs - }, - tabButton: { - paddingHorizontal: 18, - borderRadius: 100, - paddingVertical: 10, - backgroundColor: 'transparent', - }, - activeTabButton: { - backgroundColor: PRIMARY_COLOR, // Highlight the active tab - }, - tabText: { - fontSize: 16, - fontWeight: '500', // Font weight '500' for normal tabs - color: '#B1B2B2', - }, - activeTabText: { - fontWeight: 'bold', // Bold font for active tab text - color: 'white', - }, - tabContent: { - marginTop: 25, - paddingBottom: 20, - }, - overlay: { - flex: 1, - //backgroundColor: 'rgba(0,0,0,0.4)', - backgroundColor: ON_PRIMARY_COLOR, - position: 'absolute', - height: '100%', - width: '100%', - zIndex: 10, - alignItems: 'center', - justifyContent: 'center', - }, -}); - -export default ProfileEditScreen; +import { + ScrollView, + Text, + TouchableOpacity, + View, + StyleSheet, + Alert, +} from 'react-native'; +import React, {useEffect, useState} from 'react'; +import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; +import GeneralTab, { GeneralFormData } from '../components/GeneralTab'; +import ContactTab, { ContactFormData } from '../components/ContactTab'; +import ProfessionalTab, { ProfFormData } from '../components/ProfessionalTab'; +import PasswordTab, { PasswordFormData } from '../components/PasswordTab'; +import LanguagePreferenceSelector from '../components/LanguagePreferenceSelector'; +import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; +import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'; +import {useDispatch, useSelector} from 'react-redux'; +import {AxiosError} from 'axios'; +import {ProfileEditScreenProp} from '../type'; +import { + GET_STORAGE_DATA, +} from '../helper/APIUtils'; +import { + ImageLibraryOptions, + launchImageLibrary, + ImagePickerResponse, +} from 'react-native-image-picker'; +import ImageResizer from '@bam.tech/react-native-image-resizer'; +import useUploadImage from '../hooks/useUploadImage'; +import Snackbar from 'react-native-snackbar'; +import {useGetUserDetails} from '../hooks/useGetUserDetails'; +import {useUpdatePassword} from '../hooks/useUpdatePassword'; +import {useUpdateProfileImage} from '../hooks/useUpdateProfileImage'; +import {useUpdateUserContactDetail} from '../hooks/useUpdateUserContactDetail'; +import {useUpdateUserGeneralDetails} from '../hooks/useUpdateUserGeneralDetails'; +import {useUpdateUserProfDetails} from '../hooks/useUpdateUserProfDetails'; +import LoadingSpinner from '../components/LoadingSpinner'; import { rf } from '../helper/Metric'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +// let validator = require('email-validator'); +// let expr = /^(0|91)?[6-9][0-9]{9}$/; + +const ProfileEditScreen = ({navigation}: ProfileEditScreenProp) => { + const {uploadImage, loading} = useUploadImage(); + + const dispatch = useDispatch(); + + // Get safe area insets for handling notches and status bars on device + const insets = useSafeAreaInsets(); + + // Define the tabs available in the profile edit screen + const tabs: string[] = ['General', 'Professional', 'Contact', 'Password', 'Language']; + + // State to keep track of the currently selected tab + const [currentTab, setcurrentTab] = useState(tabs[0]); + + const {mutate: updatePassword, isPending: passwordMutationPending} = + useUpdatePassword(); + + const {mutate: updateProfileImage, isPending: profileImagePending} = + useUpdateProfileImage(); + + const {mutate: updateContactDetails, isPending: contactDetailsPending} = + useUpdateUserContactDetail(); + + const {mutate: updateGeneralDetails, isPending: generalDetailsPending} = + useUpdateUserGeneralDetails(); + const { + mutate: updateProfessionalDetails, + isPending: professionalDetailPending, + } = useUpdateUserProfDetails(); + + // Initialize state variables + const [user_profile_image, setUserProfileImage] = useState(''); + const {user_token} = useSelector((state: any) => state.user); + const {isConnected} = useSelector((state: any) => state.network); + + const {data: user} = useGetUserDetails(isConnected); + + + + useEffect(() => { + if (user) { + setUserProfileImage( + user.Profile_image ? + user.Profile_image.startsWith("http") ? user.Profile_image : + `${GET_STORAGE_DATA}/${user.Profile_image}` : '', + ); + } + }, [user]); + // Boolean to check if the user is a doctor + const isDoctor = user ? user?.isDoctor! : false; + // Function to handle tab selection + const handleTab = (tab: string) => { + setcurrentTab(tab); + }; + const handleSubmitGeneralDetails = (data: GeneralFormData) => { + if (!isConnected) { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + return; + } + + updateGeneralDetails( + { + username: data.username, + about: data.about, + userHandle: data.userHandle, + email: data.email, + }, + { + onSuccess: _data => { + Alert.alert('Success', 'Details submitted successfully'); + //navigation.goBack(); + }, + + onError: (err: AxiosError) => { + if (err.response) { + const statusCode = err.response.status; + switch (statusCode) { + case 400: + // Handle bad request errors (missing fields or email/user handle already in use) + Alert.alert( + 'Update Failed', + (err?.response?.data as any)?.error || + 'Please fill in all fields correctly.', + ); + + break; + case 404: + // Handle user not found + Alert.alert( + 'Update Failed', + 'User not found. Please check your information.', + ); + + break; + case 409: + // Handle conflict errors (duplicate email/user handle) + Alert.alert( + 'Update Failed', + 'Email or user handle already exists.', + ); + + break; + case 500: + // Handle internal server errors + Alert.alert( + 'Update Failed', + 'Internal server error. Please try again later.', + ); + + break; + default: + // Handle any other errors + Alert.alert( + 'Update Failed', + 'Something went wrong. Please try again later.', + ); + } + } else { + // Handle network errors + console.log('General Update Error', err); + Alert.alert( + 'Update Failed', + 'Network error. Please check your connection.', + ); + } + }, + }, + ); + }; + const handleSubmitContactDetails = (data: ContactFormData) => { + if (!isConnected) { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + return; + } + + updateContactDetails( + { + phone: data.phone_number, + email: data.contact_email, + }, + { + onSuccess: _data => { + Alert.alert('Success', 'Details submitted successfully'); + + // navigation.goBack(); + }, + + onError: (err: AxiosError) => { + if (err.response) { + const statusCode = err.response.status; + switch (statusCode) { + case 400: + // Handle bad request errors (missing fields or email/user handle already in use) + Alert.alert( + 'Update Failed', + (err?.response?.data as any)?.error || + 'Please fill in all fields correctly.', + ); + + break; + case 404: + // Handle user not found + Alert.alert( + 'Update Failed', + 'User not found. Please check your information.', + ); + + break; + case 409: + // Handle conflict errors (duplicate email/user handle) + Alert.alert( + 'Update Failed', + 'Email or user handle already exists.', + ); + break; + case 500: + // Handle internal server errors + Alert.alert( + 'Update Failed', + 'Internal server error. Please try again later.', + ); + break; + default: + // Handle any other errors + Alert.alert( + 'Update Failed', + 'Something went wrong. Please try again later.', + ); + } + } else { + // Handle network errors + console.log('General Update Error', err); + Alert.alert( + 'Update Failed', + 'Network error. Please check your connection.', + ); + } + }, + }, + ); + }; + const handleSubmitProfessionalDetails = (data: ProfFormData) => { + if (!isConnected) { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + return; + } + + updateProfessionalDetails( + { + specialization: data.specialization, + qualification: data.qualification, + experience: data.experience, + }, + { + onSuccess: _data => { + Alert.alert('Success', 'Details submitted successfully'); + // navigation.goBack(); + }, + + onError: (err: AxiosError) => { + if (err.response) { + const statusCode = err.response.status; + switch (statusCode) { + case 400: + // Handle bad request errors (missing fields or email/user handle already in use) + Alert.alert( + 'Update Failed', + (err?.response?.data as any)?.error || + 'Please fill in all fields correctly.', + ); + break; + case 404: + // Handle user not found + Alert.alert( + 'Update Failed', + 'User not found. Please check your information.', + ); + break; + case 500: + // Handle internal server errors + Alert.alert( + 'Update Failed', + 'Internal server error. Please try again later.', + ); + break; + default: + // Handle any other errors + Alert.alert( + 'Update Failed', + 'Something went wrong. Please try again later.', + ); + } + } else { + // Handle network errors + console.log('General Update Error', err); + Alert.alert( + 'Update Failed', + 'Network error. Please check your connection.', + ); + } + }, + }, + ); + }; + const handleSubmitPassword = (data: PasswordFormData) => { + if (!isConnected) { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + return; + } + + updatePassword( + { + old_password: data.old_password, + new_password: data.new_password, + }, + { + onSuccess: _data => { + Snackbar.show({ + text: 'Password updated sucessfully', + duration: Snackbar.LENGTH_SHORT, + }); + }, + + onError: (err: AxiosError) => { + if (err.response) { + const statusCode = err.response.status; + switch (statusCode) { + case 400: + Alert.alert( + 'Password Update Failed', + (err?.response?.data as any)?.error, + ); + break; + case 401: + Alert.alert( + 'Password Update Failed', + 'Old password is incorrect.', + ); + break; + case 404: + Alert.alert('Password Update Failed', 'User not found.'); + + break; + case 500: + Alert.alert( + 'Password Update Failed', + 'Internal server error. Please try again later.', + ); + + break; + default: + Alert.alert( + 'Password Update Failed', + 'Something went wrong. Please try again later.', + ); + } + } else { + // console.log('Password Update Error', err); + Alert.alert( + 'Password Update Failed', + 'Network error. Please check your connection.', + ); + } + }, + }, + ); + // userPasswordUpdateMutation.mutate(); + }; + const selectImage = async () => { + const options: ImageLibraryOptions = { + mediaType: 'photo', + }; + + launchImageLibrary(options, async (response: ImagePickerResponse) => { + if (response.didCancel) { + //console.log('User cancelled image picker'); + } else if (response.errorMessage) { + console.log('ImagePicker Error: ', response.errorMessage); + } else if (response.assets) { + const {uri, fileSize} = response.assets[0]; + + // Check file size (1 MB limit) + if (fileSize && fileSize > 1024 * 1024) { + Alert.alert('Error', 'File size exceeds 1 MB.'); + return; + } + + if (uri) { + ImageResizer.createResizedImage(uri, 1000, 1000, 'JPEG', 100) + .then(async resizedImageUri => { + setUserProfileImage(resizedImageUri.uri); + Alert.alert( + '', + 'Are you sure you want to use this image?', + [ + { + text: 'Cancel', + onPress: () => { + setUserProfileImage(user?.Profile_image || ''); + }, + style: 'cancel', + }, + { + text: 'OK', + onPress: async () => { + try { + if (isConnected) { + const result = await uploadImage(resizedImageUri.uri); + + updateProfileImage(result as string, { + onSuccess: _data => { + Alert.alert( + 'Success', + 'Profile updated successfully', + ); + }, + onError: (err: AxiosError) => { + if (err.response) { + const statusCode = err.response.status; + const errorMessage = (err?.response?.data as any)?.error; + + switch (statusCode) { + case 400: + // Handle validation errors (like invalid image format, file too large) + if ( + errorMessage?.includes('File too large') + ) { + Alert.alert( + 'Update Failed', + 'The image file exceeds the size limit of 1 MB. Please select a smaller image.', + ); + } else if ( + errorMessage?.includes( + 'Invalid image format', + ) + ) { + Alert.alert( + 'Update Failed', + 'The image format is invalid. Please upload a valid image (JPEG, PNG, etc.).', + ); + } else { + Alert.alert( + 'Update Failed', + errorMessage || + 'Please ensure the image is valid and all fields are filled correctly.', + ); + } + break; + + case 401: + // Handle unauthorized access (e.g., expired token) + Alert.alert( + 'Update Failed', + 'You are not authorized to update the profile image. Please log in again.', + ); + + break; + + case 404: + // Handle user not found errors + Alert.alert( + 'Update Failed', + 'User not found. Please ensure your account information is correct.', + ); + + break; + + case 413: + // Handle payload too large errors (typically for image uploads) + Alert.alert( + 'Update Failed', + 'The uploaded image is too large. Please select an image under 1 MB.', + ); + + break; + + case 500: + // Handle server-side errors + Alert.alert( + 'Update Failed', + 'An internal server error occurred. Please try again later.', + ); + + break; + + default: + // Handle unexpected errors + Alert.alert( + 'Update Failed', + 'An unexpected error occurred. Please try again later.', + ); + } + } else { + // Handle network errors or other Axios-related issues + if (err.message === 'Network Error') { + Alert.alert( + 'Update Failed', + 'A network error occurred. Please check your internet connection and try again.', + ); + } else { + console.log('General Update Error:', err); + Alert.alert( + 'Update Failed', + 'Something went wrong. Please try again.', + ); + } + } + }, + }); + } else { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + return; + } + } catch (err: any) { + console.error('Upload failed'); + Alert.alert('Error', 'Upload failed'); + } + }, + }, + ], + {cancelable: false}, + ); + }) + .catch(err => { + //console.log(err); + Alert.alert('Error', 'Could not resize the image.'); + }); + } + } + }); + }; + + const Loading = + loading || + generalDetailsPending || + contactDetailsPending || + professionalDetailPending || + profileImagePending || + passwordMutationPending; + return ( + + + {/* Horizontal scroll view for the tabs */} + + {tabs.map((tab: string) => { + if (isDoctor || tab !== 'Professional') { + return ( + { + handleTab(tab); + }}> + + {tab} + + + ); + } + })} + + + {/* Content of the selected tab */} + + {currentTab === 'General' && ( + + )} + {currentTab === 'Professional' && ( + + )} + {currentTab === 'Contact' && ( + + )} + {currentTab === 'Password' && ( + + )} + {currentTab === 'Language' && ( + + )} + + + {Loading && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + safeAreaView: { + flex: 1, + backgroundColor: ON_PRIMARY_COLOR, + }, + container: { + flex: 1, + backgroundColor: ON_PRIMARY_COLOR, + paddingHorizontal: 16, + }, + contentContainer: { + paddingBottom: 0, // Will be adjusted dynamically based on insets + }, + horizontalScroll: { + marginTop: 10, + }, + horizontalScrollContent: { + columnGap: 2, // Space between tabs + }, + tabButton: { + paddingHorizontal: 18, + borderRadius: 100, + paddingVertical: 10, + backgroundColor: 'transparent', + minHeight: 44, + justifyContent: 'center', + }, + activeTabButton: { + backgroundColor: PRIMARY_COLOR, // Highlight the active tab + }, + tabText: { + fontSize: rf(16), + fontWeight: '500', // Font weight '500' for normal tabs + color: '#B1B2B2', + }, + activeTabText: { + fontWeight: 'bold', // Bold font for active tab text + color: 'white', + }, + tabContent: { + marginTop: 25, + paddingBottom: 20, + }, + overlay: { + flex: 1, + //backgroundColor: 'rgba(0,0,0,0.4)', + backgroundColor: ON_PRIMARY_COLOR, + position: 'absolute', + height: '100%', + width: '100%', + zIndex: 10, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default ProfileEditScreen; diff --git a/frontend/src/screens/ProfileScreen.tsx b/frontend/src/screens/ProfileScreen.tsx index 3937cbc1..50509090 100644 --- a/frontend/src/screens/ProfileScreen.tsx +++ b/frontend/src/screens/ProfileScreen.tsx @@ -1,389 +1,390 @@ -import {StyleSheet, View, Text, Alert, useColorScheme, ScrollView, FlatList, TouchableOpacity} from 'react-native'; -import React, {useCallback, useState} from 'react'; -import {StatusBar} from 'expo-status-bar'; -import {PRIMARY_COLOR} from '../helper/Theme'; -import ActivityOverview from '../components/ActivityOverview'; -import ArticleCard from '../components/ArticleCard'; -import {useDispatch, useSelector} from 'react-redux'; -import { useTheme } from 'tamagui'; -import {useBottomTabBarHeight} from '@react-navigation/bottom-tabs'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import ProfileHeader from '../components/ProfileHeader'; -import {ArticleData, ProfileScreenProps} from '../type'; -import Loader from '../components/Loader'; -import {useFocusEffect} from '@react-navigation/native'; -import Snackbar from 'react-native-snackbar'; -import {setUserHandle} from '../store/UserSlice'; -import {useGetProfile} from '../hooks/useGetProfile'; -import {useUpdateViewCount} from '../hooks/useUpdateViewCount'; -import { NoArticleState } from '../components/EmptyStates'; +import {StyleSheet, View, Text, Alert, useColorScheme, ScrollView, FlatList, TouchableOpacity} from 'react-native'; +import React, {useCallback, useState} from 'react'; +import {StatusBar} from 'expo-status-bar'; +import {PRIMARY_COLOR} from '../helper/Theme'; +import ActivityOverview from '../components/ActivityOverview'; +import ArticleCard from '../components/ArticleCard'; +import {useDispatch, useSelector} from 'react-redux'; +import { useTheme } from 'tamagui'; +import {useBottomTabBarHeight} from '@react-navigation/bottom-tabs'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import ProfileHeader from '../components/ProfileHeader'; +import {ArticleData, ProfileScreenProps} from '../type'; +import Loader from '../components/Loader'; +import {useFocusEffect} from '@react-navigation/native'; +import Snackbar from 'react-native-snackbar'; +import {setUserHandle} from '../store/UserSlice'; +import {useGetProfile} from '../hooks/useGetProfile'; +import {useUpdateViewCount} from '../hooks/useUpdateViewCount'; +import { NoArticleState } from '../components/EmptyStates'; import { rf } from '../helper/Metric'; -const ProfileScreen = ({navigation}: ProfileScreenProps) => { - const theme = useTheme(); - const isDarkMode = useColorScheme() === 'dark'; - const {user_id} = useSelector((state: any) => state.user); - const [refreshing, setRefreshing] = useState(false); - const {isConnected} = useSelector((state: any) => state.network); - const [articleId, setArticleId] = useState(); - const [authorId, setAuthorId] = useState(''); - const [recordId, setRecordId] = useState(''); - const [selectedCardId, setSelectedCardId] = useState(''); - const dispatch = useDispatch(); - const {mutate: updateViewCount} = - useUpdateViewCount(articleId ?? 0); - - const {data: user, refetch, isLoading} = useGetProfile(); - - if (user) { - dispatch(setUserHandle(user.user_handle)); - } - const onArticleViewed = ({ - articleId, - authorId, - recordId, - }: { - articleId: number; - authorId: string; - recordId: string; - }) => { - if (isConnected) { - setArticleId(articleId); - setAuthorId(authorId); - setRecordId(recordId); - - updateViewCount(Number(articleId), { - onSuccess: async () => { - navigation.navigate('ArticleScreen', { - articleId: Number(articleId), - authorId: authorId, - recordId: recordId, - }); - }, - - onError: () => { - Alert.alert('Internal server error, try again!'); - }, - }); - - } else { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - } - }; - - - const isDoctor = user !== undefined ? user.isDoctor : false; - const bottomBarHeight = useBottomTabBarHeight(); - - const onRefresh = () => { - setRefreshing(true); - refetch(); - setRefreshing(false); - }; - - useFocusEffect( - useCallback(() => { - refetch(); - }, [refetch]), - ); - - - // eslint-disable-next-line react-hooks/exhaustive-deps - const handleReportAction = (item: ArticleData) => { - navigation.navigate('ReportScreen', { - articleId: item._id, - authorId: item.authorId as string, - commentId: null, - podcastId: null, - }); - }; - const renderItem = useCallback( - ({item}: {item: ArticleData}) => { - return ( - {}} - handleReportAction={handleReportAction} - handleEditRequestAction={() => {}} - source="profile" - /> - ); - }, - [ - selectedCardId, - navigation, - onRefresh, - handleReportAction, - ], - ); - - const onFollowerClick = () => { - if (isConnected) { - if (user && user.followers.length > 0) { - //dispatch(setSocialUserId('')); - navigation.navigate('SocialScreen', { - type: 1, - articleId: undefined, - social_user_id: undefined, - }); - } - } else { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - } - }; - - const onFollowingClick = () => { - if (isConnected) { - if (user && user.followings.length > 0) { - // dispatch(setSocialUserId('')); - navigation.navigate('SocialScreen', { - type: 2, - articleId: undefined, - social_user_id: undefined, - }); - } - } else { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - } - }; - - const renderHeader = () => { - if (user === undefined) { - return null; - } - - return ( - {}} - onOverviewClick={() => { - if (user) { - if (isConnected) { - navigation.navigate('OverviewScreen'); - } else { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - } - } - }} - /> - ); - }; - - - const [activeTab, setActiveTab] = useState<'Insight' | 'Reposts' | 'Saved'>('Insight'); - - if (isLoading) { - return ( - - - - - ); - } - - const tabColors = { - background: isDarkMode ? '#121212' : '#ffffff', - border: isDarkMode ? '#374151' : '#e5e7eb', - activeText: PRIMARY_COLOR, - inactiveText: isDarkMode ? '#9ca3af' : '#9098A3', - }; - - return ( - - - - {renderHeader()} - - {/* Custom Tab Bar */} - - setActiveTab('Insight')} - accessibilityRole="tab" - accessibilityState={{ selected: activeTab === 'Insight' }} - accessibilityLabel="Insight tab" - > - - Insight - - - setActiveTab('Reposts')} - accessibilityRole="tab" - accessibilityState={{ selected: activeTab === 'Reposts' }} - accessibilityLabel={`Reposts tab, ${user?.repostArticles.length || 0} reposts`} - > - - Reposts ({user?.repostArticles.length || 0}) - - - setActiveTab('Saved')} - accessibilityRole="tab" - accessibilityState={{ selected: activeTab === 'Saved' }} - accessibilityLabel={`Saved tab, ${user?.savedArticles.length || 0} saved articles`} - > - - Saved ({user?.savedArticles.length || 0}) - - - - - {/* Tab Content */} - - {activeTab === 'Insight' && ( - - - - )} - - {activeTab === 'Reposts' && ( - item?._id} - ListEmptyComponent={ - - } - /> - )} - - {activeTab === 'Saved' && ( - item?._id} - ListEmptyComponent={ - - } - /> - )} - - - - ); -}; - -export default ProfileScreen; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#0CAFFF', - }, - innerContainer: { - flex: 1, - }, - scrollViewContentContainer: { - paddingHorizontal: 6, - marginTop: 6, - }, - flatListContentContainer: { - paddingHorizontal: 16, - }, - tabBarContainer: { - flexDirection: 'row', - width: '100%', - borderBottomWidth: 1, - }, - tabButton: { - flex: 1, - paddingVertical: 14, - alignItems: 'center', - borderBottomWidth: 3, - borderBottomColor: 'transparent', - }, - tabButtonText: { - fontSize: 15, - fontWeight: '600', - textTransform: 'capitalize', - }, - tabContentContainer: { - flex: 1, - width: '100%', - }, - profileImage: { - height: 130, - width: 130, - borderRadius: 100, - objectFit: 'cover', - resizeMode: 'contain', - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, -}); + +const ProfileScreen = ({navigation}: ProfileScreenProps) => { + const theme = useTheme(); + const isDarkMode = useColorScheme() === 'dark'; + const {user_id} = useSelector((state: any) => state.user); + const [refreshing, setRefreshing] = useState(false); + const {isConnected} = useSelector((state: any) => state.network); + const [articleId, setArticleId] = useState(); + const [authorId, setAuthorId] = useState(''); + const [recordId, setRecordId] = useState(''); + const [selectedCardId, setSelectedCardId] = useState(''); + const dispatch = useDispatch(); + const {mutate: updateViewCount} = + useUpdateViewCount(articleId ?? 0); + + const {data: user, refetch, isLoading} = useGetProfile(); + + if (user) { + dispatch(setUserHandle(user.user_handle)); + } + const onArticleViewed = ({ + articleId, + authorId, + recordId, + }: { + articleId: number; + authorId: string; + recordId: string; + }) => { + if (isConnected) { + setArticleId(articleId); + setAuthorId(authorId); + setRecordId(recordId); + + updateViewCount(Number(articleId), { + onSuccess: async () => { + navigation.navigate('ArticleScreen', { + articleId: Number(articleId), + authorId: authorId, + recordId: recordId, + }); + }, + + onError: () => { + Alert.alert('Internal server error, try again!'); + }, + }); + + } else { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + } + }; + + + const isDoctor = user !== undefined ? user.isDoctor : false; + const bottomBarHeight = useBottomTabBarHeight(); + + const onRefresh = () => { + setRefreshing(true); + refetch(); + setRefreshing(false); + }; + + useFocusEffect( + useCallback(() => { + refetch(); + }, [refetch]), + ); + + + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleReportAction = (item: ArticleData) => { + navigation.navigate('ReportScreen', { + articleId: item._id, + authorId: item.authorId as string, + commentId: null, + podcastId: null, + }); + }; + const renderItem = useCallback( + ({item}: {item: ArticleData}) => { + return ( + {}} + handleReportAction={handleReportAction} + handleEditRequestAction={() => {}} + source="profile" + /> + ); + }, + [ + selectedCardId, + navigation, + onRefresh, + handleReportAction, + ], + ); + + const onFollowerClick = () => { + if (isConnected) { + if (user && user.followers.length > 0) { + //dispatch(setSocialUserId('')); + navigation.navigate('SocialScreen', { + type: 1, + articleId: undefined, + social_user_id: undefined, + }); + } + } else { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + } + }; + + const onFollowingClick = () => { + if (isConnected) { + if (user && user.followings.length > 0) { + // dispatch(setSocialUserId('')); + navigation.navigate('SocialScreen', { + type: 2, + articleId: undefined, + social_user_id: undefined, + }); + } + } else { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + } + }; + + const renderHeader = () => { + if (user === undefined) { + return null; + } + + return ( + {}} + onOverviewClick={() => { + if (user) { + if (isConnected) { + navigation.navigate('OverviewScreen'); + } else { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + } + } + }} + /> + ); + }; + + + const [activeTab, setActiveTab] = useState<'Insight' | 'Reposts' | 'Saved'>('Insight'); + + if (isLoading) { + return ( + + + + + ); + } + + const tabColors = { + background: isDarkMode ? '#121212' : '#ffffff', + border: isDarkMode ? '#374151' : '#e5e7eb', + activeText: PRIMARY_COLOR, + inactiveText: isDarkMode ? '#9ca3af' : '#9098A3', + }; + + return ( + + + + {renderHeader()} + + {/* Custom Tab Bar */} + + setActiveTab('Insight')} + accessibilityRole="tab" + accessibilityState={{ selected: activeTab === 'Insight' }} + accessibilityLabel="Insight tab" + > + + Insight + + + setActiveTab('Reposts')} + accessibilityRole="tab" + accessibilityState={{ selected: activeTab === 'Reposts' }} + accessibilityLabel={`Reposts tab, ${user?.repostArticles.length || 0} reposts`} + > + + Reposts ({user?.repostArticles.length || 0}) + + + setActiveTab('Saved')} + accessibilityRole="tab" + accessibilityState={{ selected: activeTab === 'Saved' }} + accessibilityLabel={`Saved tab, ${user?.savedArticles.length || 0} saved articles`} + > + + Saved ({user?.savedArticles.length || 0}) + + + + + {/* Tab Content */} + + {activeTab === 'Insight' && ( + + + + )} + + {activeTab === 'Reposts' && ( + item?._id} + ListEmptyComponent={ + + } + /> + )} + + {activeTab === 'Saved' && ( + item?._id} + ListEmptyComponent={ + + } + /> + )} + + + + ); +}; + +export default ProfileScreen; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0CAFFF', + }, + innerContainer: { + flex: 1, + }, + scrollViewContentContainer: { + paddingHorizontal: 6, + marginTop: 6, + }, + flatListContentContainer: { + paddingHorizontal: 16, + }, + tabBarContainer: { + flexDirection: 'row', + width: '100%', + borderBottomWidth: 1, + }, + tabButton: { + flex: 1, + paddingVertical: 14, + alignItems: 'center', + borderBottomWidth: 3, + borderBottomColor: 'transparent', + }, + tabButtonText: { + fontSize: rf(15), + fontWeight: '600', + textTransform: 'capitalize', + }, + tabContentContainer: { + flex: 1, + width: '100%', + }, + profileImage: { + height: 130, + width: 130, + borderRadius: 100, + objectFit: 'cover', + resizeMode: 'contain', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); diff --git a/frontend/src/screens/SocialScreen.tsx b/frontend/src/screens/SocialScreen.tsx index a5153dac..6233849e 100644 --- a/frontend/src/screens/SocialScreen.tsx +++ b/frontend/src/screens/SocialScreen.tsx @@ -1,279 +1,282 @@ -import {useEffect, useState} from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - Image, - Platform, - ScrollView, -} from 'react-native'; -import {SocialScreenProps} from '../type'; -import {PRIMARY_COLOR} from '../helper/Theme'; -import {GET_STORAGE_DATA} from '../helper/APIUtils'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import {useSelector} from 'react-redux'; -import {useQueryClient} from '@tanstack/react-query'; -import {useSocket} from '../contexts/SocketContext'; -import Loader from '../components/Loader'; -import {useGetUserSocials} from '../hooks/useGetUserSocialCircle'; -import {useUpdateFollowStatus} from '../hooks/useUpdateFollowStatus'; -import Snackbar from 'react-native-snackbar'; -import LoadingSpinner from '../components/LoadingSpinner'; +import {useEffect, useState} from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Image, + Platform, + ScrollView, +} from 'react-native'; +import {SocialScreenProps} from '../type'; +import {PRIMARY_COLOR} from '../helper/Theme'; +import {GET_STORAGE_DATA} from '../helper/APIUtils'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {useSelector} from 'react-redux'; +import {useQueryClient} from '@tanstack/react-query'; +import {useSocket} from '../contexts/SocketContext'; +import Loader from '../components/Loader'; +import {useGetUserSocials} from '../hooks/useGetUserSocialCircle'; +import {useUpdateFollowStatus} from '../hooks/useUpdateFollowStatus'; +import Snackbar from 'react-native-snackbar'; +import LoadingSpinner from '../components/LoadingSpinner'; import { rf } from '../helper/Metric'; -export default function Socialcreen({navigation, route}: SocialScreenProps) { - const insets = useSafeAreaInsets(); - //const socials = route.params.socials; - const {type, articleId, social_user_id} = route.params; - const socket = useSocket(); - const [userid, setUserId] = useState(''); - const queryClient = useQueryClient(); - - const {mutate: followMutate, isPending: followMutationPending} = - useUpdateFollowStatus(); - - const {user_id, user_handle} = useSelector( - (state: any) => state.user, - ); - - const { - data: socials, - refetch, - isLoading, - } = useGetUserSocials({ - type: type, - articleId: articleId, - social_user_id: social_user_id, - }); - - useEffect(() => { - queryClient.invalidateQueries({queryKey: ['get-user-socials']}); - navigation.setOptions({ - headerTitle: - type === 1 ? 'Follower' : type === 2 ? 'Followings' : 'Contributors', - headerTitleAlign: 'center', - }); - }, [navigation, queryClient, type]); - - if (isLoading) { - return ; - } - - return ( - - - {socials && socials.length === 0 && ( - - - No users found - - - )} - {socials && - socials.map((follower, index) => ( - - - { - navigation.navigate('UserProfileScreen', { - authorId: follower._id, - author_handle: undefined, - }); - }} - activeOpacity={0.7}> - {follower.Profile_image && follower.Profile_image !== '' ? ( - - ) : ( - - )} - - - - {follower ? follower?.user_name : ''} - - - {follower.followers - ? follower.followers.length > 1 - ? `${follower.followers.length} followers` - : `${follower.followers.length} follower` - : '0 follower'} - - - - - {follower && user_id !== follower._id && ( - <> - {followMutationPending ? ( - - ) : ( - { - setUserId(follower._id); - - followMutate(follower._id, { - onSuccess: data => { - - if (data) { - if (socket) { - socket.emit('notification', { - type: 'userFollow', - userId: userid, - message: { - title: `${user_handle} has followed you`, - body: '', - }, - }); - } - refetch(); - } - - }, - - onError: err => { - console.log('Update Follow mutation error', err); - Snackbar.show({ - text: 'Try again!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }); - }} - activeOpacity={0.7}> - - {follower.followers && - follower.followers.includes(user_id) - ? 'Following' - : 'Follow'} - - - )} - - )} - - ))} - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#f5f5f5', - }, - scrollContent: { - padding: 16, - }, - userCard: { - backgroundColor: '#ffffff', - borderRadius: 12, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - padding: 16, - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, - }, - emptyCard: { - backgroundColor: '#ffffff', - borderRadius: 12, - padding: 32, - alignItems: 'center', - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, - }, - authorContainer: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - marginRight: 12, - }, - userInfo: { - marginLeft: 12, - flex: 1, - }, - authorImage: { - height: 56, - width: 56, - borderRadius: 28, - borderWidth: 3, - borderColor: '#ffffff', - }, - authorName: { - fontWeight: '700', - fontSize: 16, - color: '#1a1a1a', - marginBottom: 4, - }, - authorFollowers: { - fontWeight: '500', - fontSize: 13, - color: '#6b7280', - }, - followButton: { - backgroundColor: PRIMARY_COLOR, - paddingHorizontal: 20, - borderRadius: 20, - paddingVertical: 10, - shadowColor: PRIMARY_COLOR, - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 4, - }, - followingButton: { - backgroundColor: '#d1d5db', - }, - followButtonText: { - color: '#ffffff', - fontSize: 14, - fontWeight: '700', - }, - message: { - fontSize: 16, - color: '#6b7280', - textAlign: 'center', - fontWeight: '500', - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, -}); + +export default function Socialcreen({navigation, route}: SocialScreenProps) { + const insets = useSafeAreaInsets(); + //const socials = route.params.socials; + const {type, articleId, social_user_id} = route.params; + const socket = useSocket(); + const [userid, setUserId] = useState(''); + const queryClient = useQueryClient(); + + const {mutate: followMutate, isPending: followMutationPending} = + useUpdateFollowStatus(); + + const {user_id, user_handle} = useSelector( + (state: any) => state.user, + ); + + const { + data: socials, + refetch, + isLoading, + } = useGetUserSocials({ + type: type, + articleId: articleId, + social_user_id: social_user_id, + }); + + useEffect(() => { + queryClient.invalidateQueries({queryKey: ['get-user-socials']}); + navigation.setOptions({ + headerTitle: + type === 1 ? 'Follower' : type === 2 ? 'Followings' : 'Contributors', + headerTitleAlign: 'center', + }); + }, [navigation, queryClient, type]); + + if (isLoading) { + return ; + } + + return ( + + + {socials && socials.length === 0 && ( + + + No users found + + + )} + {socials && + socials.map((follower, index) => ( + + + { + navigation.navigate('UserProfileScreen', { + authorId: follower._id, + author_handle: undefined, + }); + }} + activeOpacity={0.7}> + {follower.Profile_image && follower.Profile_image !== '' ? ( + + ) : ( + + )} + + + + {follower ? follower?.user_name : ''} + + + {follower.followers + ? follower.followers.length > 1 + ? `${follower.followers.length} followers` + : `${follower.followers.length} follower` + : '0 follower'} + + + + + {follower && user_id !== follower._id && ( + <> + {followMutationPending ? ( + + ) : ( + { + setUserId(follower._id); + + followMutate(follower._id, { + onSuccess: data => { + + if (data) { + if (socket) { + socket.emit('notification', { + type: 'userFollow', + userId: userid, + message: { + title: `${user_handle} has followed you`, + body: '', + }, + }); + } + refetch(); + } + + }, + + onError: err => { + console.log('Update Follow mutation error', err); + Snackbar.show({ + text: 'Try again!', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }); + }} + activeOpacity={0.7}> + + {follower.followers && + follower.followers.includes(user_id) + ? 'Following' + : 'Follow'} + + + )} + + )} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + scrollContent: { + padding: 16, + }, + userCard: { + backgroundColor: '#ffffff', + borderRadius: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + emptyCard: { + backgroundColor: '#ffffff', + borderRadius: 12, + padding: 32, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + authorContainer: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + marginRight: 12, + }, + userInfo: { + marginLeft: 12, + flex: 1, + }, + authorImage: { + height: 56, + width: 56, + borderRadius: 28, + borderWidth: 3, + borderColor: '#ffffff', + }, + authorName: { + fontWeight: '700', + fontSize: rf(16), + color: '#1a1a1a', + marginBottom: 4, + }, + authorFollowers: { + fontWeight: '500', + fontSize: rf(13), + color: '#6b7280', + }, + followButton: { + backgroundColor: PRIMARY_COLOR, + paddingHorizontal: 20, + borderRadius: 20, + paddingVertical: 10, + shadowColor: PRIMARY_COLOR, + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 4, + minHeight: 44, + justifyContent: 'center', + }, + followingButton: { + backgroundColor: '#d1d5db', + }, + followButtonText: { + color: '#ffffff', + fontSize: rf(14), + fontWeight: '700', + }, + message: { + fontSize: rf(16), + color: '#6b7280', + textAlign: 'center', + fontWeight: '500', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, +}); diff --git a/frontend/src/screens/SplashScreen.tsx b/frontend/src/screens/SplashScreen.tsx index 61401a01..c446a97c 100644 --- a/frontend/src/screens/SplashScreen.tsx +++ b/frontend/src/screens/SplashScreen.tsx @@ -1,159 +1,160 @@ -import React, {useCallback, useEffect} from 'react'; -import {Image, StyleSheet} from 'react-native'; -import {YStack, Text, Button} from 'tamagui'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - Easing, -} from 'react-native-reanimated'; -import {SplashScreenProp} from '../type'; -import {useDispatch} from 'react-redux'; -import {KEYS, clearStorage, retrieveItem} from '../helper/Utils'; -import {setUserId, setUserToken, setUserHandle} from '../store/UserSlice'; -import { useCheckTokenStatus } from '@/src/hooks/useGetTokenStatus'; -import { SECURE_KEYS, SecureKey, secureRetrieveItem } from '../helper/SecureStorageUtils'; +import React, {useCallback, useEffect} from 'react'; +import {Image, StyleSheet} from 'react-native'; +import {YStack, Text, Button} from 'tamagui'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + Easing, +} from 'react-native-reanimated'; +import {SplashScreenProp} from '../type'; +import {useDispatch} from 'react-redux'; +import {KEYS, clearStorage, retrieveItem} from '../helper/Utils'; +import {setUserId, setUserToken, setUserHandle} from '../store/UserSlice'; +import { useCheckTokenStatus } from '@/src/hooks/useGetTokenStatus'; +import { SECURE_KEYS, SecureKey, secureRetrieveItem } from '../helper/SecureStorageUtils'; import { rf } from '../helper/Metric'; -export default function SplashScreen({navigation}: SplashScreenProp) { - const opacity = useSharedValue(0); - const scale = useSharedValue(0.8); - const translateY = useSharedValue(20); - - const dispatch = useDispatch(); - - const {data: tokenRes, isLoading} = useCheckTokenStatus(); - - - // function isDateMoreThanSevenDaysOld(dateString: string) { - // const inputDate = new Date(dateString).getTime(); - // const currentDate = new Date().getTime(); - // const timeDifference = currentDate - inputDate; - // const daysDifference = timeDifference / (1000 * 3600 * 24); - // return daysDifference >= 6; - // } - - const checkLoginStatus = useCallback(async () => { - - if(!tokenRes){ - return; - } - try { - const userId = await retrieveItem(KEYS.USER_ID); - const user = await secureRetrieveItem(SECURE_KEYS.USER_TOKEN as SecureKey); - const user_handle = await retrieveItem(KEYS.USER_HANDLE); - if ( - // user_handle && - // user && - // expiryDate && - // !isDateMoreThanSevenDaysOld(expiryDate) - tokenRes?.isValid - ) { - - dispatch(setUserId(userId)); - dispatch(setUserToken(user)); - dispatch(setUserHandle(user_handle)); - - navigation.reset({ - index: 0, - routes: [{name: 'TabNavigation'}], - }); - } else { - await clearStorage(); - navigation.reset({ - index: 0, - routes: [{name: 'LoginScreen'}], - }); - } - } catch (error) { - console.error('Error retrieving user data from storage', error); - await clearStorage(); - // navigation.navigate('LoginScreen'); - navigation.reset({ - index: 0, - routes: [{name: 'LoginScreen'}], - }); - } - },[dispatch, navigation, tokenRes]); - - useEffect(() => { - console.log('Token status:', tokenRes); - if (tokenRes) { - checkLoginStatus(); - } - }, [checkLoginStatus, tokenRes]); - useEffect(() => { - opacity.value = withTiming(1, { - duration: 1200, - easing: Easing.out(Easing.exp), - }); - scale.value = withTiming(1, { - duration: 1000, - easing: Easing.out(Easing.ease), - }); - translateY.value = withTiming(0, { - duration: 1000, - easing: Easing.out(Easing.ease), - }); - }, [opacity, scale, translateY]); - - const animatedStyle = useAnimatedStyle(() => ({ - opacity: opacity.value, - transform: [{scale: scale.value}, {translateY: translateY.value}], - })); - - return ( - - - - - Ultimate Health - - - Empowering wellness through knowledge - - - - - - ); -} - -const styles = StyleSheet.create({ - icon: { - width: 130, - height: 130, - borderRadius: 65, - alignSelf: 'center', - }, -}); + +export default function SplashScreen({navigation}: SplashScreenProp) { + const opacity = useSharedValue(0); + const scale = useSharedValue(0.8); + const translateY = useSharedValue(20); + + const dispatch = useDispatch(); + + const {data: tokenRes, isLoading} = useCheckTokenStatus(); + + + // function isDateMoreThanSevenDaysOld(dateString: string) { + // const inputDate = new Date(dateString).getTime(); + // const currentDate = new Date().getTime(); + // const timeDifference = currentDate - inputDate; + // const daysDifference = timeDifference / (1000 * 3600 * 24); + // return daysDifference >= 6; + // } + + const checkLoginStatus = useCallback(async () => { + + if(!tokenRes){ + return; + } + try { + const userId = await retrieveItem(KEYS.USER_ID); + const user = await secureRetrieveItem(SECURE_KEYS.USER_TOKEN as SecureKey); + const user_handle = await retrieveItem(KEYS.USER_HANDLE); + if ( + // user_handle && + // user && + // expiryDate && + // !isDateMoreThanSevenDaysOld(expiryDate) + tokenRes?.isValid + ) { + + dispatch(setUserId(userId)); + dispatch(setUserToken(user)); + dispatch(setUserHandle(user_handle)); + + navigation.reset({ + index: 0, + routes: [{name: 'TabNavigation'}], + }); + } else { + await clearStorage(); + navigation.reset({ + index: 0, + routes: [{name: 'LoginScreen'}], + }); + } + } catch (error) { + console.error('Error retrieving user data from storage', error); + await clearStorage(); + // navigation.navigate('LoginScreen'); + navigation.reset({ + index: 0, + routes: [{name: 'LoginScreen'}], + }); + } + },[dispatch, navigation, tokenRes]); + + useEffect(() => { + console.log('Token status:', tokenRes); + if (tokenRes) { + checkLoginStatus(); + } + }, [checkLoginStatus, tokenRes]); + useEffect(() => { + opacity.value = withTiming(1, { + duration: 1200, + easing: Easing.out(Easing.exp), + }); + scale.value = withTiming(1, { + duration: 1000, + easing: Easing.out(Easing.ease), + }); + translateY.value = withTiming(0, { + duration: 1000, + easing: Easing.out(Easing.ease), + }); + }, [opacity, scale, translateY]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{scale: scale.value}, {translateY: translateY.value}], + })); + + return ( + + + + + Ultimate Health + + + Empowering wellness through knowledge + + + + + + ); +} + +const styles = StyleSheet.create({ + icon: { + width: 130, + height: 130, + borderRadius: 65, + alignSelf: 'center', + }, +}); diff --git a/frontend/src/screens/UserProfileScreen.tsx b/frontend/src/screens/UserProfileScreen.tsx index dff91cc9..2ee69741 100644 --- a/frontend/src/screens/UserProfileScreen.tsx +++ b/frontend/src/screens/UserProfileScreen.tsx @@ -1,473 +1,474 @@ -import { - StyleSheet, - View, - Alert, - TouchableOpacity, - useColorScheme, -} from 'react-native'; -import React, {useCallback, useState} from 'react'; -import {StatusBar} from 'expo-status-bar'; -import {PRIMARY_COLOR} from '../helper/Theme'; -import ActivityOverview from '../components/ActivityOverview'; -import {Tabs, MaterialTabBar} from 'react-native-collapsible-tab-view'; -import ArticleCard from '../components/ArticleCard'; -import { useTheme } from 'tamagui'; -import {useSelector} from 'react-redux'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import ProfileHeader from '../components/ProfileHeader'; -import {ArticleData, UserProfileScreenProp} from '../type'; -import FontAwesome6 from '@expo/vector-icons/FontAwesome6'; -import Loader from '../components/Loader'; -import {useFocusEffect} from '@react-navigation/native'; -import Snackbar from 'react-native-snackbar'; -import {useSocket} from '../contexts/SocketContext'; -import {useRequestArticleEdit} from '../hooks/useRequestArticleEdit'; -import {useUpdateFollowStatus} from '../hooks/useUpdateFollowStatus'; -import {useUpdateViewCount} from '../hooks/useUpdateViewCount'; -import { useGetAuthorProfile } from '../hooks/useGetAuthorProfile'; -import {useGetTotalLikeViewStatus} from '../hooks/useGetTotalLikeViewStatus'; -import { NoArticleState } from '../components/EmptyStates'; +import { + StyleSheet, + View, + Alert, + TouchableOpacity, + useColorScheme, +} from 'react-native'; +import React, {useCallback, useState} from 'react'; +import {StatusBar} from 'expo-status-bar'; +import {PRIMARY_COLOR} from '../helper/Theme'; +import ActivityOverview from '../components/ActivityOverview'; +import {Tabs, MaterialTabBar} from 'react-native-collapsible-tab-view'; +import ArticleCard from '../components/ArticleCard'; +import { useTheme } from 'tamagui'; +import {useSelector} from 'react-redux'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import ProfileHeader from '../components/ProfileHeader'; +import {ArticleData, UserProfileScreenProp} from '../type'; +import FontAwesome6 from '@expo/vector-icons/FontAwesome6'; +import Loader from '../components/Loader'; +import {useFocusEffect} from '@react-navigation/native'; +import Snackbar from 'react-native-snackbar'; +import {useSocket} from '../contexts/SocketContext'; +import {useRequestArticleEdit} from '../hooks/useRequestArticleEdit'; +import {useUpdateFollowStatus} from '../hooks/useUpdateFollowStatus'; +import {useUpdateViewCount} from '../hooks/useUpdateViewCount'; +import { useGetAuthorProfile } from '../hooks/useGetAuthorProfile'; +import {useGetTotalLikeViewStatus} from '../hooks/useGetTotalLikeViewStatus'; +import { NoArticleState } from '../components/EmptyStates'; import { rf } from '../helper/Metric'; -const UserProfileScreen = ({navigation, route}: UserProfileScreenProp) => { - const theme = useTheme(); - const isDarkMode = useColorScheme() === 'dark'; - const {authorId, userId, author_handle} = (route.params || {}) as any; - const {userId: routeUserId, userHandle: routeUserHandle} = (route.params || {}) as any; - const {user_id, user_handle} = useSelector( - (state: any) => state.user, - ); - const {isConnected} = useSelector((state: any) => state.network); - const [refreshing, setRefreshing] = useState(false); - - const [articleId, setArticleId] = useState(); - const [recordId, setRecordId] = useState(''); - const [selectedCardId, setSelectedCardId] = useState(''); - - //const [authorId, setAuthorId] = useState(''); - const socket = useSocket(); - const {mutate: requestEdit, isPending: requestEditPending} = - useRequestArticleEdit(); - - const {mutate: followMutate} = useUpdateFollowStatus(); - - const {mutate: updateViewCount} = useUpdateViewCount(articleId ?? 0); - - // Get the actual authorId string - // const actualAuthorId = typeof authorId === 'string' ? authorId : authorId?._id || userId || ''; - const actualAuthorId = routeUserId || user_id; // Prioritize routeUserId, fallback to current user_id - const { - data: user, - refetch, - isLoading, - } = useGetAuthorProfile(actualAuthorId, routeUserHandle, user_id, isConnected); - - const isDoctor = user !== undefined ? user.isDoctor : false; - //const bottomBarHeight = useBottomTabBarHeight(); - - // Fetch statistics data for the user being viewed - const {data: statsData} = useGetTotalLikeViewStatus({ - user_id: user_id, - userId: actualAuthorId, - others: true, - isConnected: isConnected, - }); - - - const onArticleViewed = ({ - articleId, - authorId: viewedAuthorId, - recordId, - }: { - articleId: number; - authorId: string; - recordId: string; - }) => { - if (isConnected) { - setArticleId(articleId); - setRecordId(recordId); - - updateViewCount(undefined, { - onSuccess: async () => { - navigation.navigate('ArticleScreen', { - articleId: Number(articleId), - authorId: viewedAuthorId, - recordId: recordId, - }); - }, - - onError: error => { - console.log('Update View Count Error', error); - Alert.alert('Internal server error, try again!'); - }, - }); - } else { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - } - }; - - const onRefresh = useCallback(() => { - setRefreshing(true); - refetch(); - - setRefreshing(false); - }, [refetch]); - - useFocusEffect( - useCallback(() => { - console.log('Current authorId:', authorId); // Check if authorId changes - refetch(); - }, [refetch, authorId]), // Ensure authorId is a stable value - ); - - const handleReportAction = useCallback( - (item: ArticleData) => { - navigation.navigate('ReportScreen', { - articleId: item._id, - authorId: item.authorId as string, - commentId: null, - podcastId: null, - }); - }, - [navigation], - ); - - const renderItem = useCallback( - ({item}: {item: ArticleData}) => { - return ( - {}} - handleReportAction={handleReportAction} - handleEditRequestAction={(item, index, reason) => { - if (isConnected) { - requestEdit( - { - articleId: item._id, - reason: reason, - articleRecordId: item.pb_recordId, - }, - { - onSuccess: data => { - Snackbar.show({ - text: data, - duration: Snackbar.LENGTH_SHORT, - }); - }, - onError: err => { - console.log(err); - Snackbar.show({ - text: 'Try again!', - duration: Snackbar.LENGTH_LONG, - }); - }, - }, - ); - } else { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - } - }} - source="user-profile" - /> - ); - }, - [ - handleReportAction, - isConnected, - navigation, - onRefresh, - selectedCardId, - requestEdit, - ], - ); - - const onFollowerClick = () => { - if (user && user.followers.length > 0) { - // dispatch(setSocialUserId(user._id)); - navigation.navigate('SocialScreen', { - type: 1, - articleId: undefined, - social_user_id: user._id, - }); - } - }; - - const onFollowingClick = () => { - if (isConnected) { - if (user && user.followings.length > 0) { - // dispatch(setSocialUserId(user._id)); - navigation.navigate('SocialScreen', { - type: 2, - articleId: undefined, - social_user_id: user._id, - }); - } - } else { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - } - }; - - const handleFollow = () => { - if (isConnected) { - if (actualAuthorId) { - followMutate(actualAuthorId, { - onSuccess: data => { - if (data) { - if (socket) { - socket.emit('notification', { - type: 'userFollow', - userId: actualAuthorId, - message: { - title: `${user_handle ? user_handle : 'Someone'} has followed you`, - body: '', - }, - }); - } - - onRefresh(); - } - }, - - onError: err => { - console.log('Update Follow mutation error', err); - Snackbar.show({ - text: 'Try again!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }); - } - } else { - Snackbar.show({ - text: 'Please check your internet connection!', - duration: Snackbar.LENGTH_SHORT, - }); - } - }; - - const renderHeader = () => { - if (user === undefined) { - return null; - } // Safeguard to prevent rendering if user is undefined - - const authorUser = typeof authorId === 'string' ? user : authorId; - - return ( - follower === user_id) - ? true - : false - } - onFollowClick={handleFollow} - onOverviewClick={() => {}} - /> - ); - }; - - const renderTabBar = (props: any) => { - return ( - - ); - }; - - if (isLoading || requestEditPending) { - return ( - - - - - ); - } - - return ( - - - { - navigation.goBack(); - }}> - - - - - {/* Tab 1 */} - - - - - - {/* Tab 2 */} - - item?._id} - refreshing={refreshing} - ListEmptyComponent={ - - } - /> - - - - item?._id} - refreshing={refreshing} - ListEmptyComponent={ - - } - /> - - - - - ); -}; - -export default UserProfileScreen; - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - innerContainer: { - flex: 1, - }, - tabsContainer: { - overflow: 'hidden', - }, - scrollViewContentContainer: { - paddingHorizontal: 6, - marginTop: 16, - flexGrow: 1, - }, - flatListContentContainer: { - paddingHorizontal: 10, - }, - indicatorStyle: { - backgroundColor: 'white', - }, - tabBarStyle: { - backgroundColor: 'white', - }, - labelStyle: { - fontWeight: '600', - fontSize: 13, - //color: 'black', - textTransform: 'capitalize', - }, - contentContainerStyle: { - width: '100%', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - shadowOpacity: 0, - shadowOffset: {width: 0, height: 0}, - shadowColor: 'white', - }, - message: { - fontSize: 16, - color: '#555', - textAlign: 'center', - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - headerLeftButtonEditorScreen: { - marginLeft: 15, - paddingHorizontal: 8, - paddingVertical: 6, - }, -}); + +const UserProfileScreen = ({navigation, route}: UserProfileScreenProp) => { + const theme = useTheme(); + const isDarkMode = useColorScheme() === 'dark'; + const {authorId, userId, author_handle} = (route.params || {}) as any; + const {userId: routeUserId, userHandle: routeUserHandle} = (route.params || {}) as any; + const {user_id, user_handle} = useSelector( + (state: any) => state.user, + ); + const {isConnected} = useSelector((state: any) => state.network); + const [refreshing, setRefreshing] = useState(false); + + const [articleId, setArticleId] = useState(); + const [recordId, setRecordId] = useState(''); + const [selectedCardId, setSelectedCardId] = useState(''); + + //const [authorId, setAuthorId] = useState(''); + const socket = useSocket(); + const {mutate: requestEdit, isPending: requestEditPending} = + useRequestArticleEdit(); + + const {mutate: followMutate} = useUpdateFollowStatus(); + + const {mutate: updateViewCount} = useUpdateViewCount(articleId ?? 0); + + // Get the actual authorId string + // const actualAuthorId = typeof authorId === 'string' ? authorId : authorId?._id || userId || ''; + const actualAuthorId = routeUserId || user_id; // Prioritize routeUserId, fallback to current user_id + const { + data: user, + refetch, + isLoading, + } = useGetAuthorProfile(actualAuthorId, routeUserHandle, user_id, isConnected); + + const isDoctor = user !== undefined ? user.isDoctor : false; + //const bottomBarHeight = useBottomTabBarHeight(); + + // Fetch statistics data for the user being viewed + const {data: statsData} = useGetTotalLikeViewStatus({ + user_id: user_id, + userId: actualAuthorId, + others: true, + isConnected: isConnected, + }); + + + const onArticleViewed = ({ + articleId, + authorId: viewedAuthorId, + recordId, + }: { + articleId: number; + authorId: string; + recordId: string; + }) => { + if (isConnected) { + setArticleId(articleId); + setRecordId(recordId); + + updateViewCount(undefined, { + onSuccess: async () => { + navigation.navigate('ArticleScreen', { + articleId: Number(articleId), + authorId: viewedAuthorId, + recordId: recordId, + }); + }, + + onError: error => { + console.log('Update View Count Error', error); + Alert.alert('Internal server error, try again!'); + }, + }); + } else { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + } + }; + + const onRefresh = useCallback(() => { + setRefreshing(true); + refetch(); + + setRefreshing(false); + }, [refetch]); + + useFocusEffect( + useCallback(() => { + console.log('Current authorId:', authorId); // Check if authorId changes + refetch(); + }, [refetch, authorId]), // Ensure authorId is a stable value + ); + + const handleReportAction = useCallback( + (item: ArticleData) => { + navigation.navigate('ReportScreen', { + articleId: item._id, + authorId: item.authorId as string, + commentId: null, + podcastId: null, + }); + }, + [navigation], + ); + + const renderItem = useCallback( + ({item}: {item: ArticleData}) => { + return ( + {}} + handleReportAction={handleReportAction} + handleEditRequestAction={(item, index, reason) => { + if (isConnected) { + requestEdit( + { + articleId: item._id, + reason: reason, + articleRecordId: item.pb_recordId, + }, + { + onSuccess: data => { + Snackbar.show({ + text: data, + duration: Snackbar.LENGTH_SHORT, + }); + }, + onError: err => { + console.log(err); + Snackbar.show({ + text: 'Try again!', + duration: Snackbar.LENGTH_LONG, + }); + }, + }, + ); + } else { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + } + }} + source="user-profile" + /> + ); + }, + [ + handleReportAction, + isConnected, + navigation, + onRefresh, + selectedCardId, + requestEdit, + ], + ); + + const onFollowerClick = () => { + if (user && user.followers.length > 0) { + // dispatch(setSocialUserId(user._id)); + navigation.navigate('SocialScreen', { + type: 1, + articleId: undefined, + social_user_id: user._id, + }); + } + }; + + const onFollowingClick = () => { + if (isConnected) { + if (user && user.followings.length > 0) { + // dispatch(setSocialUserId(user._id)); + navigation.navigate('SocialScreen', { + type: 2, + articleId: undefined, + social_user_id: user._id, + }); + } + } else { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + } + }; + + const handleFollow = () => { + if (isConnected) { + if (actualAuthorId) { + followMutate(actualAuthorId, { + onSuccess: data => { + if (data) { + if (socket) { + socket.emit('notification', { + type: 'userFollow', + userId: actualAuthorId, + message: { + title: `${user_handle ? user_handle : 'Someone'} has followed you`, + body: '', + }, + }); + } + + onRefresh(); + } + }, + + onError: err => { + console.log('Update Follow mutation error', err); + Snackbar.show({ + text: 'Try again!', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }); + } + } else { + Snackbar.show({ + text: 'Please check your internet connection!', + duration: Snackbar.LENGTH_SHORT, + }); + } + }; + + const renderHeader = () => { + if (user === undefined) { + return null; + } // Safeguard to prevent rendering if user is undefined + + const authorUser = typeof authorId === 'string' ? user : authorId; + + return ( + follower === user_id) + ? true + : false + } + onFollowClick={handleFollow} + onOverviewClick={() => {}} + /> + ); + }; + + const renderTabBar = (props: any) => { + return ( + + ); + }; + + if (isLoading || requestEditPending) { + return ( + + + + + ); + } + + return ( + + + { + navigation.goBack(); + }}> + + + + + {/* Tab 1 */} + + + + + + {/* Tab 2 */} + + item?._id} + refreshing={refreshing} + ListEmptyComponent={ + + } + /> + + + + item?._id} + refreshing={refreshing} + ListEmptyComponent={ + + } + /> + + + + + ); +}; + +export default UserProfileScreen; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + innerContainer: { + flex: 1, + }, + tabsContainer: { + overflow: 'hidden', + }, + scrollViewContentContainer: { + paddingHorizontal: 6, + marginTop: 16, + flexGrow: 1, + }, + flatListContentContainer: { + paddingHorizontal: 10, + }, + indicatorStyle: { + backgroundColor: 'white', + }, + tabBarStyle: { + backgroundColor: 'white', + }, + labelStyle: { + fontWeight: '600', + fontSize: rf(13), + //color: 'black', + textTransform: 'capitalize', + }, + contentContainerStyle: { + width: '100%', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + shadowOpacity: 0, + shadowOffset: {width: 0, height: 0}, + shadowColor: 'white', + }, + message: { + fontSize: rf(16), + color: '#555', + textAlign: 'center', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + headerLeftButtonEditorScreen: { + marginLeft: 15, + paddingHorizontal: 8, + paddingVertical: 6, + }, +}); diff --git a/frontend/src/screens/article/ArticleDescriptionScreen.tsx b/frontend/src/screens/article/ArticleDescriptionScreen.tsx index 74e6aef4..4ebccf3a 100644 --- a/frontend/src/screens/article/ArticleDescriptionScreen.tsx +++ b/frontend/src/screens/article/ArticleDescriptionScreen.tsx @@ -1,779 +1,780 @@ -import React, {useEffect, useState} from 'react'; -import { - View, - Text, - StyleSheet, - TextInput, - TouchableOpacity, - ScrollView, - Image, - Alert, - Modal, - FlatList, -} from 'react-native'; -import {useSelector} from 'react-redux'; -import {ArticleDescriptionProp, Category} from '../../type'; -import Ionicon from '@expo/vector-icons/Ionicons'; -import {PRIMARY_COLOR} from '../../helper/Theme'; -import { - ImageLibraryOptions, - ImagePickerResponse, - launchImageLibrary, -} from 'react-native-image-picker'; -import ImageResizer from '@bam.tech/react-native-image-resizer'; -import {hp} from '../../helper/Metric'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { ttsLanguageList } from '@/src/helper/Utils'; - -const ARTICLE_TITLE_MAX_LENGTH = 150; -const ARTICLE_DESCRIPTION_MAX_LENGTH = 500; -const COUNTER_WARNING_THRESHOLD = 0.9; - -const ArticleDescriptionScreen = ({ - navigation, - route, -}: ArticleDescriptionProp) => { - const {article, htmlContent, translationSource} = route.params; - const isTranslation = Boolean(translationSource); - const [title, setTitle] = useState(''); - const [authorName, setAuthorName] = useState(''); - const [description, setDescription] = useState(''); - const [selectedGenres, setSelectedGenres] = useState([]); - const [language, setLanguage] = useState('en-IN'); - const [languageModalVisible, setLanguageModalVisible] = useState(false); - const {categories} = useSelector((state: any) => state.data); - const [imageUtils, setImageUtils] = useState(''); - - /** Set Initial Value */ - useEffect(() => { - if (article) { - setTitle(article.title); - setAuthorName(article.authorName); - setDescription(article.description); - setSelectedGenres(article.tags); - if (isTranslation) { - setLanguage(''); - } - setImageUtils( - article.imageUtils && article.imageUtils.length > 0 - ? article.imageUtils[0] - : '', - ); - } - }, [article, isTranslation]); - const handleGenrePress = (genre: Category) => { - if (isSelected(genre)) { - setSelectedGenres(selectedGenres.filter(item => item.id !== genre.id)); - } else if (selectedGenres.length < 5) { - // Check if the length of selected genres is less than 5 - setSelectedGenres([...selectedGenres, genre]); // Add the new genre to the selected genres array - } - }; - - const isSelected = (genre: Category) => selectedGenres.includes(genre); - - const handleCreatePost = () => { - if (title === '') { - Alert.alert('Title section is required'); - return; - } else if (authorName === '') { - Alert.alert('Author name is required'); - return; - } else if (description === '') { - Alert.alert('Please give proper description'); - return; - } else if (selectedGenres.length === 0) { - Alert.alert('Please select at least one suitable tags for your article.'); - return; - } else if (isTranslation && !language) { - Alert.alert('Please select a target language for the translation.'); - return; - } else if ( - isTranslation && - language === translationSource?.sourceLanguage - ) { - Alert.alert('Please choose a language different from the source article.'); - return; - } - - // Later purpose - else if (imageUtils.length === 0) { - Alert.alert('Please upload one image for your article.'); - return; - } - - navigation.navigate('EditorScreen', { - title: title, - authorName: authorName, - description: description, - selectedGenres: selectedGenres, - imageUtils: imageUtils, - htmlContent: htmlContent, - language: language, - requestId: undefined, - pb_record_id: undefined, - articleData: isTranslation ? undefined : article, - translationSource, - }); - }; - - const selectImage = () => { - const options: ImageLibraryOptions = { - mediaType: 'photo', - includeBase64: true, - }; - - launchImageLibrary(options, (response: ImagePickerResponse) => { - if (response.didCancel) { - //console.log('User cancelled image picker'); - } else if (response.errorMessage) { - console.log('ImagePicker Error: ', response.errorMessage); - } else if (response.assets) { - const {uri, fileSize} = response.assets[0]; - - // Check file size (1 MB limit) - if (fileSize && fileSize > 1024 * 1024) { - Alert.alert('Error', 'File size exceeds 1 MB.'); - return; - } - - // Check dimensions - if (uri) { - ImageResizer.createResizedImage(uri, 1000, 1000, 'JPEG', 100) - .then(resizedImageUri => { - // If the image is resized successfully, upload it - }) - .catch(err => { - console.log(err); - Alert.alert('Error', 'Could not resize the image.'); - }); - } - - setImageUtils(uri ? uri : ''); - } - }); - }; - - const availableLanguages = isTranslation - ? ttsLanguageList.filter( - lang => lang.code !== translationSource?.sourceLanguage, - ) - : ttsLanguageList; - - const isNearLimit = (value: string, limit: number) => - value.length >= limit * COUNTER_WARNING_THRESHOLD; - - const handleTitleChange = (text: string) => { - setTitle(text.slice(0, ARTICLE_TITLE_MAX_LENGTH)); - }; - - const handleDescriptionChange = (text: string) => { - setDescription(text.slice(0, ARTICLE_DESCRIPTION_MAX_LENGTH)); - }; - - const getLanguageLabel = (value: string) => { - return ttsLanguageList.find(lang => lang.code === value)?.name || 'Select language'; - }; - - const LanguageSelector = () => { - return ( - setLanguageModalVisible(false)}> - setLanguageModalVisible(false)}> - - - Select Language - setLanguageModalVisible(false)}> - - - - item.code} - renderItem={({item}) => ( - { - setLanguage(item.code); - setLanguageModalVisible(false); - }}> - - {item.name} - - {language === item.code && ( - - )} - - )} - /> - - - - ); - }; - - return ( - - - - - - {/* Header Section */} - - Article Details - - {isTranslation - ? `Create a translated version of "${translationSource?.sourceTitle}"` - : 'Fill in the information below to create your article'} - - - - {isTranslation && ( - - - - Translation article - - Source language: {getLanguageLabel(translationSource?.sourceLanguage ?? '')} - - - - )} - - {/* Image Upload Section */} - - - Cover Image - - {imageUtils ? ( - - - - - - Change - - setImageUtils('')}> - - Delete - - - - ) : ( - - - Upload Cover Image - Maximum file size: 1MB • JPG, PNG - - )} - - - {/* Basic Information Section */} - - Basic Information - - {/* Title */} - - - Title * - - - - {title.length} / {ARTICLE_TITLE_MAX_LENGTH} - - - - {/* Author Name */} - - - Author Name * - - - - - {/* Language Dropdown */} - - - Language * - - setLanguageModalVisible(true)}> - - {language ? getLanguageLabel(language) : 'Select target language'} - - - - - - {/* Description */} - - - Description * - - - - {description.length} / {ARTICLE_DESCRIPTION_MAX_LENGTH} - - - - - {/* Tags Section */} - - - Tags - - - Select up to 5 tags to help people discover your article - - - {selectedGenres.length > 0 && ( - - {selectedGenres.map((genre, index) => ( - - #{genre.name} - handleGenrePress(genre)}> - - - - ))} - - )} - - - {categories.map((genre: Category, index: number) => ( - handleGenrePress(genre)}> - - #{genre.name} - - - ))} - - - - {/* Submit Button */} - - Continue to Editor - - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#f8f9fb', - }, - form: { - marginTop: hp(2), - paddingHorizontal: 16, - paddingBottom: hp(4), - }, - headerSection: { - marginBottom: 24, - paddingVertical: 16, - }, - section: { - backgroundColor: '#fff', - borderRadius: 16, - padding: 16, - marginBottom: 16, - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.05, - shadowRadius: 8, - elevation: 2, - }, - translationNotice: { - backgroundColor: 'rgba(59, 130, 246, 0.08)', - borderColor: 'rgba(59, 130, 246, 0.25)', - borderRadius: 12, - borderWidth: 1, - flexDirection: 'row', - gap: 10, - marginBottom: 16, - padding: 14, - }, - translationNoticeTextWrapper: { - flex: 1, - }, - translationNoticeTitle: { - color: '#1a1a1a', - fontSize: 15, - fontWeight: '700', - marginBottom: 2, - }, - translationNoticeText: { - color: '#4b5563', - fontSize: 13, - lineHeight: 18, - }, - sectionTitle: { - fontSize: 20, - fontWeight: '700', - color: '#1a1a1a', - marginBottom: 4, - }, - sectionSubtitle: { - fontSize: 14, - color: '#666', - lineHeight: 20, - }, - input: { - marginBottom: 16, - }, - inputLabel: { - fontSize: 15, - fontWeight: '600', - color: '#222', - marginBottom: 8, - flexDirection: 'row', - alignItems: 'center', - }, - inputControl: { - height: 50, - backgroundColor: '#fff', - paddingHorizontal: 16, - borderRadius: 12, - fontSize: 15, - fontWeight: '500', - color: '#222', - borderWidth: 1, - borderColor: '#E5E7EB', - }, - aboutInput: { - height: 120, - backgroundColor: '#fff', - paddingHorizontal: 16, - paddingVertical: 12, - borderRadius: 12, - fontSize: 15, - fontWeight: '500', - color: '#222', - borderWidth: 1, - borderColor: '#E5E7EB', - }, - charCounter: { - marginTop: 6, - alignSelf: 'flex-end', - color: '#6b7280', - fontSize: 13, - fontWeight: '500', - }, - charCounterWarning: { - color: '#EA580C', - fontWeight: '700', - }, - imageContainer: { - width: '100%', - height: 200, - borderRadius: 12, - overflow: 'hidden', - position: 'relative', - marginTop: 8, - }, - image: { - width: '100%', - height: '100%', - resizeMode: 'cover', - }, - imageOverlay: { - backgroundColor: 'rgba(0,0,0,0.5)', - position: 'absolute', - width: '100%', - bottom: 0, - paddingVertical: 12, - flexDirection: 'row', - justifyContent: 'space-around', - }, - changeButton: { - backgroundColor: 'white', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 8, - }, - changeButtonText: { - color: '#222', - fontWeight: '600', - fontSize: 14, - }, - deleteButton: { - backgroundColor: '#EF4444', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 8, - }, - deleteButtonText: { - color: 'white', - fontWeight: '600', - fontSize: 14, - }, - uploadContainer: { - backgroundColor: 'rgba(59, 130, 246, 0.05)', - height: 160, - width: '100%', - borderRadius: 12, - borderWidth: 2, - borderStyle: 'dashed', - borderColor: PRIMARY_COLOR, - justifyContent: 'center', - alignItems: 'center', - padding: 20, - marginTop: 8, - }, - uploadText: { - color: '#222', - fontWeight: '600', - fontSize: 16, - marginTop: 8, - }, - uploadHint: { - color: '#6b7280', - fontSize: 13, - marginTop: 4, - }, - languageSelector: { - height: 50, - backgroundColor: '#fff', - paddingHorizontal: 16, - borderRadius: 12, - borderWidth: 1, - borderColor: '#E5E7EB', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - languageSelectorText: { - fontSize: 15, - fontWeight: '500', - color: '#222', - }, - submitButton: { - backgroundColor: PRIMARY_COLOR, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - padding: 16, - borderRadius: 12, - marginTop: 8, - marginBottom: hp(4), - shadowColor: PRIMARY_COLOR, - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 4, - }, - submitButtonText: { - color: '#fff', - fontSize: 16, - fontWeight: '700', - }, - genreContainer: { - paddingVertical: 8, - flexDirection: 'row', - }, - genreButton: { - backgroundColor: '#F3F4F6', - paddingHorizontal: 16, - paddingVertical: 10, - marginRight: 8, - borderRadius: 20, - borderWidth: 1, - borderColor: '#E5E7EB', - }, - genreButtonText: { - color: '#374151', - fontSize: 14, - fontWeight: '600', - }, - selectedGenreButton: { - backgroundColor: PRIMARY_COLOR, - borderColor: PRIMARY_COLOR, - }, - selectedGenreButtonText: { - color: '#fff', - }, - selectedGenresWrapper: { - flexDirection: 'row', - flexWrap: 'wrap', - marginBottom: 12, - marginTop: 8, - }, - selectedGenreChip: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(59, 130, 246, 0.1)', - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 16, - marginRight: 8, - marginBottom: 8, - gap: 6, - }, - selectedGenreChipText: { - color: PRIMARY_COLOR, - fontSize: 14, - fontWeight: '600', - }, - selectedGenresContainer: { - flexDirection: 'row', - flexWrap: 'wrap', - padding: 16, - }, - selectedGenreItem: { - backgroundColor: PRIMARY_COLOR, - padding: 8, - margin: 4, - borderRadius: 4, - }, - selectedGenreText: { - color: PRIMARY_COLOR, - marginHorizontal: hp(0.5), - }, - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.5)', - justifyContent: 'flex-end', - }, - modalContent: { - backgroundColor: '#fff', - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - paddingTop: 8, - maxHeight: '70%', - }, - modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - }, - modalTitle: { - fontSize: 20, - fontWeight: '700', - color: '#1a1a1a', - }, - languageItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: '#F3F4F6', - }, - selectedLanguageItem: { - backgroundColor: 'rgba(59, 130, 246, 0.05)', - }, - languageItemText: { - fontSize: 16, - color: '#374151', - fontWeight: '500', - }, - selectedLanguageItemText: { - color: PRIMARY_COLOR, - fontWeight: '700', - }, -}); - -export default ArticleDescriptionScreen; +import React, {useEffect, useState} from 'react'; +import { + View, + Text, + StyleSheet, + TextInput, + TouchableOpacity, + ScrollView, + Image, + Alert, + Modal, + FlatList, +} from 'react-native'; +import {useSelector} from 'react-redux'; +import {ArticleDescriptionProp, Category} from '../../type'; +import Ionicon from '@expo/vector-icons/Ionicons'; +import {PRIMARY_COLOR} from '../../helper/Theme'; +import { + ImageLibraryOptions, + ImagePickerResponse, + launchImageLibrary, +} from 'react-native-image-picker'; +import ImageResizer from '@bam.tech/react-native-image-resizer'; +import {hp} from '../../helper/Metric'; +import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { ttsLanguageList } from '@/src/helper/Utils'; import { rf } from '../../helper/Metric'; + + +const ARTICLE_TITLE_MAX_LENGTH = 150; +const ARTICLE_DESCRIPTION_MAX_LENGTH = 500; +const COUNTER_WARNING_THRESHOLD = 0.9; + +const ArticleDescriptionScreen = ({ + navigation, + route, +}: ArticleDescriptionProp) => { + const {article, htmlContent, translationSource} = route.params; + const isTranslation = Boolean(translationSource); + const [title, setTitle] = useState(''); + const [authorName, setAuthorName] = useState(''); + const [description, setDescription] = useState(''); + const [selectedGenres, setSelectedGenres] = useState([]); + const [language, setLanguage] = useState('en-IN'); + const [languageModalVisible, setLanguageModalVisible] = useState(false); + const {categories} = useSelector((state: any) => state.data); + const [imageUtils, setImageUtils] = useState(''); + + /** Set Initial Value */ + useEffect(() => { + if (article) { + setTitle(article.title); + setAuthorName(article.authorName); + setDescription(article.description); + setSelectedGenres(article.tags); + if (isTranslation) { + setLanguage(''); + } + setImageUtils( + article.imageUtils && article.imageUtils.length > 0 + ? article.imageUtils[0] + : '', + ); + } + }, [article, isTranslation]); + const handleGenrePress = (genre: Category) => { + if (isSelected(genre)) { + setSelectedGenres(selectedGenres.filter(item => item.id !== genre.id)); + } else if (selectedGenres.length < 5) { + // Check if the length of selected genres is less than 5 + setSelectedGenres([...selectedGenres, genre]); // Add the new genre to the selected genres array + } + }; + + const isSelected = (genre: Category) => selectedGenres.includes(genre); + + const handleCreatePost = () => { + if (title === '') { + Alert.alert('Title section is required'); + return; + } else if (authorName === '') { + Alert.alert('Author name is required'); + return; + } else if (description === '') { + Alert.alert('Please give proper description'); + return; + } else if (selectedGenres.length === 0) { + Alert.alert('Please select at least one suitable tags for your article.'); + return; + } else if (isTranslation && !language) { + Alert.alert('Please select a target language for the translation.'); + return; + } else if ( + isTranslation && + language === translationSource?.sourceLanguage + ) { + Alert.alert('Please choose a language different from the source article.'); + return; + } + + // Later purpose + else if (imageUtils.length === 0) { + Alert.alert('Please upload one image for your article.'); + return; + } + + navigation.navigate('EditorScreen', { + title: title, + authorName: authorName, + description: description, + selectedGenres: selectedGenres, + imageUtils: imageUtils, + htmlContent: htmlContent, + language: language, + requestId: undefined, + pb_record_id: undefined, + articleData: isTranslation ? undefined : article, + translationSource, + }); + }; + + const selectImage = () => { + const options: ImageLibraryOptions = { + mediaType: 'photo', + includeBase64: true, + }; + + launchImageLibrary(options, (response: ImagePickerResponse) => { + if (response.didCancel) { + //console.log('User cancelled image picker'); + } else if (response.errorMessage) { + console.log('ImagePicker Error: ', response.errorMessage); + } else if (response.assets) { + const {uri, fileSize} = response.assets[0]; + + // Check file size (1 MB limit) + if (fileSize && fileSize > 1024 * 1024) { + Alert.alert('Error', 'File size exceeds 1 MB.'); + return; + } + + // Check dimensions + if (uri) { + ImageResizer.createResizedImage(uri, 1000, 1000, 'JPEG', 100) + .then(resizedImageUri => { + // If the image is resized successfully, upload it + }) + .catch(err => { + console.log(err); + Alert.alert('Error', 'Could not resize the image.'); + }); + } + + setImageUtils(uri ? uri : ''); + } + }); + }; + + const availableLanguages = isTranslation + ? ttsLanguageList.filter( + lang => lang.code !== translationSource?.sourceLanguage, + ) + : ttsLanguageList; + + const isNearLimit = (value: string, limit: number) => + value.length >= limit * COUNTER_WARNING_THRESHOLD; + + const handleTitleChange = (text: string) => { + setTitle(text.slice(0, ARTICLE_TITLE_MAX_LENGTH)); + }; + + const handleDescriptionChange = (text: string) => { + setDescription(text.slice(0, ARTICLE_DESCRIPTION_MAX_LENGTH)); + }; + + const getLanguageLabel = (value: string) => { + return ttsLanguageList.find(lang => lang.code === value)?.name || 'Select language'; + }; + + const LanguageSelector = () => { + return ( + setLanguageModalVisible(false)}> + setLanguageModalVisible(false)}> + + + Select Language + setLanguageModalVisible(false)}> + + + + item.code} + renderItem={({item}) => ( + { + setLanguage(item.code); + setLanguageModalVisible(false); + }}> + + {item.name} + + {language === item.code && ( + + )} + + )} + /> + + + + ); + }; + + return ( + + + + + + {/* Header Section */} + + Article Details + + {isTranslation + ? `Create a translated version of "${translationSource?.sourceTitle}"` + : 'Fill in the information below to create your article'} + + + + {isTranslation && ( + + + + Translation article + + Source language: {getLanguageLabel(translationSource?.sourceLanguage ?? '')} + + + + )} + + {/* Image Upload Section */} + + + Cover Image + + {imageUtils ? ( + + + + + + Change + + setImageUtils('')}> + + Delete + + + + ) : ( + + + Upload Cover Image + Maximum file size: 1MB • JPG, PNG + + )} + + + {/* Basic Information Section */} + + Basic Information + + {/* Title */} + + + Title * + + + + {title.length} / {ARTICLE_TITLE_MAX_LENGTH} + + + + {/* Author Name */} + + + Author Name * + + + + + {/* Language Dropdown */} + + + Language * + + setLanguageModalVisible(true)}> + + {language ? getLanguageLabel(language) : 'Select target language'} + + + + + + {/* Description */} + + + Description * + + + + {description.length} / {ARTICLE_DESCRIPTION_MAX_LENGTH} + + + + + {/* Tags Section */} + + + Tags + + + Select up to 5 tags to help people discover your article + + + {selectedGenres.length > 0 && ( + + {selectedGenres.map((genre, index) => ( + + #{genre.name} + handleGenrePress(genre)}> + + + + ))} + + )} + + + {categories.map((genre: Category, index: number) => ( + handleGenrePress(genre)}> + + #{genre.name} + + + ))} + + + + {/* Submit Button */} + + Continue to Editor + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f8f9fb', + }, + form: { + marginTop: hp(2), + paddingHorizontal: 16, + paddingBottom: hp(4), + }, + headerSection: { + marginBottom: 24, + paddingVertical: 16, + }, + section: { + backgroundColor: '#fff', + borderRadius: 16, + padding: 16, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.05, + shadowRadius: 8, + elevation: 2, + }, + translationNotice: { + backgroundColor: 'rgba(59, 130, 246, 0.08)', + borderColor: 'rgba(59, 130, 246, 0.25)', + borderRadius: 12, + borderWidth: 1, + flexDirection: 'row', + gap: 10, + marginBottom: 16, + padding: 14, + }, + translationNoticeTextWrapper: { + flex: 1, + }, + translationNoticeTitle: { + color: '#1a1a1a', + fontSize: rf(15), + fontWeight: '700', + marginBottom: 2, + }, + translationNoticeText: { + color: '#4b5563', + fontSize: rf(13), + lineHeight: 18, + }, + sectionTitle: { + fontSize: rf(20), + fontWeight: '700', + color: '#1a1a1a', + marginBottom: 4, + }, + sectionSubtitle: { + fontSize: rf(14), + color: '#666', + lineHeight: 20, + }, + input: { + marginBottom: 16, + }, + inputLabel: { + fontSize: rf(15), + fontWeight: '600', + color: '#222', + marginBottom: 8, + flexDirection: 'row', + alignItems: 'center', + }, + inputControl: { + height: 50, + backgroundColor: '#fff', + paddingHorizontal: 16, + borderRadius: 12, + fontSize: rf(15), + fontWeight: '500', + color: '#222', + borderWidth: 1, + borderColor: '#E5E7EB', + }, + aboutInput: { + height: 120, + backgroundColor: '#fff', + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 12, + fontSize: rf(15), + fontWeight: '500', + color: '#222', + borderWidth: 1, + borderColor: '#E5E7EB', + }, + charCounter: { + marginTop: 6, + alignSelf: 'flex-end', + color: '#6b7280', + fontSize: rf(13), + fontWeight: '500', + }, + charCounterWarning: { + color: '#EA580C', + fontWeight: '700', + }, + imageContainer: { + width: '100%', + height: 200, + borderRadius: 12, + overflow: 'hidden', + position: 'relative', + marginTop: 8, + }, + image: { + width: '100%', + height: '100%', + resizeMode: 'cover', + }, + imageOverlay: { + backgroundColor: 'rgba(0,0,0,0.5)', + position: 'absolute', + width: '100%', + bottom: 0, + paddingVertical: 12, + flexDirection: 'row', + justifyContent: 'space-around', + }, + changeButton: { + backgroundColor: 'white', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + }, + changeButtonText: { + color: '#222', + fontWeight: '600', + fontSize: rf(14), + }, + deleteButton: { + backgroundColor: '#EF4444', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + }, + deleteButtonText: { + color: 'white', + fontWeight: '600', + fontSize: rf(14), + }, + uploadContainer: { + backgroundColor: 'rgba(59, 130, 246, 0.05)', + height: 160, + width: '100%', + borderRadius: 12, + borderWidth: 2, + borderStyle: 'dashed', + borderColor: PRIMARY_COLOR, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + marginTop: 8, + }, + uploadText: { + color: '#222', + fontWeight: '600', + fontSize: rf(16), + marginTop: 8, + }, + uploadHint: { + color: '#6b7280', + fontSize: rf(13), + marginTop: 4, + }, + languageSelector: { + height: 50, + backgroundColor: '#fff', + paddingHorizontal: 16, + borderRadius: 12, + borderWidth: 1, + borderColor: '#E5E7EB', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + languageSelectorText: { + fontSize: rf(15), + fontWeight: '500', + color: '#222', + }, + submitButton: { + backgroundColor: PRIMARY_COLOR, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + padding: 16, + borderRadius: 12, + marginTop: 8, + marginBottom: hp(4), + shadowColor: PRIMARY_COLOR, + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 4, + }, + submitButtonText: { + color: '#fff', + fontSize: rf(16), + fontWeight: '700', + }, + genreContainer: { + paddingVertical: 8, + flexDirection: 'row', + }, + genreButton: { + backgroundColor: '#F3F4F6', + paddingHorizontal: 16, + paddingVertical: 10, + marginRight: 8, + borderRadius: 20, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + genreButtonText: { + color: '#374151', + fontSize: rf(14), + fontWeight: '600', + }, + selectedGenreButton: { + backgroundColor: PRIMARY_COLOR, + borderColor: PRIMARY_COLOR, + }, + selectedGenreButtonText: { + color: '#fff', + }, + selectedGenresWrapper: { + flexDirection: 'row', + flexWrap: 'wrap', + marginBottom: 12, + marginTop: 8, + }, + selectedGenreChip: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(59, 130, 246, 0.1)', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + marginRight: 8, + marginBottom: 8, + gap: 6, + }, + selectedGenreChipText: { + color: PRIMARY_COLOR, + fontSize: rf(14), + fontWeight: '600', + }, + selectedGenresContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + padding: 16, + }, + selectedGenreItem: { + backgroundColor: PRIMARY_COLOR, + padding: 8, + margin: 4, + borderRadius: 4, + }, + selectedGenreText: { + color: PRIMARY_COLOR, + marginHorizontal: hp(0.5), + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end', + }, + modalContent: { + backgroundColor: '#fff', + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingTop: 8, + maxHeight: '70%', + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + }, + modalTitle: { + fontSize: rf(20), + fontWeight: '700', + color: '#1a1a1a', + }, + languageItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: '#F3F4F6', + }, + selectedLanguageItem: { + backgroundColor: 'rgba(59, 130, 246, 0.05)', + }, + languageItemText: { + fontSize: rf(16), + color: '#374151', + fontWeight: '500', + }, + selectedLanguageItemText: { + color: PRIMARY_COLOR, + fontWeight: '700', + }, +}); + +export default ArticleDescriptionScreen; diff --git a/frontend/src/screens/article/ArticleScreen.tsx b/frontend/src/screens/article/ArticleScreen.tsx index a4e0fa13..afbc8fca 100644 --- a/frontend/src/screens/article/ArticleScreen.tsx +++ b/frontend/src/screens/article/ArticleScreen.tsx @@ -1,1469 +1,1470 @@ -import { - Image, - Platform, - StyleSheet, - Text, - TouchableOpacity, - View, - ScrollView, - Alert, - Dimensions, - Share, - useColorScheme, -} from 'react-native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import FontAwesome from '@expo/vector-icons/FontAwesome'; -import {PRIMARY_COLOR} from '../../helper/Theme'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import {ArticleData, ArticleScreenProp} from '../../type'; -import {useDispatch, useSelector} from 'react-redux'; -import {hp} from '../../helper/Metric'; -import {GET_IMAGE, GET_STORAGE_DATA} from '../../helper/APIUtils'; -import Loader from '../../components/Loader'; -import Snackbar from 'react-native-snackbar'; -import ResearchSummaryCard from '../../components/ResearchSummaryCard'; -import StructuredPodcastCard from '../../components/StructuredPodcastCard'; -import { generateArticleSummary, ArticleSummary } from '../../services/SummaryService'; - -import { - formatCount, - handleExternalClick, - retrieveItem, - StatusEnum, - storeItem, -} from '../../helper/Utils'; -//import CommentScreen from '../CommentScreen'; -import Tts from 'react-native-tts'; -import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; - -import {setUserHandle} from '../../store/UserSlice'; -import {FontAwesome5} from '@expo/vector-icons'; -import AutoHeightWebView from '@brown-bear/react-native-autoheight-webview'; -import LottieView from 'lottie-react-native'; - -import {useGetArticleDetails} from '@/src/hooks/useGetArticleDetail'; -import {useGetArticleContent} from '@/src/hooks/useGetArticleContent'; -import {useGetProfile} from '@/src/hooks/useGetProfile'; -import {useLikeArticle} from '@/src/hooks/useLikeArticle'; -import {useUpdateFollowStatusByArticle} from '@/src/hooks/useUpdateFollowStatus'; -import {useUpdateReadEvent} from '@/src/hooks/useUpdateReadEvent'; -import {useUpdateViewCount} from '@/src/hooks/useUpdateViewCount'; -import {useSaveArticle} from '@/src/hooks/useSaveArticle'; -import {useSocket} from '../../contexts/SocketContext'; -import LoadingSpinner from '../../components/LoadingSpinner'; - -const CHUNK_SIZE = 120; - -const ArticleScreen = ({navigation, route}: ArticleScreenProp) => { - const {articleId, authorId, recordId} = route.params; - const {user_id, isGuest} = useSelector((state: any) => state.user); - const isDarkMode = useColorScheme() === 'dark'; - const [readEventSave, setReadEventSave] = useState(false); - const [fontScale, setFontScale] = useState(1); - const [isPlaying, setIsPlaying] = useState(false); - const [isPaused, setIsPaused] = useState(false); - const [speechRate, setSpeechRate] = useState(0.5); - const [playerVisible, setPlayerVisible] = useState(false); - const [summary, setSummary] = useState(null); - const [summaryLoading, setSummaryLoading] = useState(false); - const chunkIndexRef = useRef(0); - const wordsRef = useRef([]); - - const {mutate: followMutation, isPending: followMutationPending} = - useUpdateFollowStatusByArticle(); - - const {mutate: updateReadEvent} = useUpdateReadEvent(articleId); - - const {mutate: updateViewCount} = useUpdateViewCount(articleId ?? 0); - - const socket = useSocket(); - const dispatch = useDispatch(); - - const {data: user} = useGetProfile(); - const { - data: article, - isLoading: articleLoading, - refetch, - } = useGetArticleDetails(articleId); - - const resolvedRecordId = article?.pb_recordId || recordId; - const {data: articleContent} = useGetArticleContent(resolvedRecordId); - - const {mutate: likeMutation, isPending: likeMutationPending} = useLikeArticle( - Number(articleId), - ); - - const {mutate: saveMutation, isPending: saveMutationPending} = useSaveArticle( - Number(articleId), - ); - - const FONT_SCALE_KEY = 'article_font_scale'; - const FONT_SCALE_MIN = 0.8; - const FONT_SCALE_MAX = 1.6; - const FONT_SCALE_STEP = 0.1; - const BASE_FONT_SIZE = 16; - - const likedUsers = article?.likedUsers ?? []; - const totalLikes = likedUsers.length; - - const clampFontScale = (value: number) => - Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, value)); - - const persistFontScale = async (value: number) => { - try { - await storeItem(FONT_SCALE_KEY, value.toFixed(2)); - } catch (error) { - console.error('Failed to persist font scale:', error); - } - }; - - const debounce = (func: (...args: any[]) => void, delay: number) => { - let timeout: ReturnType; - return (...args: any[]) => { - clearTimeout(timeout); - timeout = setTimeout(() => func(...args), delay); - }; - }; - - const debouncedPersistFontScale = useCallback( - debounce(persistFontScale, 300), - [], - ); - - const handleDecreaseFont = () => { - const nextValue = clampFontScale(fontScale - FONT_SCALE_STEP); - setFontScale(nextValue); - debouncedPersistFontScale(nextValue); - }; - - const handleIncreaseFont = () => { - const nextValue = clampFontScale(fontScale + FONT_SCALE_STEP); - setFontScale(nextValue); - debouncedPersistFontScale(nextValue); - }; - - useEffect(() => { - if (!isGuest) { - updateViewCount(articleId, { - onError: error => { - console.log('Update View Count Error', error); - }, - }); - } - return () => { - setIsPlaying(false); - setIsPaused(false); - setPlayerVisible(false); - Tts.stop(); - Tts.removeAllListeners('tts-finish'); - Tts.removeAllListeners('tts-error'); - }; - }, [articleId, isGuest, updateViewCount]); - - useEffect(() => { - refetch(); - }, [articleId, refetch]); - - const noDataHtml = '

No Data found

'; - - useEffect(() => { - if (user) { - dispatch(setUserHandle(user.user_handle)); - } - }, [dispatch, user]); - - useEffect(() => { - let isMounted = true; - - const loadFontScale = async () => { - try { - const storedValue = await retrieveItem(FONT_SCALE_KEY); - if (!isMounted || !storedValue) return; - - const parsed = Number(storedValue); - if (!Number.isNaN(parsed)) { - setFontScale(clampFontScale(parsed)); - } - } catch (error) { - console.error('Failed to load font scale:', error); - } - }; - - loadFontScale(); - - return () => { - isMounted = false; - }; - }, []); - - // Generate AI summary using Gemini - useEffect(() => { - if (!article?.content && !article?.body) { - setSummary(null); - return; - } - - const rawText = article?.content || article?.body || ''; - - // Only call API if there's enough text - if (!rawText || rawText.length < 100) { - setSummary(null); - return; - } - - // Reset state then call API - setSummary(null); - setSummaryLoading(true); - - generateArticleSummary(rawText) - .then(result => setSummary(result)) - .catch(() => setSummary(null)) - .finally(() => setSummaryLoading(false)); - - }, [article?.content, article?.body]); - - // --- Settings --- - const handleLike = () => { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Sign In Required', - description: 'Please sign in or sign up to like this article.', - iconName: 'heart', - }); - return; - } - if (article) { - likeMutation(undefined, { - onSuccess: (data: {article: ArticleData; likeStatus: boolean}) => { - if (data?.likeStatus && socket) { - socket.emit('notification', { - type: 'likePost', - userId: data?.article?.authorId, - articleId: data?.article?._id, - podcastId: null, - articleRecordId: data?.article?.pb_recordId, - title: user - ? `${user?.user_handle} liked your post` - : 'Someone liked your post', - message: data?.article?.title, - }); - } - refetch(); - }, - onError: (err: any) => { - console.log('error', err); - Snackbar.show({ - text: 'Something went wrong, try again!', - duration: Snackbar.LENGTH_LONG, - }); - }, - }); - } else { - Alert.alert('Article not found'); - } - }; - - const handleFollow = () => { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Sign In Required', - description: 'Please sign in or sign up to follow this author.', - iconName: 'user-plus', - }); - return; - } - // updateFollowMutation.mutate(); - - followMutation(articleId.toString(), { - onSuccess: data => { - //console.log('follow success'); - if (data && socket) { - socket.emit('notification', { - type: 'userFollow', - userId: authorId, - message: { - title: `${user?.user_handle} has followed you`, - body: '', - }, - }); - } - refetch(); - // refetchProfile(); - }, - - onError: err => { - console.log('Update Follow mutation error', err); - Snackbar.show({ - text: 'Something went wrong, Try again!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }); - }; - - const handleSave = () => { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Sign In Required', - description: 'Please sign in or sign up to save this article.', - iconName: 'bookmark', - }); - return; - } - if (article) { - saveMutation(undefined, { - onSuccess: () => { - refetch(); - Snackbar.show({ - text: article.savedUsers?.includes(user_id) - ? 'Article removed from saved' - : 'Article saved successfully!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - onError: (err: any) => { - console.log('error', err); - Snackbar.show({ - text: 'Something went wrong, try again!', - duration: Snackbar.LENGTH_LONG, - }); - }, - }); - } else { - Alert.alert('Article not found'); - } - }; - - const handleTranslateArticle = () => { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Sign In Required', - description: 'Please sign in or sign up to translate this article.', - iconName: 'translate', - }); - return; - } - if (!article) { - Alert.alert('Article not found'); - return; - } - - if (!articleContent) { - Snackbar.show({ - text: 'Article content is still loading. Please try again.', - duration: Snackbar.LENGTH_SHORT, - }); - return; - } - - navigation.navigate('ArticleDescriptionScreen', { - article, - htmlContent: articleContent, - translationSource: { - sourceArticleId: article._id, - sourceArticleRecordId: article.pb_recordId || recordId || '', - sourceLanguage: article.language || 'en-IN', - sourceTitle: article.title, - }, - }); - }; - - const handleImproveArticle = () => { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Sign In Required', - description: 'Please sign in or sign up to improve this article.', - iconName: 'auto-fix', - }); - return; - } - if (!article) { - Alert.alert('Article not found'); - return; - } - if (!articleContent) { - Snackbar.show({ - text: 'Article content is still loading. Please try again.', - duration: Snackbar.LENGTH_SHORT, - }); - return; - } - navigation.navigate('EditorScreen', { - title: article.title, - description: article.description, - selectedGenres: article.tags, - imageUtils: article.imageUtils[0], - articleData: article, - requestId: undefined, - pb_record_id: article.pb_recordId, - authorName: user?.user_handle ?? '', - htmlContent: articleContent, - language: article.language, - }); - }; - - async function convertHtmlToPlainText(html: string) { - let modifiedHtml = html.replace(/ style="[^"]*"/g, ''); - - modifiedHtml = modifiedHtml.replace(/]*>[\s\S]*?<\/style>/g, ''); - - modifiedHtml = modifiedHtml.replace(/ /g, ' '); - - let plainText = modifiedHtml.replace(/<[^>]*>/g, ''); - - return plainText; - } - - const ensureLanguageInstalled = async (lang: string) => { - Snackbar.show({ - text: `Checking if language ${lang} is installed.`, - duration: Snackbar.LENGTH_SHORT, - }); - const voices = await Tts.voices(); - - const voice = voices.find(v => v.language === lang && !v.notInstalled); - - if (voice) { - await Tts.setDefaultVoice(voice.id); - return true; - } - - if (Platform.OS === 'android') { - Tts.requestInstallData(); - } - - return false; - }; - - const speakNextChunk = () => { - const words = wordsRef.current; - const chunkIndex = chunkIndexRef.current; - if (chunkIndex >= words.length) { - // All chunks spoken — hide the floating player and reset state - setIsPlaying(false); - setIsPaused(false); - setPlayerVisible(false); - Tts.removeAllListeners('tts-finish'); - Tts.removeAllListeners('tts-error'); - return; - } - const chunk = words.slice(chunkIndex, chunkIndex + CHUNK_SIZE).join(' '); - chunkIndexRef.current = chunkIndex + CHUNK_SIZE; - Tts.speak(chunk); - }; - - const speakSection = async (_language = 'en-IN', content: string) => { - try { - await Tts.stop(); - Tts.removeAllListeners('tts-finish'); - Tts.removeAllListeners('tts-error'); - - const ready = await ensureLanguageInstalled(_language); - if (!ready) return; - - await Tts.setDefaultLanguage(_language); - Tts.setDefaultPitch(1.0); - Tts.setDefaultRate(speechRate); - - const plainText = await convertHtmlToPlainText(content); - if (!plainText) return; - - const words = plainText.trim().split(/\s+/); - wordsRef.current = words; - chunkIndexRef.current = 0; - - Tts.addEventListener('tts-finish', speakNextChunk); - Tts.addEventListener('tts-error', e => { - console.log('TTS Error:', e); - setIsPlaying(false); - setIsPaused(false); - setPlayerVisible(false); - }); - - setIsPlaying(true); - setIsPaused(false); - setPlayerVisible(true); - speakNextChunk(); - } catch (error) { - console.log('TTS Error:', error); - setIsPlaying(false); - setIsPaused(false); - setPlayerVisible(false); - } - }; - - const handleTtsPlay = () => { - if (articleContent) { - const language = article?.language || 'en-IN'; - speakSection(language, articleContent); - } - }; - - const handleTtsPause = async () => { - try { - if (isPaused) { - // Resume - setIsPlaying(true); - setIsPaused(false); - // Step back one chunk to resume from the interrupted chunk - chunkIndexRef.current = Math.max(0, chunkIndexRef.current - CHUNK_SIZE); - - // Re-attach listeners - Tts.addEventListener('tts-finish', speakNextChunk); - Tts.addEventListener('tts-error', e => { - console.log('TTS Error:', e); - setIsPlaying(false); - setIsPaused(false); - setPlayerVisible(false); - }); - - speakNextChunk(); - } else { - // Pause - Tts.removeAllListeners('tts-finish'); - Tts.removeAllListeners('tts-error'); - await Tts.stop(); - setIsPlaying(false); - setIsPaused(true); - } - } catch (e) { - console.log('TTS Pause/Resume Error:', e); - } - }; - - const handleTtsStop = async () => { - try { - await Tts.stop(); - Tts.removeAllListeners('tts-finish'); - Tts.removeAllListeners('tts-error'); - wordsRef.current = []; - chunkIndexRef.current = 0; - setIsPlaying(false); - setIsPaused(false); - setPlayerVisible(false); - } catch (e) { - console.log('TTS Stop Error:', e); - } - }; - - const SPEED_OPTIONS = [0.5, 0.75, 1.0, 1.25, 1.5]; - const SPEED_LABELS: Record = { - 0.5: '0.5x', - 0.75: '0.75x', - 1.0: '1x', - 1.25: '1.25x', - 1.5: '1.5x', - }; - - const handleSpeedChange = () => { - const currentIndex = SPEED_OPTIONS.indexOf(speechRate); - const nextRate = SPEED_OPTIONS[(currentIndex + 1) % SPEED_OPTIONS.length]; - setSpeechRate(nextRate); - Tts.setDefaultRate(nextRate); - // Restart current position with new speed if currently playing - if (isPlaying && !isPaused) { - Tts.removeAllListeners('tts-finish'); - Tts.removeAllListeners('tts-error'); - // Step back one chunk so we replay current chunk at new speed - // Note: Rewind is approximate and might read earlier parts of a smaller previous chunk. - chunkIndexRef.current = Math.max(0, chunkIndexRef.current - CHUNK_SIZE); - Tts.stop().then(() => { - Tts.addEventListener('tts-finish', speakNextChunk); - Tts.addEventListener('tts-error', e => { - console.log('TTS Error:', e); - setIsPlaying(false); - setIsPaused(false); - setPlayerVisible(false); - }); - speakNextChunk(); - }); - } - }; - - if (articleLoading) { - return ; - } - - const articleFontSize = BASE_FONT_SIZE * fontScale; - const articleCustomStyle = ` - body { font-family: 'Times New Roman'; font-size: ${articleFontSize}px; line-height: 1.6; } - p, li { font-size: ${articleFontSize}px; } - img, video, iframe { max-width: 100%; height: auto; } - `; - - const footerColors = { - background: isDarkMode ? '#111827' : '#ffffff', - border: isDarkMode ? '#1f2937' : '#E5E5E5', - pillBackground: isDarkMode ? '#1f2937' : '#F3F4F6', - activePillBackground: isDarkMode ? '#3b82f6' : '#EFF6FF', - text: isDarkMode ? '#d1d5db' : '#4b5563', - activeText: PRIMARY_COLOR, - }; - - return ( - - - {article && article?.imageUtils && article?.imageUtils.length > 0 ? ( - - ) : ( - - )} - {likeMutationPending ? ( - - ) : ( - - user._id === user_id) - ? PRIMARY_COLOR - : 'black' - } - /> - - )} - - { - if (playerVisible) { - handleTtsStop(); - } else { - handleTtsPlay(); - } - }} - style={[ - styles.playButton, - { - backgroundColor: 'white', - }, - ]}> - - - - {isPlaying && ( - - - - )} - - - { - let windowHeight = Dimensions.get('window').height, - height = e.nativeEvent.contentSize.height, - offset = e.nativeEvent.contentOffset.y; - if (windowHeight + offset >= height) { - if ( - article && - !readEventSave && - !isGuest && - article.status === StatusEnum.PUBLISHED - ) { - updateReadEvent(undefined, { - onSuccess: () => { - console.log('Read Event Updated'); - setReadEventSave(true); - Snackbar.show({ - text: 'Your read status updated.', - duration: Snackbar.LENGTH_SHORT, - }); - }, - onError: err => { - console.log('Update Read Status mutation error', err); - Snackbar.show({ - text: 'Failed to update your read status.', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }); - } - } - }} - contentContainerStyle={styles.scrollViewContent}> - - {article && ( - - {article?.viewCount - ? article.viewCount > 1 - : `${article.viewCount} view`} - - )} - {article && article?.tags && ( - - {article.tags.map(tag => tag.name).join(' | ')} - - )} - - {article && ( - <> - {article?.title} - - Text size - - - A- - - - A+ - - - - {totalLikes > 0 && ( - - {likedUsers - .slice(Math.max(0, totalLikes - 3)) - .map((likedUser, index) => { - const profileImage = likedUser.Profile_image; - const uri = profileImage && profileImage.trim() !== '' - ? (profileImage.startsWith('http') - ? profileImage - : `${GET_STORAGE_DATA}/${profileImage}`) - : 'https://images.pexels.com/photos/771742/pexels-photo-771742.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500'; - - return ( - - - - ); - })} - - {totalLikes > 3 && ( - - +{totalLikes - 3} - - )} - - )} - - - - - {/* ── Research Summary Card ── */} - - - {article?.relatedPodcasts && article.relatedPodcasts.length > 0 && ( - - )} - - )} - - - - - {/* Action Bar Row */} - - user._id === user_id) - ? footerColors.activePillBackground - : footerColors.pillBackground, - }, - ]} - onPress={handleLike} - disabled={likeMutationPending}> - {likeMutationPending ? ( - - ) : ( - <> - user._id === user_id) - ? PRIMARY_COLOR - : footerColors.text - } - /> - user._id === user_id) - ? PRIMARY_COLOR - : footerColors.text, - }, - ]}> - {article?.likeCount ? formatCount(article.likeCount) : 0} - - - )} - - - { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Sign In Required', - description: - 'Please sign in or sign up to view and post comments.', - iconName: 'comment', - }); - return; - } - if (article) { - navigation.navigate('CommentScreen', { - articleId: Number(article._id), - mentionedUsers: article.mentionedUsers, - article: article, - }); - } - }}> - - - Comment - - - - { - try { - if (article) { - const result = await Share.share({ - message: `Check out this article: ${article.title}\n\n${article.description}`, - title: article.title, - }); - - if (result.action === Share.sharedAction) { - Snackbar.show({ - text: 'Article shared successfully!', - duration: Snackbar.LENGTH_SHORT, - }); - } - } - } catch (error) { - console.log('Error sharing:', error); - Snackbar.show({ - text: 'Failed to share article', - duration: Snackbar.LENGTH_SHORT, - }); - } - }}> - - - Share - - - - - - - Translate - - - - - - - Improve - - - - - {saveMutationPending ? ( - - ) : ( - <> - - - Save - - - )} - - - - {/*Improvement row */} - - {/* Author Row */} - - - { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Sign In Required', - description: - 'Please sign in or sign up to view user profiles.', - iconName: 'user', - }); - return; - } - navigation.navigate('UserProfileScreen', { - authorId: (article?.authorId as any)?._id || authorId, - author_handle: undefined, - }); - }}> - {(article?.authorId as any)?.Profile_image ? ( - - ) : ( - - )} - - - - {article ? article?.authorName : ''} - - - {(article?.authorId as any)?.followers - ? (article?.authorId as any).followers.length > 1 - ? `${(article?.authorId as any).followers.length} followers` - : `${(article?.authorId as any).followers.length} follower` - : '0 follower'} - - {article && - article.contributors && - article.contributors.length > 0 && ( - { - if (isGuest) { - navigation.navigate('GuestPlaceholderScreen', { - title: 'Sign In Required', - description: - 'Please sign in or sign up to view all contributors.', - iconName: 'users', - }); - return; - } - navigation.navigate('SocialScreen', { - type: 3, - articleId: Number(article?._id), - social_user_id: undefined, - }); - }}> - - See all contributors - - - )} - - - {article && - user_id !== (article.authorId as any)?._id && - (followMutationPending ? ( - - ) : ( - - - {(article.authorId as any)?.followers && - (article.authorId as any).followers.some( - (user: any) => user._id === user_id, - ) - ? 'Following' - : 'Follow'} - - - ))} - - - {/* Floating TTS Media Player */} - {playerVisible && ( - - - {/* Speed button */} - - - {SPEED_LABELS[speechRate]} - - - - {/* Play / Pause button — disabled if neither playing nor paused */} - - - - - {/* Stop button */} - - - - - {/* Status label */} - - {isPaused ? 'Paused' : isPlaying ? 'Playing...' : 'Stopped'} - - - - )} - - ); -}; - -export default ArticleScreen; - -const styles = StyleSheet.create({ - container: { - flex: 1, - position: 'relative', - backgroundColor: '#ffffff', - }, - scrollView: { - flex: 0, - // marginTop: hp(4), - borderBottomEndRadius: hp(2), - backgroundColor: '#ffffff', - position: 'relative', - }, - scrollViewContent: { - marginBottom: 10, - flexGrow: 0, - }, - imageContainer: { - width: '100%', - height: 200, - position: 'relative', - borderBottomLeftRadius: 10, - borderBottomRightRadius: 10, - overflow: 'hidden', - zIndex: 4, - elevation: 4, - }, - image: { - height: 200, - width: '100%', - objectFit: 'cover', - }, - likeButton: { - padding: 10, - position: 'absolute', - bottom: 4, - right: 70, - borderRadius: 50, - }, - - playButton: { - padding: 10, - position: 'absolute', - bottom: 4, - right: 15, - borderRadius: 50, - }, - contentContainer: { - marginTop: 25, - paddingHorizontal: 16, - }, - categoryText: { - fontWeight: '400', - fontSize: 12, - color: '#6C6C6D', - textTransform: 'uppercase', - }, - viewText: { - fontWeight: '500', - fontSize: 14, - color: '#6C6C6D', - }, - titleText: { - fontSize: 25, - fontWeight: 'bold', - marginTop: 5, - }, - fontSizeControls: { - marginTop: 12, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - fontSizeLabel: { - fontSize: 13, - color: '#6C6C6D', - fontWeight: '500', - }, - fontSizeButtons: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - }, - fontSizeButton: { - borderWidth: 1, - borderColor: '#D0D0D0', - borderRadius: 14, - paddingHorizontal: 10, - paddingVertical: 4, - backgroundColor: '#FFFFFF', - }, - fontSizeButtonText: { - fontSize: 14, - color: '#333333', - fontWeight: '600', - }, - avatarsContainer: { - position: 'relative', - flex: 1, - height: 70, - marginTop: 10, - }, - - profileImage: { - height: 70, - width: 70, - borderRadius: 100, - objectFit: 'cover', - resizeMode: 'contain', - }, - avatar: { - height: 70, - width: 70, - borderRadius: 100, - position: 'absolute', - borderWidth: 1, - borderColor: 'white', - backgroundColor: '#D9D9D9', - }, - avatarTripleOverlap: { - left: 45, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: PRIMARY_COLOR, - }, - moreText: { - fontSize: hp(4), - fontWeight: '700', - color: 'white', - }, - descriptionContainer: { - flex: 1, - marginTop: 10, - }, - - webView: { - flex: 1, - width: '100%', - margin: 0, - padding: 0, - }, - descriptionText: { - fontWeight: '400', - color: '#6C6C6D', - fontSize: 15, - textAlign: 'justify', - }, - footer: { - position: 'relative', - bottom: 0, - zIndex: 10, - borderTopEndRadius: 20, - borderTopStartRadius: 20, - paddingTop: 14, - paddingBottom: 14, - paddingHorizontal: 16, - }, - actionBarFooter: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingBottom: 12, - borderBottomWidth: 1, - marginBottom: 12, - gap: 8, - }, - actionButtonFooter: { - flex: 1, - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 8, - borderRadius: 12, - gap: 3, - minHeight: 52, - }, - actionTextFooter: { - fontSize: 10, - fontWeight: '600', - }, - authorRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - authorContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - authorImage: { - height: 40, - width: 40, - borderRadius: 40, - }, - authorName: { - fontWeight: '700', - fontSize: 14, - }, - authorFollowers: { - fontWeight: '400', - fontSize: 11, - }, - followButton: { - backgroundColor: PRIMARY_COLOR, - paddingHorizontal: 12, - borderRadius: 18, - paddingVertical: 8, - }, - followButtonText: { - color: 'white', - fontSize: 13, - fontWeight: '600', - }, - - commentsList: { - flex: 1, - marginBottom: 20, - }, - - textInput: { - height: 100, - borderColor: '#ccc', - borderWidth: 1, - borderRadius: 8, - padding: 10, - textAlignVertical: 'top', - backgroundColor: '#fff', - marginTop: 10, - }, - submitButton: { - backgroundColor: PRIMARY_COLOR, - padding: 15, - marginTop: 20, - borderRadius: 8, - alignItems: 'center', - }, - - botContainer: { - position: 'absolute', - bottom: 60, - right: 20, - zIndex: 100, - }, - ttsPlayerContainer: { - position: 'absolute', - bottom: 155, - left: 12, - right: 12, - zIndex: 200, - elevation: 12, - }, - ttsPlayerInner: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#0f172a', - borderRadius: 50, - paddingVertical: 12, - paddingHorizontal: 24, - gap: 20, - shadowColor: '#000', - shadowOffset: {width: 0, height: 6}, - shadowOpacity: 0.4, - shadowRadius: 10, - }, - ttsControlButton: { - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: '#ffffff15', - alignItems: 'center', - justifyContent: 'center', - }, - ttsSpeedButton: { - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 16, - backgroundColor: PRIMARY_COLOR, - }, - ttsSpeedText: { - color: 'white', - fontSize: 13, - fontWeight: '700', - }, - ttsStatusText: { - flex: 1, - color: '#f8fafc', - fontSize: 13, - fontWeight: '600', - textAlign: 'right', - }, - - submitButtonText: { - fontSize: 18, - color: '#fff', - fontWeight: 'bold', - }, - contributorTextStyle: { - fontWeight: '500', - color: PRIMARY_COLOR, - marginTop: hp(0.5), - fontSize: 14, - }, -}); +import { + Image, + Platform, + StyleSheet, + Text, + TouchableOpacity, + View, + ScrollView, + Alert, + Dimensions, + Share, + useColorScheme, +} from 'react-native'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import FontAwesome from '@expo/vector-icons/FontAwesome'; +import {PRIMARY_COLOR} from '../../helper/Theme'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {ArticleData, ArticleScreenProp} from '../../type'; +import {useDispatch, useSelector} from 'react-redux'; +import {hp} from '../../helper/Metric'; +import {GET_IMAGE, GET_STORAGE_DATA} from '../../helper/APIUtils'; +import Loader from '../../components/Loader'; +import Snackbar from 'react-native-snackbar'; +import ResearchSummaryCard from '../../components/ResearchSummaryCard'; +import StructuredPodcastCard from '../../components/StructuredPodcastCard'; +import { generateArticleSummary, ArticleSummary } from '../../services/SummaryService'; + +import { + formatCount, + handleExternalClick, + retrieveItem, + StatusEnum, + storeItem, +} from '../../helper/Utils'; +//import CommentScreen from '../CommentScreen'; +import Tts from 'react-native-tts'; +import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; + +import {setUserHandle} from '../../store/UserSlice'; +import {FontAwesome5} from '@expo/vector-icons'; +import AutoHeightWebView from '@brown-bear/react-native-autoheight-webview'; +import LottieView from 'lottie-react-native'; + +import {useGetArticleDetails} from '@/src/hooks/useGetArticleDetail'; +import {useGetArticleContent} from '@/src/hooks/useGetArticleContent'; +import {useGetProfile} from '@/src/hooks/useGetProfile'; +import {useLikeArticle} from '@/src/hooks/useLikeArticle'; +import {useUpdateFollowStatusByArticle} from '@/src/hooks/useUpdateFollowStatus'; +import {useUpdateReadEvent} from '@/src/hooks/useUpdateReadEvent'; +import {useUpdateViewCount} from '@/src/hooks/useUpdateViewCount'; +import {useSaveArticle} from '@/src/hooks/useSaveArticle'; +import {useSocket} from '../../contexts/SocketContext'; +import LoadingSpinner from '../../components/LoadingSpinner'; import { rf } from '../../helper/Metric'; + + +const CHUNK_SIZE = 120; + +const ArticleScreen = ({navigation, route}: ArticleScreenProp) => { + const {articleId, authorId, recordId} = route.params; + const {user_id, isGuest} = useSelector((state: any) => state.user); + const isDarkMode = useColorScheme() === 'dark'; + const [readEventSave, setReadEventSave] = useState(false); + const [fontScale, setFontScale] = useState(1); + const [isPlaying, setIsPlaying] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [speechRate, setSpeechRate] = useState(0.5); + const [playerVisible, setPlayerVisible] = useState(false); + const [summary, setSummary] = useState(null); + const [summaryLoading, setSummaryLoading] = useState(false); + const chunkIndexRef = useRef(0); + const wordsRef = useRef([]); + + const {mutate: followMutation, isPending: followMutationPending} = + useUpdateFollowStatusByArticle(); + + const {mutate: updateReadEvent} = useUpdateReadEvent(articleId); + + const {mutate: updateViewCount} = useUpdateViewCount(articleId ?? 0); + + const socket = useSocket(); + const dispatch = useDispatch(); + + const {data: user} = useGetProfile(); + const { + data: article, + isLoading: articleLoading, + refetch, + } = useGetArticleDetails(articleId); + + const resolvedRecordId = article?.pb_recordId || recordId; + const {data: articleContent} = useGetArticleContent(resolvedRecordId); + + const {mutate: likeMutation, isPending: likeMutationPending} = useLikeArticle( + Number(articleId), + ); + + const {mutate: saveMutation, isPending: saveMutationPending} = useSaveArticle( + Number(articleId), + ); + + const FONT_SCALE_KEY = 'article_font_scale'; + const FONT_SCALE_MIN = 0.8; + const FONT_SCALE_MAX = 1.6; + const FONT_SCALE_STEP = 0.1; + const BASE_FONT_SIZE = 16; + + const likedUsers = article?.likedUsers ?? []; + const totalLikes = likedUsers.length; + + const clampFontScale = (value: number) => + Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, value)); + + const persistFontScale = async (value: number) => { + try { + await storeItem(FONT_SCALE_KEY, value.toFixed(2)); + } catch (error) { + console.error('Failed to persist font scale:', error); + } + }; + + const debounce = (func: (...args: any[]) => void, delay: number) => { + let timeout: ReturnType; + return (...args: any[]) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), delay); + }; + }; + + const debouncedPersistFontScale = useCallback( + debounce(persistFontScale, 300), + [], + ); + + const handleDecreaseFont = () => { + const nextValue = clampFontScale(fontScale - FONT_SCALE_STEP); + setFontScale(nextValue); + debouncedPersistFontScale(nextValue); + }; + + const handleIncreaseFont = () => { + const nextValue = clampFontScale(fontScale + FONT_SCALE_STEP); + setFontScale(nextValue); + debouncedPersistFontScale(nextValue); + }; + + useEffect(() => { + if (!isGuest) { + updateViewCount(articleId, { + onError: error => { + console.log('Update View Count Error', error); + }, + }); + } + return () => { + setIsPlaying(false); + setIsPaused(false); + setPlayerVisible(false); + Tts.stop(); + Tts.removeAllListeners('tts-finish'); + Tts.removeAllListeners('tts-error'); + }; + }, [articleId, isGuest, updateViewCount]); + + useEffect(() => { + refetch(); + }, [articleId, refetch]); + + const noDataHtml = '

No Data found

'; + + useEffect(() => { + if (user) { + dispatch(setUserHandle(user.user_handle)); + } + }, [dispatch, user]); + + useEffect(() => { + let isMounted = true; + + const loadFontScale = async () => { + try { + const storedValue = await retrieveItem(FONT_SCALE_KEY); + if (!isMounted || !storedValue) return; + + const parsed = Number(storedValue); + if (!Number.isNaN(parsed)) { + setFontScale(clampFontScale(parsed)); + } + } catch (error) { + console.error('Failed to load font scale:', error); + } + }; + + loadFontScale(); + + return () => { + isMounted = false; + }; + }, []); + + // Generate AI summary using Gemini + useEffect(() => { + if (!article?.content && !article?.body) { + setSummary(null); + return; + } + + const rawText = article?.content || article?.body || ''; + + // Only call API if there's enough text + if (!rawText || rawText.length < 100) { + setSummary(null); + return; + } + + // Reset state then call API + setSummary(null); + setSummaryLoading(true); + + generateArticleSummary(rawText) + .then(result => setSummary(result)) + .catch(() => setSummary(null)) + .finally(() => setSummaryLoading(false)); + + }, [article?.content, article?.body]); + + // --- Settings --- + const handleLike = () => { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: 'Please sign in or sign up to like this article.', + iconName: 'heart', + }); + return; + } + if (article) { + likeMutation(undefined, { + onSuccess: (data: {article: ArticleData; likeStatus: boolean}) => { + if (data?.likeStatus && socket) { + socket.emit('notification', { + type: 'likePost', + userId: data?.article?.authorId, + articleId: data?.article?._id, + podcastId: null, + articleRecordId: data?.article?.pb_recordId, + title: user + ? `${user?.user_handle} liked your post` + : 'Someone liked your post', + message: data?.article?.title, + }); + } + refetch(); + }, + onError: (err: any) => { + console.log('error', err); + Snackbar.show({ + text: 'Something went wrong, try again!', + duration: Snackbar.LENGTH_LONG, + }); + }, + }); + } else { + Alert.alert('Article not found'); + } + }; + + const handleFollow = () => { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: 'Please sign in or sign up to follow this author.', + iconName: 'user-plus', + }); + return; + } + // updateFollowMutation.mutate(); + + followMutation(articleId.toString(), { + onSuccess: data => { + //console.log('follow success'); + if (data && socket) { + socket.emit('notification', { + type: 'userFollow', + userId: authorId, + message: { + title: `${user?.user_handle} has followed you`, + body: '', + }, + }); + } + refetch(); + // refetchProfile(); + }, + + onError: err => { + console.log('Update Follow mutation error', err); + Snackbar.show({ + text: 'Something went wrong, Try again!', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }); + }; + + const handleSave = () => { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: 'Please sign in or sign up to save this article.', + iconName: 'bookmark', + }); + return; + } + if (article) { + saveMutation(undefined, { + onSuccess: () => { + refetch(); + Snackbar.show({ + text: article.savedUsers?.includes(user_id) + ? 'Article removed from saved' + : 'Article saved successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + }, + onError: (err: any) => { + console.log('error', err); + Snackbar.show({ + text: 'Something went wrong, try again!', + duration: Snackbar.LENGTH_LONG, + }); + }, + }); + } else { + Alert.alert('Article not found'); + } + }; + + const handleTranslateArticle = () => { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: 'Please sign in or sign up to translate this article.', + iconName: 'translate', + }); + return; + } + if (!article) { + Alert.alert('Article not found'); + return; + } + + if (!articleContent) { + Snackbar.show({ + text: 'Article content is still loading. Please try again.', + duration: Snackbar.LENGTH_SHORT, + }); + return; + } + + navigation.navigate('ArticleDescriptionScreen', { + article, + htmlContent: articleContent, + translationSource: { + sourceArticleId: article._id, + sourceArticleRecordId: article.pb_recordId || recordId || '', + sourceLanguage: article.language || 'en-IN', + sourceTitle: article.title, + }, + }); + }; + + const handleImproveArticle = () => { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: 'Please sign in or sign up to improve this article.', + iconName: 'auto-fix', + }); + return; + } + if (!article) { + Alert.alert('Article not found'); + return; + } + if (!articleContent) { + Snackbar.show({ + text: 'Article content is still loading. Please try again.', + duration: Snackbar.LENGTH_SHORT, + }); + return; + } + navigation.navigate('EditorScreen', { + title: article.title, + description: article.description, + selectedGenres: article.tags, + imageUtils: article.imageUtils[0], + articleData: article, + requestId: undefined, + pb_record_id: article.pb_recordId, + authorName: user?.user_handle ?? '', + htmlContent: articleContent, + language: article.language, + }); + }; + + async function convertHtmlToPlainText(html: string) { + let modifiedHtml = html.replace(/ style="[^"]*"/g, ''); + + modifiedHtml = modifiedHtml.replace(/]*>[\s\S]*?<\/style>/g, ''); + + modifiedHtml = modifiedHtml.replace(/ /g, ' '); + + let plainText = modifiedHtml.replace(/<[^>]*>/g, ''); + + return plainText; + } + + const ensureLanguageInstalled = async (lang: string) => { + Snackbar.show({ + text: `Checking if language ${lang} is installed.`, + duration: Snackbar.LENGTH_SHORT, + }); + const voices = await Tts.voices(); + + const voice = voices.find(v => v.language === lang && !v.notInstalled); + + if (voice) { + await Tts.setDefaultVoice(voice.id); + return true; + } + + if (Platform.OS === 'android') { + Tts.requestInstallData(); + } + + return false; + }; + + const speakNextChunk = () => { + const words = wordsRef.current; + const chunkIndex = chunkIndexRef.current; + if (chunkIndex >= words.length) { + // All chunks spoken — hide the floating player and reset state + setIsPlaying(false); + setIsPaused(false); + setPlayerVisible(false); + Tts.removeAllListeners('tts-finish'); + Tts.removeAllListeners('tts-error'); + return; + } + const chunk = words.slice(chunkIndex, chunkIndex + CHUNK_SIZE).join(' '); + chunkIndexRef.current = chunkIndex + CHUNK_SIZE; + Tts.speak(chunk); + }; + + const speakSection = async (_language = 'en-IN', content: string) => { + try { + await Tts.stop(); + Tts.removeAllListeners('tts-finish'); + Tts.removeAllListeners('tts-error'); + + const ready = await ensureLanguageInstalled(_language); + if (!ready) return; + + await Tts.setDefaultLanguage(_language); + Tts.setDefaultPitch(1.0); + Tts.setDefaultRate(speechRate); + + const plainText = await convertHtmlToPlainText(content); + if (!plainText) return; + + const words = plainText.trim().split(/\s+/); + wordsRef.current = words; + chunkIndexRef.current = 0; + + Tts.addEventListener('tts-finish', speakNextChunk); + Tts.addEventListener('tts-error', e => { + console.log('TTS Error:', e); + setIsPlaying(false); + setIsPaused(false); + setPlayerVisible(false); + }); + + setIsPlaying(true); + setIsPaused(false); + setPlayerVisible(true); + speakNextChunk(); + } catch (error) { + console.log('TTS Error:', error); + setIsPlaying(false); + setIsPaused(false); + setPlayerVisible(false); + } + }; + + const handleTtsPlay = () => { + if (articleContent) { + const language = article?.language || 'en-IN'; + speakSection(language, articleContent); + } + }; + + const handleTtsPause = async () => { + try { + if (isPaused) { + // Resume + setIsPlaying(true); + setIsPaused(false); + // Step back one chunk to resume from the interrupted chunk + chunkIndexRef.current = Math.max(0, chunkIndexRef.current - CHUNK_SIZE); + + // Re-attach listeners + Tts.addEventListener('tts-finish', speakNextChunk); + Tts.addEventListener('tts-error', e => { + console.log('TTS Error:', e); + setIsPlaying(false); + setIsPaused(false); + setPlayerVisible(false); + }); + + speakNextChunk(); + } else { + // Pause + Tts.removeAllListeners('tts-finish'); + Tts.removeAllListeners('tts-error'); + await Tts.stop(); + setIsPlaying(false); + setIsPaused(true); + } + } catch (e) { + console.log('TTS Pause/Resume Error:', e); + } + }; + + const handleTtsStop = async () => { + try { + await Tts.stop(); + Tts.removeAllListeners('tts-finish'); + Tts.removeAllListeners('tts-error'); + wordsRef.current = []; + chunkIndexRef.current = 0; + setIsPlaying(false); + setIsPaused(false); + setPlayerVisible(false); + } catch (e) { + console.log('TTS Stop Error:', e); + } + }; + + const SPEED_OPTIONS = [0.5, 0.75, 1.0, 1.25, 1.5]; + const SPEED_LABELS: Record = { + 0.5: '0.5x', + 0.75: '0.75x', + 1.0: '1x', + 1.25: '1.25x', + 1.5: '1.5x', + }; + + const handleSpeedChange = () => { + const currentIndex = SPEED_OPTIONS.indexOf(speechRate); + const nextRate = SPEED_OPTIONS[(currentIndex + 1) % SPEED_OPTIONS.length]; + setSpeechRate(nextRate); + Tts.setDefaultRate(nextRate); + // Restart current position with new speed if currently playing + if (isPlaying && !isPaused) { + Tts.removeAllListeners('tts-finish'); + Tts.removeAllListeners('tts-error'); + // Step back one chunk so we replay current chunk at new speed + // Note: Rewind is approximate and might read earlier parts of a smaller previous chunk. + chunkIndexRef.current = Math.max(0, chunkIndexRef.current - CHUNK_SIZE); + Tts.stop().then(() => { + Tts.addEventListener('tts-finish', speakNextChunk); + Tts.addEventListener('tts-error', e => { + console.log('TTS Error:', e); + setIsPlaying(false); + setIsPaused(false); + setPlayerVisible(false); + }); + speakNextChunk(); + }); + } + }; + + if (articleLoading) { + return ; + } + + const articleFontSize = BASE_FONT_SIZE * fontScale; + const articleCustomStyle = ` + body { font-family: 'Times New Roman'; font-size: ${articleFontSize}px; line-height: 1.6; } + p, li { font-size: ${articleFontSize}px; } + img, video, iframe { max-width: 100%; height: auto; } + `; + + const footerColors = { + background: isDarkMode ? '#111827' : '#ffffff', + border: isDarkMode ? '#1f2937' : '#E5E5E5', + pillBackground: isDarkMode ? '#1f2937' : '#F3F4F6', + activePillBackground: isDarkMode ? '#3b82f6' : '#EFF6FF', + text: isDarkMode ? '#d1d5db' : '#4b5563', + activeText: PRIMARY_COLOR, + }; + + return ( + + + {article && article?.imageUtils && article?.imageUtils.length > 0 ? ( + + ) : ( + + )} + {likeMutationPending ? ( + + ) : ( + + user._id === user_id) + ? PRIMARY_COLOR + : 'black' + } + /> + + )} + + { + if (playerVisible) { + handleTtsStop(); + } else { + handleTtsPlay(); + } + }} + style={[ + styles.playButton, + { + backgroundColor: 'white', + }, + ]}> + + + + {isPlaying && ( + + + + )} + + + { + let windowHeight = Dimensions.get('window').height, + height = e.nativeEvent.contentSize.height, + offset = e.nativeEvent.contentOffset.y; + if (windowHeight + offset >= height) { + if ( + article && + !readEventSave && + !isGuest && + article.status === StatusEnum.PUBLISHED + ) { + updateReadEvent(undefined, { + onSuccess: () => { + console.log('Read Event Updated'); + setReadEventSave(true); + Snackbar.show({ + text: 'Your read status updated.', + duration: Snackbar.LENGTH_SHORT, + }); + }, + onError: err => { + console.log('Update Read Status mutation error', err); + Snackbar.show({ + text: 'Failed to update your read status.', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }); + } + } + }} + contentContainerStyle={styles.scrollViewContent}> + + {article && ( + + {article?.viewCount + ? article.viewCount > 1 + : `${article.viewCount} view`} + + )} + {article && article?.tags && ( + + {article.tags.map(tag => tag.name).join(' | ')} + + )} + + {article && ( + <> + {article?.title} + + Text size + + + A- + + + A+ + + + + {totalLikes > 0 && ( + + {likedUsers + .slice(Math.max(0, totalLikes - 3)) + .map((likedUser, index) => { + const profileImage = likedUser.Profile_image; + const uri = profileImage && profileImage.trim() !== '' + ? (profileImage.startsWith('http') + ? profileImage + : `${GET_STORAGE_DATA}/${profileImage}`) + : 'https://images.pexels.com/photos/771742/pexels-photo-771742.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500'; + + return ( + + + + ); + })} + + {totalLikes > 3 && ( + + +{totalLikes - 3} + + )} + + )} + + + + + {/* ── Research Summary Card ── */} + + + {article?.relatedPodcasts && article.relatedPodcasts.length > 0 && ( + + )} + + )} + + + + + {/* Action Bar Row */} + + user._id === user_id) + ? footerColors.activePillBackground + : footerColors.pillBackground, + }, + ]} + onPress={handleLike} + disabled={likeMutationPending}> + {likeMutationPending ? ( + + ) : ( + <> + user._id === user_id) + ? PRIMARY_COLOR + : footerColors.text + } + /> + user._id === user_id) + ? PRIMARY_COLOR + : footerColors.text, + }, + ]}> + {article?.likeCount ? formatCount(article.likeCount) : 0} + + + )} + + + { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: + 'Please sign in or sign up to view and post comments.', + iconName: 'comment', + }); + return; + } + if (article) { + navigation.navigate('CommentScreen', { + articleId: Number(article._id), + mentionedUsers: article.mentionedUsers, + article: article, + }); + } + }}> + + + Comment + + + + { + try { + if (article) { + const result = await Share.share({ + message: `Check out this article: ${article.title}\n\n${article.description}`, + title: article.title, + }); + + if (result.action === Share.sharedAction) { + Snackbar.show({ + text: 'Article shared successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + } + } + } catch (error) { + console.log('Error sharing:', error); + Snackbar.show({ + text: 'Failed to share article', + duration: Snackbar.LENGTH_SHORT, + }); + } + }}> + + + Share + + + + + + + Translate + + + + + + + Improve + + + + + {saveMutationPending ? ( + + ) : ( + <> + + + Save + + + )} + + + + {/*Improvement row */} + + {/* Author Row */} + + + { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: + 'Please sign in or sign up to view user profiles.', + iconName: 'user', + }); + return; + } + navigation.navigate('UserProfileScreen', { + authorId: (article?.authorId as any)?._id || authorId, + author_handle: undefined, + }); + }}> + {(article?.authorId as any)?.Profile_image ? ( + + ) : ( + + )} + + + + {article ? article?.authorName : ''} + + + {(article?.authorId as any)?.followers + ? (article?.authorId as any).followers.length > 1 + ? `${(article?.authorId as any).followers.length} followers` + : `${(article?.authorId as any).followers.length} follower` + : '0 follower'} + + {article && + article.contributors && + article.contributors.length > 0 && ( + { + if (isGuest) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: + 'Please sign in or sign up to view all contributors.', + iconName: 'users', + }); + return; + } + navigation.navigate('SocialScreen', { + type: 3, + articleId: Number(article?._id), + social_user_id: undefined, + }); + }}> + + See all contributors + + + )} + + + {article && + user_id !== (article.authorId as any)?._id && + (followMutationPending ? ( + + ) : ( + + + {(article.authorId as any)?.followers && + (article.authorId as any).followers.some( + (user: any) => user._id === user_id, + ) + ? 'Following' + : 'Follow'} + + + ))} + + + {/* Floating TTS Media Player */} + {playerVisible && ( + + + {/* Speed button */} + + + {SPEED_LABELS[speechRate]} + + + + {/* Play / Pause button — disabled if neither playing nor paused */} + + + + + {/* Stop button */} + + + + + {/* Status label */} + + {isPaused ? 'Paused' : isPlaying ? 'Playing...' : 'Stopped'} + + + + )} + + ); +}; + +export default ArticleScreen; + +const styles = StyleSheet.create({ + container: { + flex: 1, + position: 'relative', + backgroundColor: '#ffffff', + }, + scrollView: { + flex: 0, + // marginTop: hp(4), + borderBottomEndRadius: hp(2), + backgroundColor: '#ffffff', + position: 'relative', + }, + scrollViewContent: { + marginBottom: 10, + flexGrow: 0, + }, + imageContainer: { + width: '100%', + height: 200, + position: 'relative', + borderBottomLeftRadius: 10, + borderBottomRightRadius: 10, + overflow: 'hidden', + zIndex: 4, + elevation: 4, + }, + image: { + height: 200, + width: '100%', + objectFit: 'cover', + }, + likeButton: { + padding: 10, + position: 'absolute', + bottom: 4, + right: 70, + borderRadius: 50, + }, + + playButton: { + padding: 10, + position: 'absolute', + bottom: 4, + right: 15, + borderRadius: 50, + }, + contentContainer: { + marginTop: 25, + paddingHorizontal: 16, + }, + categoryText: { + fontWeight: '400', + fontSize: rf(12), + color: '#6C6C6D', + textTransform: 'uppercase', + }, + viewText: { + fontWeight: '500', + fontSize: rf(14), + color: '#6C6C6D', + }, + titleText: { + fontSize: rf(25), + fontWeight: 'bold', + marginTop: 5, + }, + fontSizeControls: { + marginTop: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + fontSizeLabel: { + fontSize: rf(13), + color: '#6C6C6D', + fontWeight: '500', + }, + fontSizeButtons: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + fontSizeButton: { + borderWidth: 1, + borderColor: '#D0D0D0', + borderRadius: 14, + paddingHorizontal: 10, + paddingVertical: 4, + backgroundColor: '#FFFFFF', + }, + fontSizeButtonText: { + fontSize: rf(14), + color: '#333333', + fontWeight: '600', + }, + avatarsContainer: { + position: 'relative', + flex: 1, + height: 70, + marginTop: 10, + }, + + profileImage: { + height: 70, + width: 70, + borderRadius: 100, + objectFit: 'cover', + resizeMode: 'contain', + }, + avatar: { + height: 70, + width: 70, + borderRadius: 100, + position: 'absolute', + borderWidth: 1, + borderColor: 'white', + backgroundColor: '#D9D9D9', + }, + avatarTripleOverlap: { + left: 45, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: PRIMARY_COLOR, + }, + moreText: { + fontSize: hp(4), + fontWeight: '700', + color: 'white', + }, + descriptionContainer: { + flex: 1, + marginTop: 10, + }, + + webView: { + flex: 1, + width: '100%', + margin: 0, + padding: 0, + }, + descriptionText: { + fontWeight: '400', + color: '#6C6C6D', + fontSize: rf(15), + textAlign: 'justify', + }, + footer: { + position: 'relative', + bottom: 0, + zIndex: 10, + borderTopEndRadius: 20, + borderTopStartRadius: 20, + paddingTop: 14, + paddingBottom: 14, + paddingHorizontal: 16, + }, + actionBarFooter: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingBottom: 12, + borderBottomWidth: 1, + marginBottom: 12, + gap: 8, + }, + actionButtonFooter: { + flex: 1, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 8, + borderRadius: 12, + gap: 3, + minHeight: 52, + }, + actionTextFooter: { + fontSize: rf(10), + fontWeight: '600', + }, + authorRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + authorContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + authorImage: { + height: 40, + width: 40, + borderRadius: 40, + }, + authorName: { + fontWeight: '700', + fontSize: rf(14), + }, + authorFollowers: { + fontWeight: '400', + fontSize: rf(11), + }, + followButton: { + backgroundColor: PRIMARY_COLOR, + paddingHorizontal: 12, + borderRadius: 18, + paddingVertical: 8, + }, + followButtonText: { + color: 'white', + fontSize: rf(13), + fontWeight: '600', + }, + + commentsList: { + flex: 1, + marginBottom: 20, + }, + + textInput: { + height: 100, + borderColor: '#ccc', + borderWidth: 1, + borderRadius: 8, + padding: 10, + textAlignVertical: 'top', + backgroundColor: '#fff', + marginTop: 10, + }, + submitButton: { + backgroundColor: PRIMARY_COLOR, + padding: 15, + marginTop: 20, + borderRadius: 8, + alignItems: 'center', + }, + + botContainer: { + position: 'absolute', + bottom: 60, + right: 20, + zIndex: 100, + }, + ttsPlayerContainer: { + position: 'absolute', + bottom: 155, + left: 12, + right: 12, + zIndex: 200, + elevation: 12, + }, + ttsPlayerInner: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#0f172a', + borderRadius: 50, + paddingVertical: 12, + paddingHorizontal: 24, + gap: 20, + shadowColor: '#000', + shadowOffset: {width: 0, height: 6}, + shadowOpacity: 0.4, + shadowRadius: 10, + }, + ttsControlButton: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: '#ffffff15', + alignItems: 'center', + justifyContent: 'center', + }, + ttsSpeedButton: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 16, + backgroundColor: PRIMARY_COLOR, + }, + ttsSpeedText: { + color: 'white', + fontSize: rf(13), + fontWeight: '700', + }, + ttsStatusText: { + flex: 1, + color: '#f8fafc', + fontSize: rf(13), + fontWeight: '600', + textAlign: 'right', + }, + + submitButtonText: { + fontSize: rf(18), + color: '#fff', + fontWeight: 'bold', + }, + contributorTextStyle: { + fontWeight: '500', + color: PRIMARY_COLOR, + marginTop: hp(0.5), + fontSize: rf(14), + }, +}); diff --git a/frontend/src/screens/article/EditorScreen.tsx b/frontend/src/screens/article/EditorScreen.tsx index 4a574e32..9204886d 100644 --- a/frontend/src/screens/article/EditorScreen.tsx +++ b/frontend/src/screens/article/EditorScreen.tsx @@ -1,451 +1,452 @@ - -import React, {useEffect, useRef, useState} from 'react'; -import { - StyleSheet, - Text, - ScrollView, - Alert, - TouchableOpacity, -} from 'react-native'; -import {actions, RichEditor, RichToolbar} from 'react-native-pell-rich-editor'; -import Feather from '@expo/vector-icons/Feather'; -import Entypo from '@expo/vector-icons/Entypo'; -import IonIcon from '@expo/vector-icons/Ionicons'; -import FontAwesome from '@expo/vector-icons/FontAwesome'; -import Fontisto from '@expo/vector-icons/Fontisto'; -import {PRIMARY_COLOR} from '../../helper/Theme'; -import {EditorScreenProp} from '../../type'; -import {launchImageLibrary} from 'react-native-image-picker'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import {useDispatch} from 'react-redux'; -import {setSuggestion, setSuggestionAccepted} from '../../store/dataSlice'; - -// Feature: -// If you want to discard your post, in that case no post will upload into storage, -const EditorScreen = ({navigation, route}: EditorScreenProp) => { - const insets = useSafeAreaInsets(); - const { - title, - description, - selectedGenres, - authorName, - imageUtils, - articleData, - requestId, - htmlContent, - pb_record_id, - translationSource, - } = route.params; - - const RichText = useRef(null); - const [article, setArticle] = useState(''); - const [localImages, setLocalImages] = useState([]); - const [htmlImages, setHtmlImages] = useState([]); - const [editorReady, setEditorReady] = useState(false); - const dispatch = useDispatch(); - - React.useEffect(() => { - navigation.setOptions({ - headerRight: () => ( - { - console.log('Preview button pressed'); - if (article.length > 20) { - //console.log('Preview Screen'); - dispatch(setSuggestion({suggestion: ''})); - dispatch(setSuggestionAccepted({selection: false})); - navigation.navigate('PreviewScreen', { - article: article, - title: title, - authorName: authorName, - description: description, - image: imageUtils, - selectedGenres: selectedGenres, - localImages: localImages, - htmlImages: htmlImages, - articleData: articleData, - requestId: requestId, - pb_record_id: pb_record_id, - language: route.params.language, - translationSource, - }); - } else { - Alert.alert('Error', 'Please enter at least 20 characters'); - } - }}> - - - ), - }); - }, [ - navigation, - article, - title, - description, - imageUtils, - selectedGenres, - authorName, - localImages, - htmlImages, - articleData, - requestId, - dispatch, - pb_record_id, - translationSource, - ]); - - useEffect(() => { - /* - const loadArticle = async () => { - if (!articleData) { - return; - } - try { - if (articleData.content.endsWith('.html')) { - await getContent(articleData.content); - - setEditorReady(true); - } else { - setArticle(articleData.content); - setEditorReady(true); - } - } catch (err) { - console.error('Failed to load article:', err); - } - }; - */ - - if (htmlContent) { - setArticle(htmlContent); - setEditorReady(true); - } - - // loadArticle(); - }, [htmlContent]); - - React.useEffect(() => { - if (imageUtils) { - setLocalImages(prevImages => [imageUtils, ...prevImages]); // Add imageUtils at the start - } - }, [imageUtils]); - - useEffect(() => { - if (editorReady && article && RichText.current) { - RichText.current?.setContentHTML(article); - //RichText.current?.focusContentEditor(); - } - }, [ editorReady]); - - // this function will be called when the editor has been initialized - function editorInitializedCallback() { - RichText.current?.registerToolbar(function (_items) { - setEditorReady(true); - }); - } - - /* - - const getContent = async content => { - try { - const response = await fetch(`${GET_STORAGE_DATA}/${content}`); - const text = await response.text(); - setArticle(text); - } catch (error) { - // console.error('Error fetching URI:', error); - setArticle(content); - } - }; - */ - - // Callback after height change - function handleHeightChange(_height: number) { - // console.log("editor height change:", height); - } - - async function onPressAddImage() { - const result = await launchImageLibrary({ - mediaType: 'photo', - presentationStyle: 'popover', - quality: 0.7, - includeBase64: true, - }); - if (result.assets && result.assets.length > 0) { - const type = result.assets[0].type; - const base64String = result.assets[0].base64; - const str = `data:${type};base64,${base64String}`; - - const width = 1000; - const height = 1000; - - // imageHTML must be declared before it is referenced in setHtmlImages - const imageHTML = ``; - - setLocalImages(prev => [...prev, str]); - setHtmlImages(prev => [...prev, imageHTML]); - - await RichText.current?.insertHTML(imageHTML); - - //await RichText.current?.insertImage(str); - } else { - //console.log('No image selected'); - } - } - - // async function insertVideo() { - // const result = await ImagePicker.launchImageLibrary({ - // mediaType: 'video', - // presentationStyle: 'popover', - // }); - - // if (result.assets && result.assets.length > 0) { - // const fileUri = result.assets[0].uri; - // console.log('Image URI:', fileUri); - // setvideoData(`${fileUri}`); - // // Convert the video URI to a Blob - // // await RichText.current?.insertVideo(blobUrl); - // // Insert video through local file url - // RichText.current?.insertVideo(fileUri); - // } else { - // console.log('No video selected'); - // } - // } - /* - const onPressAddImage = () => { - const options = { - mediaType: 'photo', - includeBase64: true, - }; - - launchImageLibrary(options, async response => { - if (response.didCancel) { - console.log('User cancelled image picker'); - } else if (response.error) { - console.log('ImagePicker Error: ', response.error); - } else if (response.assets) { - const {uri, fileSize} = response.assets[0]; - - // Check file size (1 MB limit) - if (fileSize && fileSize > 1024 * 1024) { - Alert.alert('Error', 'File size exceeds 1 MB.'); - return; - } - - // Check dimensions - ImageResizer.createResizedImage(uri, 2000, 2000, 'JPEG', 100) - .then(async resizedImageUri => { - // If the image is resized successfully, upload it - }) - .catch(err => { - console.log(err); - Alert.alert('Error', 'Could not resize the image.'); - }); - - // setImageUtils(uri ? uri : ''); - await RichText.current?.insertImage(uri ? uri : ''); - } - }); - }; -*/ - return ( - - - {/* Writing Helper Text */} - - Compose your article with rich formatting tools below - - - ( - - ), - [actions.alignLeft]: ({tintColor}: {tintColor: string}) => ( - - ), - [actions.alignCenter]: ({tintColor}: {tintColor: string}) => ( - - ), - [actions.alignRight]: ({tintColor}: {tintColor: string}) => ( - - ), - [actions.undo]: ({tintColor}: {tintColor: string}) => ( - - ), - [actions.redo]: ({tintColor}: {tintColor: string}) => ( - - ), - [actions.heading1]: ({tintColor}: {tintColor: string}) => ( - H1 - ), - [actions.heading2]: ({tintColor}: {tintColor: string}) => ( - H2 - ), - [actions.heading3]: ({tintColor}: {tintColor: string}) => ( - H3 - ), - [actions.insertImage]: ({tintColor}: {tintColor: string}) => ( - - ), - [actions.blockquote]: ({tintColor}: {tintColor: string}) => ( - - ), - }} - insertImage={onPressAddImage} - /> - - setArticle(text)} - editorInitializedCallback={editorInitializedCallback} - onHeightChange={handleHeightChange} - initialHeight={650} - useContainer={true} - pasteAsPlainText={false} - /> - - {/* Character Count Helper */} - - {article.replace(/<[^>]*>/g, '').length} characters - - - ); -}; - -export default EditorScreen; - -const styles = StyleSheet.create({ - /********************************/ - /* styles for html tags */ - a: { - fontWeight: 'bold', - color: PRIMARY_COLOR, - }, - div: { - fontFamily: 'monospace', - }, - p: { - fontSize: 16, - lineHeight: 24, - }, - /*******************************/ - container: { - flex: 1, - backgroundColor: '#F9FAFB', - }, - helperText: { - fontSize: 15, - color: '#6B7280', - paddingHorizontal: 16, - paddingVertical: 12, - backgroundColor: '#FFFFFF', - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - fontWeight: '500', - }, - editor: { - backgroundColor: '#FFFFFF', - borderRadius: 12, - marginHorizontal: 5, - marginTop: 8, - borderWidth: 1, - borderColor: '#E5E7EB', - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.05, - shadowRadius: 4, - elevation: 2, - minHeight: 650, - }, - rich: { - flex: 1, - backgroundColor: '#FFFFFF', - paddingHorizontal: 16, - paddingVertical: 12, - fontSize: 16, - lineHeight: 24, - }, - richBar: { - height: 56, - backgroundColor: PRIMARY_COLOR, - borderRadius: 12, - marginHorizontal: 12, - marginTop: 8, - marginBottom: 8, - shadowColor: PRIMARY_COLOR, - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.2, - shadowRadius: 4, - elevation: 3, - }, - headingIcon: { - textAlign: 'center', - fontSize: 18, - fontWeight: '700', - }, - characterCount: { - fontSize: 13, - color: '#9CA3AF', - paddingHorizontal: 16, - paddingVertical: 12, - textAlign: 'right', - backgroundColor: '#FFFFFF', - marginTop: 4, - marginHorizontal: 12, - borderRadius: 8, - borderWidth: 1, - borderColor: '#E5E7EB', - }, - preview_button: { - marginRight: 15, - paddingHorizontal: 8, - paddingVertical: 6, - }, -}); + +import React, {useEffect, useRef, useState} from 'react'; +import { + StyleSheet, + Text, + ScrollView, + Alert, + TouchableOpacity, +} from 'react-native'; +import {actions, RichEditor, RichToolbar} from 'react-native-pell-rich-editor'; +import Feather from '@expo/vector-icons/Feather'; +import Entypo from '@expo/vector-icons/Entypo'; +import IonIcon from '@expo/vector-icons/Ionicons'; +import FontAwesome from '@expo/vector-icons/FontAwesome'; +import Fontisto from '@expo/vector-icons/Fontisto'; +import {PRIMARY_COLOR} from '../../helper/Theme'; +import {EditorScreenProp} from '../../type'; +import {launchImageLibrary} from 'react-native-image-picker'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {useDispatch} from 'react-redux'; +import {setSuggestion, setSuggestionAccepted} from '../../store/dataSlice'; import { rf } from '../../helper/Metric'; + + +// Feature: +// If you want to discard your post, in that case no post will upload into storage, +const EditorScreen = ({navigation, route}: EditorScreenProp) => { + const insets = useSafeAreaInsets(); + const { + title, + description, + selectedGenres, + authorName, + imageUtils, + articleData, + requestId, + htmlContent, + pb_record_id, + translationSource, + } = route.params; + + const RichText = useRef(null); + const [article, setArticle] = useState(''); + const [localImages, setLocalImages] = useState([]); + const [htmlImages, setHtmlImages] = useState([]); + const [editorReady, setEditorReady] = useState(false); + const dispatch = useDispatch(); + + React.useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + { + console.log('Preview button pressed'); + if (article.length > 20) { + //console.log('Preview Screen'); + dispatch(setSuggestion({suggestion: ''})); + dispatch(setSuggestionAccepted({selection: false})); + navigation.navigate('PreviewScreen', { + article: article, + title: title, + authorName: authorName, + description: description, + image: imageUtils, + selectedGenres: selectedGenres, + localImages: localImages, + htmlImages: htmlImages, + articleData: articleData, + requestId: requestId, + pb_record_id: pb_record_id, + language: route.params.language, + translationSource, + }); + } else { + Alert.alert('Error', 'Please enter at least 20 characters'); + } + }}> + + + ), + }); + }, [ + navigation, + article, + title, + description, + imageUtils, + selectedGenres, + authorName, + localImages, + htmlImages, + articleData, + requestId, + dispatch, + pb_record_id, + translationSource, + ]); + + useEffect(() => { + /* + const loadArticle = async () => { + if (!articleData) { + return; + } + try { + if (articleData.content.endsWith('.html')) { + await getContent(articleData.content); + + setEditorReady(true); + } else { + setArticle(articleData.content); + setEditorReady(true); + } + } catch (err) { + console.error('Failed to load article:', err); + } + }; + */ + + if (htmlContent) { + setArticle(htmlContent); + setEditorReady(true); + } + + // loadArticle(); + }, [htmlContent]); + + React.useEffect(() => { + if (imageUtils) { + setLocalImages(prevImages => [imageUtils, ...prevImages]); // Add imageUtils at the start + } + }, [imageUtils]); + + useEffect(() => { + if (editorReady && article && RichText.current) { + RichText.current?.setContentHTML(article); + //RichText.current?.focusContentEditor(); + } + }, [ editorReady]); + + // this function will be called when the editor has been initialized + function editorInitializedCallback() { + RichText.current?.registerToolbar(function (_items) { + setEditorReady(true); + }); + } + + /* + + const getContent = async content => { + try { + const response = await fetch(`${GET_STORAGE_DATA}/${content}`); + const text = await response.text(); + setArticle(text); + } catch (error) { + // console.error('Error fetching URI:', error); + setArticle(content); + } + }; + */ + + // Callback after height change + function handleHeightChange(_height: number) { + // console.log("editor height change:", height); + } + + async function onPressAddImage() { + const result = await launchImageLibrary({ + mediaType: 'photo', + presentationStyle: 'popover', + quality: 0.7, + includeBase64: true, + }); + if (result.assets && result.assets.length > 0) { + const type = result.assets[0].type; + const base64String = result.assets[0].base64; + const str = `data:${type};base64,${base64String}`; + + const width = 1000; + const height = 1000; + + // imageHTML must be declared before it is referenced in setHtmlImages + const imageHTML = ``; + + setLocalImages(prev => [...prev, str]); + setHtmlImages(prev => [...prev, imageHTML]); + + await RichText.current?.insertHTML(imageHTML); + + //await RichText.current?.insertImage(str); + } else { + //console.log('No image selected'); + } + } + + // async function insertVideo() { + // const result = await ImagePicker.launchImageLibrary({ + // mediaType: 'video', + // presentationStyle: 'popover', + // }); + + // if (result.assets && result.assets.length > 0) { + // const fileUri = result.assets[0].uri; + // console.log('Image URI:', fileUri); + // setvideoData(`${fileUri}`); + // // Convert the video URI to a Blob + // // await RichText.current?.insertVideo(blobUrl); + // // Insert video through local file url + // RichText.current?.insertVideo(fileUri); + // } else { + // console.log('No video selected'); + // } + // } + /* + const onPressAddImage = () => { + const options = { + mediaType: 'photo', + includeBase64: true, + }; + + launchImageLibrary(options, async response => { + if (response.didCancel) { + console.log('User cancelled image picker'); + } else if (response.error) { + console.log('ImagePicker Error: ', response.error); + } else if (response.assets) { + const {uri, fileSize} = response.assets[0]; + + // Check file size (1 MB limit) + if (fileSize && fileSize > 1024 * 1024) { + Alert.alert('Error', 'File size exceeds 1 MB.'); + return; + } + + // Check dimensions + ImageResizer.createResizedImage(uri, 2000, 2000, 'JPEG', 100) + .then(async resizedImageUri => { + // If the image is resized successfully, upload it + }) + .catch(err => { + console.log(err); + Alert.alert('Error', 'Could not resize the image.'); + }); + + // setImageUtils(uri ? uri : ''); + await RichText.current?.insertImage(uri ? uri : ''); + } + }); + }; +*/ + return ( + + + {/* Writing Helper Text */} + + Compose your article with rich formatting tools below + + + ( + + ), + [actions.alignLeft]: ({tintColor}: {tintColor: string}) => ( + + ), + [actions.alignCenter]: ({tintColor}: {tintColor: string}) => ( + + ), + [actions.alignRight]: ({tintColor}: {tintColor: string}) => ( + + ), + [actions.undo]: ({tintColor}: {tintColor: string}) => ( + + ), + [actions.redo]: ({tintColor}: {tintColor: string}) => ( + + ), + [actions.heading1]: ({tintColor}: {tintColor: string}) => ( + H1 + ), + [actions.heading2]: ({tintColor}: {tintColor: string}) => ( + H2 + ), + [actions.heading3]: ({tintColor}: {tintColor: string}) => ( + H3 + ), + [actions.insertImage]: ({tintColor}: {tintColor: string}) => ( + + ), + [actions.blockquote]: ({tintColor}: {tintColor: string}) => ( + + ), + }} + insertImage={onPressAddImage} + /> + + setArticle(text)} + editorInitializedCallback={editorInitializedCallback} + onHeightChange={handleHeightChange} + initialHeight={650} + useContainer={true} + pasteAsPlainText={false} + /> + + {/* Character Count Helper */} + + {article.replace(/<[^>]*>/g, '').length} characters + + + ); +}; + +export default EditorScreen; + +const styles = StyleSheet.create({ + /********************************/ + /* styles for html tags */ + a: { + fontWeight: 'bold', + color: PRIMARY_COLOR, + }, + div: { + fontFamily: 'monospace', + }, + p: { + fontSize: rf(16), + lineHeight: 24, + }, + /*******************************/ + container: { + flex: 1, + backgroundColor: '#F9FAFB', + }, + helperText: { + fontSize: rf(15), + color: '#6B7280', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#FFFFFF', + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + fontWeight: '500', + }, + editor: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + marginHorizontal: 5, + marginTop: 8, + borderWidth: 1, + borderColor: '#E5E7EB', + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 2, + minHeight: 650, + }, + rich: { + flex: 1, + backgroundColor: '#FFFFFF', + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: rf(16), + lineHeight: 24, + }, + richBar: { + height: 56, + backgroundColor: PRIMARY_COLOR, + borderRadius: 12, + marginHorizontal: 12, + marginTop: 8, + marginBottom: 8, + shadowColor: PRIMARY_COLOR, + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 3, + }, + headingIcon: { + textAlign: 'center', + fontSize: rf(18), + fontWeight: '700', + }, + characterCount: { + fontSize: rf(13), + color: '#9CA3AF', + paddingHorizontal: 16, + paddingVertical: 12, + textAlign: 'right', + backgroundColor: '#FFFFFF', + marginTop: 4, + marginHorizontal: 12, + borderRadius: 8, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + preview_button: { + marginRight: 15, + paddingHorizontal: 8, + paddingVertical: 6, + }, +}); diff --git a/frontend/src/screens/article/PreviewScreen.tsx b/frontend/src/screens/article/PreviewScreen.tsx index 7c4a4646..47288edb 100644 --- a/frontend/src/screens/article/PreviewScreen.tsx +++ b/frontend/src/screens/article/PreviewScreen.tsx @@ -1,575 +1,576 @@ -import React, {useState} from 'react'; -import { - Alert, - StyleSheet, - Text, - View, - TouchableOpacity, - Dimensions, -} from 'react-native'; -import {BUTTON_COLOR} from '../../helper/Theme'; -import { - PocketBaseResponse, - PreviewScreenProp, -} from '../../type'; -import {createHTMLStructure, handleExternalClick} from '../../helper/Utils'; -import ImageResizer from '@bam.tech/react-native-image-resizer'; - -import Loader from '../../components/Loader'; -import {GET_IMAGE} from '../../helper/APIUtils'; -import {useDispatch, useSelector} from 'react-redux'; -import useUploadImage from '../../hooks/useUploadImage'; -import {setSuggestion} from '../../store/dataSlice'; -import Snackbar from 'react-native-snackbar'; -import AutoHeightWebView from '@brown-bear/react-native-autoheight-webview'; -import {useGetProfile} from '@/src/hooks/useGetProfile'; -import {usePostArticleData} from '@/src/hooks/usePostArticle'; -import {useSubmitImprovement} from '@/src/hooks/useSubmitImprovement'; -import {useSubmitSuggestedChanges} from '@/src/hooks/useSubmitSuggestedChanges'; -import {useUploadArticleToPocketbase} from '@/src/hooks/useUploadArticlePocketbase'; -import {useUploadImprovementToPocketbase} from '@/src/hooks/useUploadImprovementToPocketbase'; -import {useRenderSuggestion} from '@/src/hooks/useRenderSuggestion'; - -export default function PreviewScreen({navigation, route}: PreviewScreenProp) { - const { - article, - title, - description, - authorName, - selectedGenres, - localImages, - articleData, - requestId, - language, - pb_record_id, - translationSource, - } = route.params; - - const [imageUtil, setImageUtil] = useState(''); - const [imageUtils, setImageUtils] = useState([]); - - const {user_token, user_id} = useSelector((state: any) => state.user); - const {suggestion, suggestionAccepted} = useSelector( - (state: any) => state.data, - ); - - const {isConnected} = useSelector((state: any) => state.network); - const dispatch = useDispatch(); - - const {mutate: postMutation, isPending: postMutationPending} = - usePostArticleData(); - const {mutate: improvementMutation, isPending: improvementMutationPending} = - useSubmitImprovement(); - - const {mutate: submitChangesMutation, isPending: submitChangesPending} = - useSubmitSuggestedChanges(); - - const {mutate: uploadPocketbase, isPending: uploadPocketbasePending} = - useUploadArticleToPocketbase(); - const { - mutate: uploadImprovementToPocketbase, - isPending: uploadPocketbaseImprovementPending, - } = useUploadImprovementToPocketbase(); - - const {mutate: renderAISuggestion, isPending: renderSuggestionPending} = - useRenderSuggestion(); - - const {uploadImage, loading} = useUploadImage(); - - const {data: user} = useGetProfile(); - // console.log(selectedGenres); - - React.useEffect(() => { - navigation.setOptions({ - headerRight: () => ( - { - //createPostMutation.mutate(); - if (isConnected) { - handlePostSubmit(); - } else { - Snackbar.show({ - text: 'Please check your internet connection', - duration: Snackbar.LENGTH_SHORT, - }); - } - }}> - Submit - - ), - }); - }, [navigation]); - - const handlePostSubmit = async () => { - let finalArticle = - suggestionAccepted && suggestion !== '' ? suggestion : article; - let imageUtil = ''; - - // Resize and confirm for all images before uploading - try { - // Show confirmation alert - const confirmation = await showConfirmationAlert(); - if (!confirmation) { - Alert.alert('Post discarded'); - navigation.navigate('TabNavigation'); - return; - } - - let resultImages: string[] = []; - - // Process each local image - for (let i = 0; i < localImages.length; i++) { - const localImage = localImages[i]; - - let uploadedUrl: string | undefined; - - if (localImage.includes('api/getfile')) { - uploadedUrl = localImage; - } else { - // Resize the image and handle the upload - const resizedImageUri = await resizeImage(localImage); - - uploadedUrl = await uploadImage(resizedImageUri?.uri as string); - } - - if (i === 0 && imageUtil.length === 0) { - imageUtil = uploadedUrl?.includes('api/getfile') - ? uploadedUrl - : `${GET_IMAGE}/${uploadedUrl}`; - } else { - resultImages.push(`${GET_IMAGE}/${uploadedUrl}` || ''); - finalArticle = finalArticle.replace( - localImage, - `${GET_IMAGE}/${uploadedUrl}`, - ); - } - } - - // Use a local variable so mutation callbacks get the correct value - // synchronously — React setState is async so imageUtils state would - // still be [] by the time the mutation fires in the same tick. - const finalImageUtils = [imageUtil, ...resultImages]; - setImageUtils(finalImageUtils); - - // Submit Improvement - if (requestId) { - uploadImprovementToPocketbase( - { - title: title, - htmlContent: finalArticle, - article_id: articleData ? articleData.pb_recordId : null, - record_id: pb_record_id ?? null, - improvement_id: requestId, - user_id: user_id, - }, - { - onSuccess: (data: PocketBaseResponse) => { - if (data.html_file) { - improvementMutation( - { - edited_content: data.html_file, - recordId: data.recordId, - requestId: requestId ?? '', - imageUtils: finalImageUtils, - }, - { - onSuccess: data => { - Snackbar.show({ - text: 'Changes submitted for review', - duration: Snackbar.LENGTH_SHORT, - }); - - navigation.navigate('TabNavigation'); - }, - onError: error => { - console.log('Article post Error', error); - - Alert.alert('Error', 'Failed to upload your post'); - }, - }, - ); - } else { - Alert.alert('Failed to upload your post'); - } - }, - onError: error => { - console.log('Article post Error', error); - // console.log(error); - - Alert.alert('Failed to upload your post'); - }, - }, - ); - } - // Submit changes or create a new post - else { - // Submit new article - setImageUtil(imageUtil); - uploadPocketbase( - { - title: title, - htmlContent: finalArticle, - articleData: articleData, - }, - { - onSuccess: (data: PocketBaseResponse) => { - if (data.html_file) { - if (articleData) { - submitChangesMutation( - { - article: data.html_file, - title: title, - userId: articleData - ? typeof articleData.authorId === 'string' - ? articleData.authorId - : articleData.authorId._id - : '', - authorName: authorName, - articleId: articleData?._id, - tags: selectedGenres, - imageUtils: finalImageUtils, - description: description, - }, - { - onSuccess: data => { - // User will not get notified, until the article published - - Snackbar.show({ - text: 'Article submitted for review', - duration: Snackbar.LENGTH_SHORT, - }); - - navigation.navigate('TabNavigation'); - }, - onError: error => { - console.log('Article post Error', error); - // console.log(error); - - Alert.alert('Failed to upload your post'); - }, - }, - ); - } else { - postMutation( - { - title: title, - authorName: authorName, - authorId: user?._id ?? '', - content: data.html_file, - tags: selectedGenres, - imageUtils: finalImageUtils, - description: description, - pb_recordId: data.recordId, - allow_podcast: true, - language: language, - isTranslation: Boolean(translationSource), - sourceArticleId: translationSource?.sourceArticleId, - sourceArticleRecordId: - translationSource?.sourceArticleRecordId, - sourceLanguage: translationSource?.sourceLanguage, - targetLanguage: language, - translationOf: translationSource?.sourceArticleId, - }, - { - onSuccess: () => { - Snackbar.show({ - text: translationSource - ? 'Translation submitted successfully' - : 'Article added successfully', - duration: Snackbar.LENGTH_SHORT, - }); - - navigation.navigate('TabNavigation'); - }, - - onError: error => { - console.log('Article post Error', error); - Snackbar.show({ - text: 'Failed to upload your post', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }, - ); - } - } else { - Snackbar.show({ - text: 'Failed to upload your post', - duration: Snackbar.LENGTH_SHORT, - }); - } - }, - onError: error => { - console.log('Article post Error pb', error.message); - Alert.alert('Failed to upload your post'); - }, - }, - ); - } - } catch (err) { - console.error('Image processing failed:', err); - Alert.alert('Error', 'Could not process the images.'); - } - }; - - // Helper function to show confirmation alert - const showConfirmationAlert = () => { - return new Promise(resolve => { - Alert.alert( - 'Create Post', - 'Please confirm you want to upload this post.', - [ - { - text: 'Cancel', - onPress: () => resolve(false), - style: 'cancel', - }, - { - text: 'OK', - onPress: () => resolve(true), - }, - ], - {cancelable: false}, - ); - }); - }; - - // Helper function to resize an image - const resizeImage = async (localImage: string) => { - try { - const resizedImageUri = await ImageResizer.createResizedImage( - localImage, - 1000, // Width - 1000, // Height - 'JPEG', // Format - 100, // Quality - ); - return resizedImageUri; - } catch (err) { - console.error('Failed to resize image:', err); - // throw new Error('Image resizing failed'); - } - }; - - - if ( - renderSuggestionPending || - uploadPocketbaseImprovementPending || - uploadPocketbasePending || - postMutationPending || - submitChangesPending || - improvementMutationPending || - loading - ) { - return ; - } - return ( - - {/* AI Review Card with Modern Design */} - - - - - Article Ready for Review - - Enhance your content with AI-powered suggestions and improvements - - { - if (isConnected) { - renderAISuggestion( - { - text: article, - }, - { - onSuccess: data => { - const suggestionHtml = data.full_html ?? data.suggested_html; - - if (suggestionHtml) { - dispatch(setSuggestion({suggestion: data.suggestion})); - - navigation.navigate('RenderSuggestion', { - htmlContent: suggestionHtml, - readability_score: data.readability_score, - reading_time: data.reading_time, - }); - } else { - Snackbar.show({ - text: 'Failed to load suggestions, try again!', - duration: Snackbar.LENGTH_SHORT, - }); - } - }, - onError: error => { - console.log('Article suggestion Error', error); - - Snackbar.show({ - text: 'Failed to load suggestions, try again!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }, - ); - } else { - Snackbar.show({ - text: 'Please check your internet connection', - duration: Snackbar.LENGTH_SHORT, - }); - } - }} - activeOpacity={0.8}> - Get AI Suggestions - - - - {/* Preview Label */} - - Preview - - - - console.log(size.height)} - files={[ - { - href: 'cssfileaddress', - type: 'text/css', - rel: 'stylesheet', - }, - ]} - originWhitelist={['*']} - source={{ - html: createHTMLStructure( - title, - article, - selectedGenres, - '', - user ? user?.user_name : '', - ), - }} - scalesPageToFit={true} - viewportContent={'width=device-width, user-scalable=no'} - onShouldStartLoadWithRequest={handleExternalClick} - /> - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#F9FAFB', - }, - textWhite: { - fontWeight: '700', - fontSize: 16, - color: 'white', - }, - button: { - marginRight: 10, - paddingHorizontal: 14, - paddingVertical: 8, - backgroundColor: BUTTON_COLOR, - borderRadius: 8, - shadowColor: BUTTON_COLOR, - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.3, - shadowRadius: 4, - elevation: 3, - }, - aiReviewCard: { - backgroundColor: '#FFFFFF', - margin: 16, - padding: 24, - borderRadius: 16, - alignItems: 'center', - shadowColor: '#000', - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.1, - shadowRadius: 8, - elevation: 4, - borderWidth: 1, - borderColor: '#E5E7EB', - }, - iconContainer: { - width: 60, - height: 60, - borderRadius: 30, - backgroundColor: '#FEF3C7', - justifyContent: 'center', - alignItems: 'center', - marginBottom: 16, - }, - iconText: { - fontSize: 32, - }, - reviewTitle: { - fontSize: 22, - fontWeight: '700', - color: '#1F2937', - marginBottom: 8, - textAlign: 'center', - }, - reviewSubtext: { - fontSize: 15, - color: '#6B7280', - textAlign: 'center', - marginBottom: 20, - lineHeight: 22, - paddingHorizontal: 8, - }, - continueButton: { - backgroundColor: BUTTON_COLOR, - paddingVertical: 14, - paddingHorizontal: 32, - borderRadius: 12, - shadowColor: BUTTON_COLOR, - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.3, - shadowRadius: 6, - elevation: 5, - minWidth: 200, - alignItems: 'center', - }, - continueButtonText: { - color: '#FFFFFF', - fontSize: 16, - fontWeight: '700', - letterSpacing: 0.5, - }, - previewHeader: { - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: '#F3F4F6', - borderBottomWidth: 1, - borderBottomColor: '#E5E7EB', - }, - previewLabel: { - fontSize: 14, - fontWeight: '600', - color: '#6B7280', - textTransform: 'uppercase', - letterSpacing: 1, - }, - articlePreviewContainer: { - flex: 1, - backgroundColor: '#FFFFFF', - marginHorizontal: 16, - marginVertical: 12, - borderRadius: 12, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.08, - shadowRadius: 4, - elevation: 3, - }, - webView: { - width: Dimensions.get('window').width - 32, - }, -}); +import React, {useState} from 'react'; +import { + Alert, + StyleSheet, + Text, + View, + TouchableOpacity, + Dimensions, +} from 'react-native'; +import {BUTTON_COLOR} from '../../helper/Theme'; +import { + PocketBaseResponse, + PreviewScreenProp, +} from '../../type'; +import {createHTMLStructure, handleExternalClick} from '../../helper/Utils'; +import ImageResizer from '@bam.tech/react-native-image-resizer'; + +import Loader from '../../components/Loader'; +import {GET_IMAGE} from '../../helper/APIUtils'; +import {useDispatch, useSelector} from 'react-redux'; +import useUploadImage from '../../hooks/useUploadImage'; +import {setSuggestion} from '../../store/dataSlice'; +import Snackbar from 'react-native-snackbar'; +import AutoHeightWebView from '@brown-bear/react-native-autoheight-webview'; +import {useGetProfile} from '@/src/hooks/useGetProfile'; +import {usePostArticleData} from '@/src/hooks/usePostArticle'; +import {useSubmitImprovement} from '@/src/hooks/useSubmitImprovement'; +import {useSubmitSuggestedChanges} from '@/src/hooks/useSubmitSuggestedChanges'; +import {useUploadArticleToPocketbase} from '@/src/hooks/useUploadArticlePocketbase'; +import {useUploadImprovementToPocketbase} from '@/src/hooks/useUploadImprovementToPocketbase'; +import {useRenderSuggestion} from '@/src/hooks/useRenderSuggestion'; import { rf } from '../../helper/Metric'; + + +export default function PreviewScreen({navigation, route}: PreviewScreenProp) { + const { + article, + title, + description, + authorName, + selectedGenres, + localImages, + articleData, + requestId, + language, + pb_record_id, + translationSource, + } = route.params; + + const [imageUtil, setImageUtil] = useState(''); + const [imageUtils, setImageUtils] = useState([]); + + const {user_token, user_id} = useSelector((state: any) => state.user); + const {suggestion, suggestionAccepted} = useSelector( + (state: any) => state.data, + ); + + const {isConnected} = useSelector((state: any) => state.network); + const dispatch = useDispatch(); + + const {mutate: postMutation, isPending: postMutationPending} = + usePostArticleData(); + const {mutate: improvementMutation, isPending: improvementMutationPending} = + useSubmitImprovement(); + + const {mutate: submitChangesMutation, isPending: submitChangesPending} = + useSubmitSuggestedChanges(); + + const {mutate: uploadPocketbase, isPending: uploadPocketbasePending} = + useUploadArticleToPocketbase(); + const { + mutate: uploadImprovementToPocketbase, + isPending: uploadPocketbaseImprovementPending, + } = useUploadImprovementToPocketbase(); + + const {mutate: renderAISuggestion, isPending: renderSuggestionPending} = + useRenderSuggestion(); + + const {uploadImage, loading} = useUploadImage(); + + const {data: user} = useGetProfile(); + // console.log(selectedGenres); + + React.useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + { + //createPostMutation.mutate(); + if (isConnected) { + handlePostSubmit(); + } else { + Snackbar.show({ + text: 'Please check your internet connection', + duration: Snackbar.LENGTH_SHORT, + }); + } + }}> + Submit + + ), + }); + }, [navigation]); + + const handlePostSubmit = async () => { + let finalArticle = + suggestionAccepted && suggestion !== '' ? suggestion : article; + let imageUtil = ''; + + // Resize and confirm for all images before uploading + try { + // Show confirmation alert + const confirmation = await showConfirmationAlert(); + if (!confirmation) { + Alert.alert('Post discarded'); + navigation.navigate('TabNavigation'); + return; + } + + let resultImages: string[] = []; + + // Process each local image + for (let i = 0; i < localImages.length; i++) { + const localImage = localImages[i]; + + let uploadedUrl: string | undefined; + + if (localImage.includes('api/getfile')) { + uploadedUrl = localImage; + } else { + // Resize the image and handle the upload + const resizedImageUri = await resizeImage(localImage); + + uploadedUrl = await uploadImage(resizedImageUri?.uri as string); + } + + if (i === 0 && imageUtil.length === 0) { + imageUtil = uploadedUrl?.includes('api/getfile') + ? uploadedUrl + : `${GET_IMAGE}/${uploadedUrl}`; + } else { + resultImages.push(`${GET_IMAGE}/${uploadedUrl}` || ''); + finalArticle = finalArticle.replace( + localImage, + `${GET_IMAGE}/${uploadedUrl}`, + ); + } + } + + // Use a local variable so mutation callbacks get the correct value + // synchronously — React setState is async so imageUtils state would + // still be [] by the time the mutation fires in the same tick. + const finalImageUtils = [imageUtil, ...resultImages]; + setImageUtils(finalImageUtils); + + // Submit Improvement + if (requestId) { + uploadImprovementToPocketbase( + { + title: title, + htmlContent: finalArticle, + article_id: articleData ? articleData.pb_recordId : null, + record_id: pb_record_id ?? null, + improvement_id: requestId, + user_id: user_id, + }, + { + onSuccess: (data: PocketBaseResponse) => { + if (data.html_file) { + improvementMutation( + { + edited_content: data.html_file, + recordId: data.recordId, + requestId: requestId ?? '', + imageUtils: finalImageUtils, + }, + { + onSuccess: data => { + Snackbar.show({ + text: 'Changes submitted for review', + duration: Snackbar.LENGTH_SHORT, + }); + + navigation.navigate('TabNavigation'); + }, + onError: error => { + console.log('Article post Error', error); + + Alert.alert('Error', 'Failed to upload your post'); + }, + }, + ); + } else { + Alert.alert('Failed to upload your post'); + } + }, + onError: error => { + console.log('Article post Error', error); + // console.log(error); + + Alert.alert('Failed to upload your post'); + }, + }, + ); + } + // Submit changes or create a new post + else { + // Submit new article + setImageUtil(imageUtil); + uploadPocketbase( + { + title: title, + htmlContent: finalArticle, + articleData: articleData, + }, + { + onSuccess: (data: PocketBaseResponse) => { + if (data.html_file) { + if (articleData) { + submitChangesMutation( + { + article: data.html_file, + title: title, + userId: articleData + ? typeof articleData.authorId === 'string' + ? articleData.authorId + : articleData.authorId._id + : '', + authorName: authorName, + articleId: articleData?._id, + tags: selectedGenres, + imageUtils: finalImageUtils, + description: description, + }, + { + onSuccess: data => { + // User will not get notified, until the article published + + Snackbar.show({ + text: 'Article submitted for review', + duration: Snackbar.LENGTH_SHORT, + }); + + navigation.navigate('TabNavigation'); + }, + onError: error => { + console.log('Article post Error', error); + // console.log(error); + + Alert.alert('Failed to upload your post'); + }, + }, + ); + } else { + postMutation( + { + title: title, + authorName: authorName, + authorId: user?._id ?? '', + content: data.html_file, + tags: selectedGenres, + imageUtils: finalImageUtils, + description: description, + pb_recordId: data.recordId, + allow_podcast: true, + language: language, + isTranslation: Boolean(translationSource), + sourceArticleId: translationSource?.sourceArticleId, + sourceArticleRecordId: + translationSource?.sourceArticleRecordId, + sourceLanguage: translationSource?.sourceLanguage, + targetLanguage: language, + translationOf: translationSource?.sourceArticleId, + }, + { + onSuccess: () => { + Snackbar.show({ + text: translationSource + ? 'Translation submitted successfully' + : 'Article added successfully', + duration: Snackbar.LENGTH_SHORT, + }); + + navigation.navigate('TabNavigation'); + }, + + onError: error => { + console.log('Article post Error', error); + Snackbar.show({ + text: 'Failed to upload your post', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }, + ); + } + } else { + Snackbar.show({ + text: 'Failed to upload your post', + duration: Snackbar.LENGTH_SHORT, + }); + } + }, + onError: error => { + console.log('Article post Error pb', error.message); + Alert.alert('Failed to upload your post'); + }, + }, + ); + } + } catch (err) { + console.error('Image processing failed:', err); + Alert.alert('Error', 'Could not process the images.'); + } + }; + + // Helper function to show confirmation alert + const showConfirmationAlert = () => { + return new Promise(resolve => { + Alert.alert( + 'Create Post', + 'Please confirm you want to upload this post.', + [ + { + text: 'Cancel', + onPress: () => resolve(false), + style: 'cancel', + }, + { + text: 'OK', + onPress: () => resolve(true), + }, + ], + {cancelable: false}, + ); + }); + }; + + // Helper function to resize an image + const resizeImage = async (localImage: string) => { + try { + const resizedImageUri = await ImageResizer.createResizedImage( + localImage, + 1000, // Width + 1000, // Height + 'JPEG', // Format + 100, // Quality + ); + return resizedImageUri; + } catch (err) { + console.error('Failed to resize image:', err); + // throw new Error('Image resizing failed'); + } + }; + + + if ( + renderSuggestionPending || + uploadPocketbaseImprovementPending || + uploadPocketbasePending || + postMutationPending || + submitChangesPending || + improvementMutationPending || + loading + ) { + return ; + } + return ( + + {/* AI Review Card with Modern Design */} + + + + + Article Ready for Review + + Enhance your content with AI-powered suggestions and improvements + + { + if (isConnected) { + renderAISuggestion( + { + text: article, + }, + { + onSuccess: data => { + const suggestionHtml = data.full_html ?? data.suggested_html; + + if (suggestionHtml) { + dispatch(setSuggestion({suggestion: data.suggestion})); + + navigation.navigate('RenderSuggestion', { + htmlContent: suggestionHtml, + readability_score: data.readability_score, + reading_time: data.reading_time, + }); + } else { + Snackbar.show({ + text: 'Failed to load suggestions, try again!', + duration: Snackbar.LENGTH_SHORT, + }); + } + }, + onError: error => { + console.log('Article suggestion Error', error); + + Snackbar.show({ + text: 'Failed to load suggestions, try again!', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }, + ); + } else { + Snackbar.show({ + text: 'Please check your internet connection', + duration: Snackbar.LENGTH_SHORT, + }); + } + }} + activeOpacity={0.8}> + Get AI Suggestions + + + + {/* Preview Label */} + + Preview + + + + console.log(size.height)} + files={[ + { + href: 'cssfileaddress', + type: 'text/css', + rel: 'stylesheet', + }, + ]} + originWhitelist={['*']} + source={{ + html: createHTMLStructure( + title, + article, + selectedGenres, + '', + user ? user?.user_name : '', + ), + }} + scalesPageToFit={true} + viewportContent={'width=device-width, user-scalable=no'} + onShouldStartLoadWithRequest={handleExternalClick} + /> + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F9FAFB', + }, + textWhite: { + fontWeight: '700', + fontSize: rf(16), + color: 'white', + }, + button: { + marginRight: 10, + paddingHorizontal: 14, + paddingVertical: 8, + backgroundColor: BUTTON_COLOR, + borderRadius: 8, + shadowColor: BUTTON_COLOR, + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 3, + }, + aiReviewCard: { + backgroundColor: '#FFFFFF', + margin: 16, + padding: 24, + borderRadius: 16, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 4, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + iconContainer: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#FEF3C7', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + iconText: { + fontSize: rf(32), + }, + reviewTitle: { + fontSize: rf(22), + fontWeight: '700', + color: '#1F2937', + marginBottom: 8, + textAlign: 'center', + }, + reviewSubtext: { + fontSize: rf(15), + color: '#6B7280', + textAlign: 'center', + marginBottom: 20, + lineHeight: 22, + paddingHorizontal: 8, + }, + continueButton: { + backgroundColor: BUTTON_COLOR, + paddingVertical: 14, + paddingHorizontal: 32, + borderRadius: 12, + shadowColor: BUTTON_COLOR, + shadowOffset: {width: 0, height: 4}, + shadowOpacity: 0.3, + shadowRadius: 6, + elevation: 5, + minWidth: 200, + alignItems: 'center', + }, + continueButtonText: { + color: '#FFFFFF', + fontSize: rf(16), + fontWeight: '700', + letterSpacing: 0.5, + }, + previewHeader: { + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: '#F3F4F6', + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + }, + previewLabel: { + fontSize: rf(14), + fontWeight: '600', + color: '#6B7280', + textTransform: 'uppercase', + letterSpacing: 1, + }, + articlePreviewContainer: { + flex: 1, + backgroundColor: '#FFFFFF', + marginHorizontal: 16, + marginVertical: 12, + borderRadius: 12, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.08, + shadowRadius: 4, + elevation: 3, + }, + webView: { + width: Dimensions.get('window').width - 32, + }, +}); diff --git a/frontend/src/screens/article/RenderSuggestion.tsx b/frontend/src/screens/article/RenderSuggestion.tsx index aa7e578d..b413abac 100644 --- a/frontend/src/screens/article/RenderSuggestion.tsx +++ b/frontend/src/screens/article/RenderSuggestion.tsx @@ -1,128 +1,129 @@ -import React, { useRef } from 'react'; -import { View, StyleSheet, TouchableOpacity, Text } from 'react-native'; -import { RenderSuggestionProp } from '../../type'; -import { useDispatch } from 'react-redux'; -import { setSuggestionAccepted } from '../../store/dataSlice'; -import AutoHeightWebView from '@brown-bear/react-native-autoheight-webview'; -// ✅ Re-introduced original project helpers for security sanitization and link safety -import { createHTMLStructure, handleExternalClick } from '../../helper/Utils'; +import React, { useRef } from 'react'; +import { View, StyleSheet, TouchableOpacity, Text } from 'react-native'; +import { RenderSuggestionProp } from '../../type'; +import { useDispatch } from 'react-redux'; +import { setSuggestionAccepted } from '../../store/dataSlice'; +import AutoHeightWebView from '@brown-bear/react-native-autoheight-webview'; +// ✅ Re-introduced original project helpers for security sanitization and link safety +import { createHTMLStructure, handleExternalClick } from '../../helper/Utils'; import { rf } from '../../helper/Metric'; -export default function RenderSuggestion({ - navigation, - route, -}: RenderSuggestionProp) { - const { htmlContent, readability_score, reading_time } = route.params; - const dispatch = useDispatch(); - const readabilityScore = readability_score ?? 0; - - const handleAccept = () => { - dispatch(setSuggestionAccepted({ selection: true })); - navigation.goBack(); - }; - - const handleCancel = () => { - dispatch(setSuggestionAccepted({ selection: false })); - navigation.goBack(); - }; - - // ✅ Format reading time display (improved null safety) - const formatReadingTime = (time: string | undefined | null): string => { - if (!time) { - return 'Estimated 2-3 min read'; - } - // If time is a number or numeric string - const minutes = parseInt(time, 10); - if (isNaN(minutes)) { - return time; // Return as-is if not a number - } - return `${minutes} min read`; - }; - - // ✅ Wraps the content through the project's sanitization utility to prevent XSS flaws - const formattedHtml = createHTMLStructure('', htmlContent || '

No suggestions available.

', [], '', ''); - - return ( - - {/* 📊 Display Readability Metrics */} - - Readability Score - = 60 ? '#059669' : '#dc2626' } - ]}> - {readability_score ?? '--'}/100 - - - ⏱ {formatReadingTime(reading_time)} - - - - {/* 🌟 Optional: Add warning if reading time is missing (for debugging) */} - {!reading_time && __DEV__ && ( - - - ⚠️ Debug: reading_time missing from API response - - - )} - - {/* 🌐 Render Highlighted Complex Sentences Safely */} - - - {/* Buttons */} - - - Dismiss - - - - Apply Suggestions - - - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#ffffff', padding: 16 }, - metricsContainer: { - padding: 16, - backgroundColor: '#f8fafc', - borderRadius: 12, - marginBottom: 16, - alignItems: 'center' - }, - scoreTitle: { fontSize: 14, color: '#64748b', fontWeight: '600' }, - scoreNumber: { fontSize: 36, fontWeight: 'bold', marginVertical: 4 }, - readingTime: { fontSize: 14, color: '#475569' }, - webView: { width: '100%', flex: 1, marginBottom: 16 }, - buttonRow: { flexDirection: 'row', justifyContent: 'space-between' }, - cancelButton: { padding: 14, borderRadius: 8, backgroundColor: '#f1f5f9', flex: 0.48, alignItems: 'center' }, - cancelText: { color: '#475569', fontWeight: '600' }, - acceptButton: { padding: 14, borderRadius: 8, backgroundColor: '#0f172a', flex: 0.48, alignItems: 'center' }, - acceptText: { color: '#ffffff', fontWeight: '600' }, - // ✅ Added debug styles (optional, only shows in development) - debugContainer: { - backgroundColor: '#fef3c7', - padding: 8, - borderRadius: 8, - marginBottom: 12, - borderLeftWidth: 4, - borderLeftColor: '#f59e0b', - }, - debugText: { - color: '#92400e', - fontSize: 12, - }, + +export default function RenderSuggestion({ + navigation, + route, +}: RenderSuggestionProp) { + const { htmlContent, readability_score, reading_time } = route.params; + const dispatch = useDispatch(); + const readabilityScore = readability_score ?? 0; + + const handleAccept = () => { + dispatch(setSuggestionAccepted({ selection: true })); + navigation.goBack(); + }; + + const handleCancel = () => { + dispatch(setSuggestionAccepted({ selection: false })); + navigation.goBack(); + }; + + // ✅ Format reading time display (improved null safety) + const formatReadingTime = (time: string | undefined | null): string => { + if (!time) { + return 'Estimated 2-3 min read'; + } + // If time is a number or numeric string + const minutes = parseInt(time, 10); + if (isNaN(minutes)) { + return time; // Return as-is if not a number + } + return `${minutes} min read`; + }; + + // ✅ Wraps the content through the project's sanitization utility to prevent XSS flaws + const formattedHtml = createHTMLStructure('', htmlContent || '

No suggestions available.

', [], '', ''); + + return ( + + {/* 📊 Display Readability Metrics */} + + Readability Score + = 60 ? '#059669' : '#dc2626' } + ]}> + {readability_score ?? '--'}/100 + + + ⏱ {formatReadingTime(reading_time)} + + + + {/* 🌟 Optional: Add warning if reading time is missing (for debugging) */} + {!reading_time && __DEV__ && ( + + + ⚠️ Debug: reading_time missing from API response + + + )} + + {/* 🌐 Render Highlighted Complex Sentences Safely */} + + + {/* Buttons */} + + + Dismiss + + + + Apply Suggestions + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#ffffff', padding: 16 }, + metricsContainer: { + padding: 16, + backgroundColor: '#f8fafc', + borderRadius: 12, + marginBottom: 16, + alignItems: 'center' + }, + scoreTitle: { fontSize: rf(14), color: '#64748b', fontWeight: '600' }, + scoreNumber: { fontSize: rf(36), fontWeight: 'bold', marginVertical: 4 }, + readingTime: { fontSize: rf(14), color: '#475569' }, + webView: { width: '100%', flex: 1, marginBottom: 16 }, + buttonRow: { flexDirection: 'row', justifyContent: 'space-between' }, + cancelButton: { padding: 14, borderRadius: 8, backgroundColor: '#f1f5f9', flex: 0.48, alignItems: 'center' }, + cancelText: { color: '#475569', fontWeight: '600' }, + acceptButton: { padding: 14, borderRadius: 8, backgroundColor: '#0f172a', flex: 0.48, alignItems: 'center' }, + acceptText: { color: '#ffffff', fontWeight: '600' }, + // ✅ Added debug styles (optional, only shows in development) + debugContainer: { + backgroundColor: '#fef3c7', + padding: 8, + borderRadius: 8, + marginBottom: 12, + borderLeftWidth: 4, + borderLeftColor: '#f59e0b', + }, + debugText: { + color: '#92400e', + fontSize: rf(12), + }, }); \ No newline at end of file diff --git a/frontend/src/screens/auth/LoginScreen.tsx b/frontend/src/screens/auth/LoginScreen.tsx index 58c960af..c3d27fac 100644 --- a/frontend/src/screens/auth/LoginScreen.tsx +++ b/frontend/src/screens/auth/LoginScreen.tsx @@ -1,592 +1,593 @@ -import Entypo from '@expo/vector-icons/Entypo'; -import Icon from '@expo/vector-icons/Ionicons'; -import {AxiosError, isAxiosError} from 'axios'; -import {StatusBar} from 'expo-status-bar'; -import messaging from '@react-native-firebase/messaging'; -import React, {useEffect, useState} from 'react'; -import { useForm, Controller } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import {Alert, Image, useColorScheme} from 'react-native'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import Snackbar from 'react-native-snackbar'; -import {useDispatch} from 'react-redux'; -import { - Button, - Input, - Separator, - Text, - useTheme, - XStack, - YStack, -} from 'tamagui'; - -import {useRequestVerification} from '@/src/hooks/useResendVerification'; -import {useSendOtpMutation} from '@/src/hooks/useSendOtp'; -import {useLoginMutation} from '@/src/hooks/useUserLogin'; -import EmailInputBottomSheet from '../../components/EmailInputModal'; -import Loader from '../../components/Loader'; -import {SECURE_KEYS, secureStoreItem} from '../../helper/SecureStorageUtils'; -import {resetSessionExpiredNotification} from '../../helper/setupAxiosInterceptor'; -import {KEYS, storeItem} from '../../helper/Utils'; -import { - setGuestMode, - setUserHandle, - setUserId, - setUserToken, -} from '../../store/UserSlice'; - -import { AuthData, LoginScreenProp } from '../../type'; - -const loginSchema = z.object({ - email: z.string().email('Please Enter a Valid Email'), - password: z.string().min(6, 'Password must be 6 Characters Long'), -}); - -type LoginFormData = z.infer; - -const LoginScreen = ({navigation, route}: LoginScreenProp) => { - const inset = useSafeAreaInsets(); - const {redirectTo} = route.params || {}; - const dispatch = useDispatch(); - const isDarkMode = useColorScheme() === 'dark'; - const [emailInputVisible, setEmailInputVisible] = useState(false); - const [requestVerificationMode, setRequestVerification] = useState(false); - const [otpMail, setOtpMail] = useState(''); - const [fcmToken, setFcmToken] = useState(null); - - const { - control, - handleSubmit, - formState: { isValid }, - setValue, - } = useForm({ - resolver: zodResolver(loginSchema), - mode: 'onChange', - defaultValues: { - email: '', - password: '', - }, - }); - - const [secureTextEntry, setSecureTextEntry] = useState(true); - const {mutate: resendVerification, isPending: resendVerificationPending} = - useRequestVerification(); - - const {mutate: sendOtp, isPending: sendOtpPending} = useSendOtpMutation(); - - const {mutate: login, isPending: loginPending} = useLoginMutation(); - - const handleSecureEntryClickEvent = () => { - setSecureTextEntry(!secureTextEntry); - }; - - const theme = useTheme(); - - async function requestUserPermission() { - const authStatus = await messaging().requestPermission(); - const enabled = - authStatus === messaging.AuthorizationStatus.AUTHORIZED || - authStatus === messaging.AuthorizationStatus.PROVISIONAL; - - if (enabled) { - if (__DEV__) { - console.log('Authorization status:', authStatus); - } - } - } - - useEffect(() => { - if (__DEV__) { - console.log('Email modal visibility state', emailInputVisible); - } - }, [emailInputVisible]); - - async function getFCMToken() { - try { - await requestUserPermission(); - const fcmToken = await messaging().getToken(); - if (fcmToken) { - if (__DEV__) { - console.log('FCM Token:', fcmToken); - } - setFcmToken(fcmToken); - return fcmToken; - } else { - if (__DEV__) { - console.log('Failed to get FCM Token'); - } - return null; - } - } catch (error) { - if (__DEV__) { - console.log('Error getting FCM Token:', error); - } - // Return a placeholder token in debug mode to allow login to proceed - return __DEV__ ? 'debug-mode-token' : null; - } - } - - const onSubmit = async (data: LoginFormData) => { - if (__DEV__) { - console.log('Login attempt in progress'); - } - - const fcmToken = await getFCMToken(); - - if (__DEV__) { - console.log('Attempting to retrieve FCM Token'); - } - - login( - { - email: data.email, - password: data.password, - fcmToken: fcmToken ?? 'not found', - }, - { - onSuccess: async data => { - const auth: AuthData = { - userId: data._id, - token: data?.refreshToken, - user_handle: data?.user_handle, - }; - try { - await storeItem(KEYS.USER_ID, auth.userId.toString()); - await storeItem(KEYS.USER_HANDLE, data?.user_handle); - if (auth.token) { - await secureStoreItem( - SECURE_KEYS.USER_TOKEN, - auth.token, - ); - await storeItem( - KEYS.USER_TOKEN_EXPIRY_DATE, - new Date().toISOString(), - ); - dispatch(setUserId(auth.userId)); - dispatch(setUserToken(auth.token)); - dispatch(setUserHandle(auth.user_handle)); - dispatch(setGuestMode(false)); - // Reset so the next session expiry triggers the notification again. - resetSessionExpiredNotification(); - setTimeout(() => { - if (redirectTo) { - (navigation as any).navigate( - redirectTo.name, - redirectTo.params, - ); - - return; - } - navigation.reset({ - index: 0, - routes: [{name: 'TabNavigation'}], - }); - }, 1000); - } else { - Alert.alert('Token not found'); - } - } catch (e) { - if (__DEV__) { - console.log('Async Storage ERROR', e); - } - } - }, - - onError: (error: AxiosError) => { - if (__DEV__) { - console.log('Error', error); - } - setValue('password', ''); - if (error.response) { - const errorCode = error.response.status; - switch (errorCode) { - case 400: - Alert.alert('Error', 'Please provide email and password'); - break; - case 401: - Alert.alert('Error', 'Invalid password'); - break; - case 403: - Alert.alert( - 'Error', - 'Email not verified. Please check your email.', - ); - break; - case 404: - Alert.alert('Error', 'User not found'); - break; - default: - Alert.alert('Error', 'Internal server error'); - } - } else { - Alert.alert('Error', 'User not found'); - } - }, - }, - ); - }; - - const handleEmailInputBack = () => { - setEmailInputVisible(false); - }; - - const navigateToOtpScreen = () => { - setEmailInputVisible(false); - navigation.navigate('OtpScreen', { - email: otpMail, - }); - }; - - if (loginPending || sendOtpPending || resendVerificationPending) { - return ; - } - return ( - - - - - {/* Logo Section */} - - {/* Form Section */} - - - - - - Enter your email and password to securely access your account - - - - - ( - - {error && ( - - {error.message} - - )} - - - - - - )} - /> - - ( - - {error && ( - - {error.message} - - )} - - - - - - - )} - /> - - - { - setRequestVerification(true); - setEmailInputVisible(true); - }}> - Request Verification - - - { - setEmailInputVisible(true); - setRequestVerification(false); - }}> - Forgot Password? - - - - - - - - - - - - - - - - { - setOtpMail(email); - if (requestVerificationMode) { - resendVerification( - { - email, - }, - { - onSuccess: () => { - /** Check Status */ - Alert.alert('Verification Email Sent'); - setValue('password', ''); - setValue('email', ''); - }, - onError: (error: AxiosError) => { - if (__DEV__) { - console.log('Email Verification error', error); - } - - if (error.response) { - const statusCode = error.response.status; - switch (statusCode) { - case 400: - Snackbar.show({ - text: 'User not found or already verified', - duration: Snackbar.LENGTH_SHORT, - }); - break; - case 429: - Snackbar.show({ - text: 'Verification email already sent, please try again after 1 hour', - duration: Snackbar.LENGTH_SHORT, - }); - break; - case 500: - Snackbar.show({ - text: 'Internal server error, try again', - duration: Snackbar.LENGTH_SHORT, - }); - - break; - default: - Alert.alert( - 'Error', - 'Something went wrong. Please try again later.', - ); - } - } else { - if (__DEV__) { - console.log('Email Verification error', error); - } - } - }, - }, - ); - } else { - sendOtp( - { - email, - }, - { - onSuccess: () => { - Alert.alert('OTP has sent to your mail'); - navigateToOtpScreen(); - }, - onError: error => { - if (isAxiosError(error)) { - if (error.response) { - if (error.response.status === 400) { - Alert.alert( - 'Error', - 'User with this email does not exist.', - ); - } else if (error.response.status === 500) { - Alert.alert('Error', 'Error sending email.'); - } else { - Alert.alert('Error', 'Something went wrong.'); - } - } else { - Alert.alert('Error', 'Network error.'); - } - } else { - Alert.alert('Error', 'Network error.'); - } - }, - }, - ); - } - }} - backButtonClick={handleEmailInputBack} - onDismiss={() => setEmailInputVisible(false)} - isRequestVerification={requestVerificationMode} - /> - - - ); -}; - -export default LoginScreen; +import Entypo from '@expo/vector-icons/Entypo'; +import Icon from '@expo/vector-icons/Ionicons'; +import {AxiosError, isAxiosError} from 'axios'; +import {StatusBar} from 'expo-status-bar'; +import messaging from '@react-native-firebase/messaging'; +import React, {useEffect, useState} from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import {Alert, Image, useColorScheme} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import Snackbar from 'react-native-snackbar'; +import {useDispatch} from 'react-redux'; +import { + Button, + Input, + Separator, + Text, + useTheme, + XStack, + YStack, +} from 'tamagui'; + +import {useRequestVerification} from '@/src/hooks/useResendVerification'; +import {useSendOtpMutation} from '@/src/hooks/useSendOtp'; +import {useLoginMutation} from '@/src/hooks/useUserLogin'; +import EmailInputBottomSheet from '../../components/EmailInputModal'; +import Loader from '../../components/Loader'; +import {SECURE_KEYS, secureStoreItem} from '../../helper/SecureStorageUtils'; +import {resetSessionExpiredNotification} from '../../helper/setupAxiosInterceptor'; +import {KEYS, storeItem} from '../../helper/Utils'; +import { + setGuestMode, + setUserHandle, + setUserId, + setUserToken, +} from '../../store/UserSlice'; + +import { AuthData, LoginScreenProp } from '../../type'; import { rf } from '../../helper/Metric'; + + +const loginSchema = z.object({ + email: z.string().email('Please Enter a Valid Email'), + password: z.string().min(6, 'Password must be 6 Characters Long'), +}); + +type LoginFormData = z.infer; + +const LoginScreen = ({navigation, route}: LoginScreenProp) => { + const inset = useSafeAreaInsets(); + const {redirectTo} = route.params || {}; + const dispatch = useDispatch(); + const isDarkMode = useColorScheme() === 'dark'; + const [emailInputVisible, setEmailInputVisible] = useState(false); + const [requestVerificationMode, setRequestVerification] = useState(false); + const [otpMail, setOtpMail] = useState(''); + const [fcmToken, setFcmToken] = useState(null); + + const { + control, + handleSubmit, + formState: { isValid }, + setValue, + } = useForm({ + resolver: zodResolver(loginSchema), + mode: 'onChange', + defaultValues: { + email: '', + password: '', + }, + }); + + const [secureTextEntry, setSecureTextEntry] = useState(true); + const {mutate: resendVerification, isPending: resendVerificationPending} = + useRequestVerification(); + + const {mutate: sendOtp, isPending: sendOtpPending} = useSendOtpMutation(); + + const {mutate: login, isPending: loginPending} = useLoginMutation(); + + const handleSecureEntryClickEvent = () => { + setSecureTextEntry(!secureTextEntry); + }; + + const theme = useTheme(); + + async function requestUserPermission() { + const authStatus = await messaging().requestPermission(); + const enabled = + authStatus === messaging.AuthorizationStatus.AUTHORIZED || + authStatus === messaging.AuthorizationStatus.PROVISIONAL; + + if (enabled) { + if (__DEV__) { + console.log('Authorization status:', authStatus); + } + } + } + + useEffect(() => { + if (__DEV__) { + console.log('Email modal visibility state', emailInputVisible); + } + }, [emailInputVisible]); + + async function getFCMToken() { + try { + await requestUserPermission(); + const fcmToken = await messaging().getToken(); + if (fcmToken) { + if (__DEV__) { + console.log('FCM Token:', fcmToken); + } + setFcmToken(fcmToken); + return fcmToken; + } else { + if (__DEV__) { + console.log('Failed to get FCM Token'); + } + return null; + } + } catch (error) { + if (__DEV__) { + console.log('Error getting FCM Token:', error); + } + // Return a placeholder token in debug mode to allow login to proceed + return __DEV__ ? 'debug-mode-token' : null; + } + } + + const onSubmit = async (data: LoginFormData) => { + if (__DEV__) { + console.log('Login attempt in progress'); + } + + const fcmToken = await getFCMToken(); + + if (__DEV__) { + console.log('Attempting to retrieve FCM Token'); + } + + login( + { + email: data.email, + password: data.password, + fcmToken: fcmToken ?? 'not found', + }, + { + onSuccess: async data => { + const auth: AuthData = { + userId: data._id, + token: data?.refreshToken, + user_handle: data?.user_handle, + }; + try { + await storeItem(KEYS.USER_ID, auth.userId.toString()); + await storeItem(KEYS.USER_HANDLE, data?.user_handle); + if (auth.token) { + await secureStoreItem( + SECURE_KEYS.USER_TOKEN, + auth.token, + ); + await storeItem( + KEYS.USER_TOKEN_EXPIRY_DATE, + new Date().toISOString(), + ); + dispatch(setUserId(auth.userId)); + dispatch(setUserToken(auth.token)); + dispatch(setUserHandle(auth.user_handle)); + dispatch(setGuestMode(false)); + // Reset so the next session expiry triggers the notification again. + resetSessionExpiredNotification(); + setTimeout(() => { + if (redirectTo) { + (navigation as any).navigate( + redirectTo.name, + redirectTo.params, + ); + + return; + } + navigation.reset({ + index: 0, + routes: [{name: 'TabNavigation'}], + }); + }, 1000); + } else { + Alert.alert('Token not found'); + } + } catch (e) { + if (__DEV__) { + console.log('Async Storage ERROR', e); + } + } + }, + + onError: (error: AxiosError) => { + if (__DEV__) { + console.log('Error', error); + } + setValue('password', ''); + if (error.response) { + const errorCode = error.response.status; + switch (errorCode) { + case 400: + Alert.alert('Error', 'Please provide email and password'); + break; + case 401: + Alert.alert('Error', 'Invalid password'); + break; + case 403: + Alert.alert( + 'Error', + 'Email not verified. Please check your email.', + ); + break; + case 404: + Alert.alert('Error', 'User not found'); + break; + default: + Alert.alert('Error', 'Internal server error'); + } + } else { + Alert.alert('Error', 'User not found'); + } + }, + }, + ); + }; + + const handleEmailInputBack = () => { + setEmailInputVisible(false); + }; + + const navigateToOtpScreen = () => { + setEmailInputVisible(false); + navigation.navigate('OtpScreen', { + email: otpMail, + }); + }; + + if (loginPending || sendOtpPending || resendVerificationPending) { + return ; + } + return ( + + + + + {/* Logo Section */} + + {/* Form Section */} + + + + + + Enter your email and password to securely access your account + + + + + ( + + {error && ( + + {error.message} + + )} + + + + + + )} + /> + + ( + + {error && ( + + {error.message} + + )} + + + + + + + )} + /> + + + { + setRequestVerification(true); + setEmailInputVisible(true); + }}> + Request Verification + + + { + setEmailInputVisible(true); + setRequestVerification(false); + }}> + Forgot Password? + + + + + + + + + + + + + + + + { + setOtpMail(email); + if (requestVerificationMode) { + resendVerification( + { + email, + }, + { + onSuccess: () => { + /** Check Status */ + Alert.alert('Verification Email Sent'); + setValue('password', ''); + setValue('email', ''); + }, + onError: (error: AxiosError) => { + if (__DEV__) { + console.log('Email Verification error', error); + } + + if (error.response) { + const statusCode = error.response.status; + switch (statusCode) { + case 400: + Snackbar.show({ + text: 'User not found or already verified', + duration: Snackbar.LENGTH_SHORT, + }); + break; + case 429: + Snackbar.show({ + text: 'Verification email already sent, please try again after 1 hour', + duration: Snackbar.LENGTH_SHORT, + }); + break; + case 500: + Snackbar.show({ + text: 'Internal server error, try again', + duration: Snackbar.LENGTH_SHORT, + }); + + break; + default: + Alert.alert( + 'Error', + 'Something went wrong. Please try again later.', + ); + } + } else { + if (__DEV__) { + console.log('Email Verification error', error); + } + } + }, + }, + ); + } else { + sendOtp( + { + email, + }, + { + onSuccess: () => { + Alert.alert('OTP has sent to your mail'); + navigateToOtpScreen(); + }, + onError: error => { + if (isAxiosError(error)) { + if (error.response) { + if (error.response.status === 400) { + Alert.alert( + 'Error', + 'User with this email does not exist.', + ); + } else if (error.response.status === 500) { + Alert.alert('Error', 'Error sending email.'); + } else { + Alert.alert('Error', 'Something went wrong.'); + } + } else { + Alert.alert('Error', 'Network error.'); + } + } else { + Alert.alert('Error', 'Network error.'); + } + }, + }, + ); + } + }} + backButtonClick={handleEmailInputBack} + onDismiss={() => setEmailInputVisible(false)} + isRequestVerification={requestVerificationMode} + /> + + + ); +}; + +export default LoginScreen; diff --git a/frontend/src/screens/auth/LogoutScreen.tsx b/frontend/src/screens/auth/LogoutScreen.tsx index 0202e55b..b655c3f2 100644 --- a/frontend/src/screens/auth/LogoutScreen.tsx +++ b/frontend/src/screens/auth/LogoutScreen.tsx @@ -1,211 +1,212 @@ -import React from 'react'; -import { - StyleSheet, - View, - Image, - Text, - TouchableOpacity, - Alert, -} from 'react-native'; -import {PRIMARY_COLOR} from '../../helper/Theme'; -import {GET_STORAGE_DATA, USER_LOGOUT} from '../../helper/APIUtils'; -import axios, {AxiosError} from 'axios'; -import {resetUserState} from '../../store/UserSlice'; -import {useDispatch, useSelector} from 'react-redux'; -import {clearStorage} from '../../helper/Utils'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import {LogoutScreenProp} from '@/src/type'; -import {useUserLogout} from '@/src/hooks/useUserLogout'; -import {useTheme} from 'tamagui'; +import React from 'react'; +import { + StyleSheet, + View, + Image, + Text, + TouchableOpacity, + Alert, +} from 'react-native'; +import {PRIMARY_COLOR} from '../../helper/Theme'; +import {GET_STORAGE_DATA, USER_LOGOUT} from '../../helper/APIUtils'; +import axios, {AxiosError} from 'axios'; +import {resetUserState} from '../../store/UserSlice'; +import {useDispatch, useSelector} from 'react-redux'; +import {clearStorage} from '../../helper/Utils'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {LogoutScreenProp} from '@/src/type'; +import {useUserLogout} from '@/src/hooks/useUserLogout'; +import {useTheme} from 'tamagui'; import { rf } from '../../helper/Metric'; -const LogoutScreen = ({navigation, route}: LogoutScreenProp) => { - const {profile_image, username} = route.params; - // const {user_token} = useSelector((state: any) => state.user); - const dispatch = useDispatch(); - const theme = useTheme(); - const {mutate: logout} = useUserLogout(); - - const handleLogout = () => { - logout( - {}, - { - onSuccess: async () => { - Alert.alert('Success', 'Logout successfully'); - await clearStorage(); - dispatch(resetUserState()); - navigation.reset({ - index: 0, - routes: [{name: 'LoginScreen'}], - }); - }, - - onError: (err: AxiosError) => { - if (err.response) { - const statusCode = err.response.status; - switch (statusCode) { - case 500: - // Handle internal server errors - Alert.alert( - 'Logout Failed', - 'Internal server error. Please try again later.', - ); - break; - default: - // Handle any other errors - Alert.alert( - 'Logout Failed', - 'Something went wrong. Please try again later.', - ); - } - } else { - // Handle network errors - console.log('General Update Error', err); - Alert.alert( - 'Logout Failed', - 'Network error. Please check your connection.', - ); - } - }, - }, - ); - }; - return ( - - - - - - - - Log out of - {'\n'} - {username} - - - - Are you sure you would like to log out of this account ? - - - - - - Yes, log me out - - - - - { - navigation.goBack(); - }}> - - Cancel - - - - - - - ); -}; - -export default LogoutScreen; - -const styles = StyleSheet.create({ - container: { - padding: 24, - flexGrow: 1, - flexShrink: 1, - flexBasis: 0, - }, - /** Alert */ - alert: { - position: 'relative', - flexDirection: 'column', - alignItems: 'stretch', - flexGrow: 1, - flexShrink: 1, - flexBasis: 0, - paddingTop: 80, - }, - alertContent: { - flexGrow: 1, - flexShrink: 1, - flexBasis: 0, - }, - alertAvatar: { - width: 160, - height: 160, - borderRadius: 9999, - alignSelf: 'center', - marginBottom: 24, - }, - alertTitle: { - marginBottom: 16, - fontSize: 34, - lineHeight: 44, - fontWeight: '700', - - textAlign: 'center', - }, - alertMessage: { - marginBottom: 24, - textAlign: 'center', - fontSize: 16, - lineHeight: 22, - fontWeight: '500', - - }, - /** Button */ - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 8, - paddingVertical: 8, - paddingHorizontal: 16, - borderWidth: 1, - backgroundColor: PRIMARY_COLOR, - borderColor: PRIMARY_COLOR, - }, - btnText: { - fontSize: 17, - lineHeight: 24, - fontWeight: '600', - - }, - btnSecondary: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 8, - paddingVertical: 8, - paddingHorizontal: 16, - borderWidth: 1, - backgroundColor: 'transparent', - borderColor: 'transparent', - }, - btnSecondaryText: { - fontSize: 17, - lineHeight: 24, - fontWeight: '600', - color: PRIMARY_COLOR, - }, -}); + +const LogoutScreen = ({navigation, route}: LogoutScreenProp) => { + const {profile_image, username} = route.params; + // const {user_token} = useSelector((state: any) => state.user); + const dispatch = useDispatch(); + const theme = useTheme(); + const {mutate: logout} = useUserLogout(); + + const handleLogout = () => { + logout( + {}, + { + onSuccess: async () => { + Alert.alert('Success', 'Logout successfully'); + await clearStorage(); + dispatch(resetUserState()); + navigation.reset({ + index: 0, + routes: [{name: 'LoginScreen'}], + }); + }, + + onError: (err: AxiosError) => { + if (err.response) { + const statusCode = err.response.status; + switch (statusCode) { + case 500: + // Handle internal server errors + Alert.alert( + 'Logout Failed', + 'Internal server error. Please try again later.', + ); + break; + default: + // Handle any other errors + Alert.alert( + 'Logout Failed', + 'Something went wrong. Please try again later.', + ); + } + } else { + // Handle network errors + console.log('General Update Error', err); + Alert.alert( + 'Logout Failed', + 'Network error. Please check your connection.', + ); + } + }, + }, + ); + }; + return ( + + + + + + + + Log out of + {'\n'} + {username} + + + + Are you sure you would like to log out of this account ? + + + + + + Yes, log me out + + + + + { + navigation.goBack(); + }}> + + Cancel + + + + + + + ); +}; + +export default LogoutScreen; + +const styles = StyleSheet.create({ + container: { + padding: 24, + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + }, + /** Alert */ + alert: { + position: 'relative', + flexDirection: 'column', + alignItems: 'stretch', + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + paddingTop: 80, + }, + alertContent: { + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + }, + alertAvatar: { + width: 160, + height: 160, + borderRadius: 9999, + alignSelf: 'center', + marginBottom: 24, + }, + alertTitle: { + marginBottom: 16, + fontSize: rf(34), + lineHeight: 44, + fontWeight: '700', + + textAlign: 'center', + }, + alertMessage: { + marginBottom: 24, + textAlign: 'center', + fontSize: rf(16), + lineHeight: 22, + fontWeight: '500', + + }, + /** Button */ + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + paddingVertical: 8, + paddingHorizontal: 16, + borderWidth: 1, + backgroundColor: PRIMARY_COLOR, + borderColor: PRIMARY_COLOR, + }, + btnText: { + fontSize: rf(17), + lineHeight: 24, + fontWeight: '600', + + }, + btnSecondary: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + paddingVertical: 8, + paddingHorizontal: 16, + borderWidth: 1, + backgroundColor: 'transparent', + borderColor: 'transparent', + }, + btnSecondaryText: { + fontSize: rf(17), + lineHeight: 24, + fontWeight: '600', + color: PRIMARY_COLOR, + }, +}); diff --git a/frontend/src/screens/auth/NewPasswordScreen.tsx b/frontend/src/screens/auth/NewPasswordScreen.tsx index 9d515e8b..a52de1ec 100644 --- a/frontend/src/screens/auth/NewPasswordScreen.tsx +++ b/frontend/src/screens/auth/NewPasswordScreen.tsx @@ -1,452 +1,453 @@ -import React, { useState } from 'react'; -import { Alert } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { - Button, - Card, - Circle, - Input, - Paragraph, - Text, - useTheme, - XStack, - YStack -} from 'tamagui'; -import { useForm, Controller } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; +import React, { useState } from 'react'; +import { Alert } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + Button, + Card, + Circle, + Input, + Paragraph, + Text, + useTheme, + XStack, + YStack +} from 'tamagui'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; + +import { ON_PRIMARY_COLOR, PRIMARY_COLOR } from '@/src/helper/Theme'; +import { useChangePasswordMutation } from '@/src/hooks/useChangePassword'; +import AntDesign from '@expo/vector-icons/AntDesign'; +import Entypo from '@expo/vector-icons/Entypo'; +import Icon from '@expo/vector-icons/Ionicons'; +import { AxiosError } from 'axios'; +import Loader from '../../components/Loader'; +import { NewPasswordScreenProp } from '../../type'; import { rf } from '../../helper/Metric'; -import { ON_PRIMARY_COLOR, PRIMARY_COLOR } from '@/src/helper/Theme'; -import { useChangePasswordMutation } from '@/src/hooks/useChangePassword'; -import AntDesign from '@expo/vector-icons/AntDesign'; -import Entypo from '@expo/vector-icons/Entypo'; -import Icon from '@expo/vector-icons/Ionicons'; -import { AxiosError } from 'axios'; -import Loader from '../../components/Loader'; -import { NewPasswordScreenProp } from '../../type'; - -const newPasswordSchema = z.object({ - password: z.string() - .min(6, 'At least 6 characters with lowercase letter') - .regex(/(?=.*[a-z]).{6,}/, 'At least 6 characters with lowercase letter'), - confirmPassword: z.string().min(1, 'Please confirm your password'), -}).refine((data) => data.password === data.confirmPassword, { - message: "Passwords don't match", - path: ['confirmPassword'], -}); -type NewPasswordFormData = z.infer; - -export default function NewPasswordScreen({ - navigation, - route, -}: NewPasswordScreenProp) { - const {email} = route.params; - - const { - control, - handleSubmit, - watch, - formState: { isValid, errors }, - } = useForm({ - resolver: zodResolver(newPasswordSchema), - mode: 'onChange', - defaultValues: { - password: '', - confirmPassword: '', - }, - }); - - const password = watch('password'); - const confirmPassword = watch('confirmPassword'); - const passwordVerify = !errors.password && password.length >= 6; - - const [secureTextEntry, setSecureTextEntry] = useState(true); - const [secureNewTextEntry, setSecureNewTextEntry] = useState(true); - const {mutate: changePassword, isPending} = useChangePasswordMutation(); - - const handleSecureEntryClickEvent = () => { - setSecureTextEntry(!secureTextEntry); - }; - - const theme = useTheme(); - - const handleSecureNewEntryClickEvent = () => { - setSecureNewTextEntry(!secureNewTextEntry); - }; - - const handlePasswordSubmit = (data: NewPasswordFormData) => { - - changePassword( - { - email, - newPassword: password, - }, - { - onSuccess: () => { - Alert.alert('Password reset successfully'); - navigation.navigate('LoginScreen', {}); - }, - - onError: (error: AxiosError) => { - if (error.response) { - switch (error.response.status) { - case 400: - Alert.alert('Error', 'User not found'); - break; - - case 402: - Alert.alert( - 'Error', - 'New password should not be same as old password', - ); - break; - - default: - Alert.alert('Error', 'Something went wrong. Please try again.'); - } - } else { - Alert.alert('Error', 'Something went wrong. Please try again.'); - } - }, - }, - ); - }; - - ); - }; - - const insets = useSafeAreaInsets(); - if (isPending) { - return ; - } - return ( - - - - {/* Icon Circle */} - - - - - {/* Title & Subtitle */} - - Create New Password - - - - Your new password must be different from previously used passwords. - - - {/* Inputs */} - - - - New Password - - - ( - - - - - - )} - /> - - - - - {/* Password Requirements */} - - {password && passwordVerify ? ( - <> - - ✓ - - - Password meets requirements - - - ) : errors.password ? ( - <> - - ✗ - - - {errors.password.message} - - - ) : ( - - At least 6 characters with lowercase letter - - )} - - - - - - Confirm Password - - - ( - - - - - - )} - /> - - - - - {/* Confirmation Status */} - {confirmPassword && ( - - {password === confirmPassword ? ( - <> - - ✓ - - - Passwords match - - - ) : ( - <> - - ✗ - - - {errors.confirmPassword?.message || "Passwords don't match"} - - - )} - - )} - - - - {/* Confirm Button */} - - - - - {/* Return Link */} - - - - - ); -} + +const newPasswordSchema = z.object({ + password: z.string() + .min(6, 'At least 6 characters with lowercase letter') + .regex(/(?=.*[a-z]).{6,}/, 'At least 6 characters with lowercase letter'), + confirmPassword: z.string().min(1, 'Please confirm your password'), +}).refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], +}); +type NewPasswordFormData = z.infer; + +export default function NewPasswordScreen({ + navigation, + route, +}: NewPasswordScreenProp) { + const {email} = route.params; + + const { + control, + handleSubmit, + watch, + formState: { isValid, errors }, + } = useForm({ + resolver: zodResolver(newPasswordSchema), + mode: 'onChange', + defaultValues: { + password: '', + confirmPassword: '', + }, + }); + + const password = watch('password'); + const confirmPassword = watch('confirmPassword'); + const passwordVerify = !errors.password && password.length >= 6; + + const [secureTextEntry, setSecureTextEntry] = useState(true); + const [secureNewTextEntry, setSecureNewTextEntry] = useState(true); + const {mutate: changePassword, isPending} = useChangePasswordMutation(); + + const handleSecureEntryClickEvent = () => { + setSecureTextEntry(!secureTextEntry); + }; + + const theme = useTheme(); + + const handleSecureNewEntryClickEvent = () => { + setSecureNewTextEntry(!secureNewTextEntry); + }; + + const handlePasswordSubmit = (data: NewPasswordFormData) => { + + changePassword( + { + email, + newPassword: password, + }, + { + onSuccess: () => { + Alert.alert('Password reset successfully'); + navigation.navigate('LoginScreen', {}); + }, + + onError: (error: AxiosError) => { + if (error.response) { + switch (error.response.status) { + case 400: + Alert.alert('Error', 'User not found'); + break; + + case 402: + Alert.alert( + 'Error', + 'New password should not be same as old password', + ); + break; + + default: + Alert.alert('Error', 'Something went wrong. Please try again.'); + } + } else { + Alert.alert('Error', 'Something went wrong. Please try again.'); + } + }, + }, + ); + }; + + ); + }; + + const insets = useSafeAreaInsets(); + if (isPending) { + return ; + } + return ( + + + + {/* Icon Circle */} + + + + + {/* Title & Subtitle */} + + Create New Password + + + + Your new password must be different from previously used passwords. + + + {/* Inputs */} + + + + New Password + + + ( + + + + + + )} + /> + + + + + {/* Password Requirements */} + + {password && passwordVerify ? ( + <> + + ✓ + + + Password meets requirements + + + ) : errors.password ? ( + <> + + ✗ + + + {errors.password.message} + + + ) : ( + + At least 6 characters with lowercase letter + + )} + + + + + + Confirm Password + + + ( + + + + + + )} + /> + + + + + {/* Confirmation Status */} + {confirmPassword && ( + + {password === confirmPassword ? ( + <> + + ✓ + + + Passwords match + + + ) : ( + <> + + ✗ + + + {errors.confirmPassword?.message || "Passwords don't match"} + + + )} + + )} + + + + {/* Confirm Button */} + + + + + {/* Return Link */} + + + + + ); +} diff --git a/frontend/src/screens/auth/OtpScreen.tsx b/frontend/src/screens/auth/OtpScreen.tsx index 9ca1b8a8..3ca56409 100644 --- a/frontend/src/screens/auth/OtpScreen.tsx +++ b/frontend/src/screens/auth/OtpScreen.tsx @@ -1,285 +1,286 @@ -import { useSendOtpMutation } from '@/src/hooks/useSendOtp'; -import { useVerifyOtpMutation } from '@/src/hooks/useVerifyOtp'; -import axios, { AxiosError, isAxiosError } from 'axios'; -import React, { useRef, useState } from 'react'; -import { Alert, TextInput } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import {OtpScreenProp} from '../../type'; -import Loader from '../../components/Loader'; -import { - Button, - Card, - Input, - Paragraph, - Text, - Theme, - useTheme, - XStack, - YStack, -} from 'tamagui'; +import { useSendOtpMutation } from '@/src/hooks/useSendOtp'; +import { useVerifyOtpMutation } from '@/src/hooks/useVerifyOtp'; +import axios, { AxiosError, isAxiosError } from 'axios'; +import React, { useRef, useState } from 'react'; +import { Alert, TextInput } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import {OtpScreenProp} from '../../type'; +import Loader from '../../components/Loader'; +import { import { rf } from '../../helper/Metric'; -export default function OtpScreen({navigation, route}: OtpScreenProp) { - const [otp, setOtp] = useState(['', '', '', '']); - const inputs = useRef<(TextInput | null)[]>([]); - const {email} = route.params; - const theme = useTheme(); - const [errorMessages, setErrorMessages] = useState(); - const {mutate: sendOtp, isPending: sendOtpPending} = useSendOtpMutation(); - const {mutate: checkOtp, isPending: checkOtpPending} = useVerifyOtpMutation(); - - const handleSubmit = () => { - //navigation.navigate('NewPasswordScreen'); - const fullCode = otp!.join(''); - if (!fullCode || fullCode.length === 0) { - setErrorMessages(['Please provide otp inputs']); - return; - } else { - setErrorMessages(undefined); - - checkOtp( - { - email: email, - otp: fullCode, - }, - { - onSuccess: () => { - navigation.navigate('NewPasswordScreen', { - email: email, - }); - }, - onError: (error: AxiosError) => { - console.log('OTP ERROR', error); - setErrorMessages(['Invalid or expired otp']); - Alert.alert('Invalid or expired otp'); - }, - }, - ); - } - }; - - const handleChange = (text: string, index: number) => { - setErrorMessages(undefined); - - const newOtp = [...otp]; - newOtp[index] = text; - setOtp(newOtp); - - if (text && index < otp.length - 1) { - inputs.current[index + 1]?.focus(); - } - }; - - const handleKeyPress = (e: any, index: number) => { - if (e.nativeEvent.key === 'Backspace' && !otp[index] && index > 0) { - inputs.current[index - 1]?.focus(); - } - }; - - if (sendOtpPending || checkOtpPending) { - return ; - } - return ( - - - - - - {/* Icon */} - - - 🔐 - - - - - Verify Your Code - - - We've sent a 4-digit verification code to{'\n'} - - {email} - - - - - - {otp.map((digit, index) => ( - { - inputs.current[index] = ref; - }} - value={digit} - onChangeText={text => handleChange(text, index)} - onKeyPress={e => handleKeyPress(e, index)} - keyboardType="numeric" - maxLength={1} - textAlign="center" - fontSize={28} - fontWeight="700" - borderWidth={2} - borderColor={ - errorMessages ? '$red8' : digit ? '$blue9' : '$gray6' - } - focusStyle={{ - borderColor: errorMessages ? '$red9' : '$blue10', - borderWidth: 2.5, - backgroundColor: '$white', - shadowColor: errorMessages ? '$red8' : '$blue8', - shadowRadius: 8, - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.3, - }} - br="$5" - width={60} - height={64} - bg={errorMessages ? '$red1' : digit ? '$blue1' : '$gray1'} - color="$color12" - /> - ))} - - - {errorMessages && ( - - - ⚠️ - - - {errorMessages} - - - )} - - - - - - Didn't receive the code? - - - - - - - - ); -} + Button, + Card, + Input, + Paragraph, + Text, + Theme, + useTheme, + XStack, + YStack, +} from 'tamagui'; + +export default function OtpScreen({navigation, route}: OtpScreenProp) { + const [otp, setOtp] = useState(['', '', '', '']); + const inputs = useRef<(TextInput | null)[]>([]); + const {email} = route.params; + const theme = useTheme(); + const [errorMessages, setErrorMessages] = useState(); + const {mutate: sendOtp, isPending: sendOtpPending} = useSendOtpMutation(); + const {mutate: checkOtp, isPending: checkOtpPending} = useVerifyOtpMutation(); + + const handleSubmit = () => { + //navigation.navigate('NewPasswordScreen'); + const fullCode = otp!.join(''); + if (!fullCode || fullCode.length === 0) { + setErrorMessages(['Please provide otp inputs']); + return; + } else { + setErrorMessages(undefined); + + checkOtp( + { + email: email, + otp: fullCode, + }, + { + onSuccess: () => { + navigation.navigate('NewPasswordScreen', { + email: email, + }); + }, + onError: (error: AxiosError) => { + console.log('OTP ERROR', error); + setErrorMessages(['Invalid or expired otp']); + Alert.alert('Invalid or expired otp'); + }, + }, + ); + } + }; + + const handleChange = (text: string, index: number) => { + setErrorMessages(undefined); + + const newOtp = [...otp]; + newOtp[index] = text; + setOtp(newOtp); + + if (text && index < otp.length - 1) { + inputs.current[index + 1]?.focus(); + } + }; + + const handleKeyPress = (e: any, index: number) => { + if (e.nativeEvent.key === 'Backspace' && !otp[index] && index > 0) { + inputs.current[index - 1]?.focus(); + } + }; + + if (sendOtpPending || checkOtpPending) { + return ; + } + return ( + + + + + + {/* Icon */} + + + 🔐 + + + + + Verify Your Code + + + We've sent a 4-digit verification code to{'\n'} + + {email} + + + + + + {otp.map((digit, index) => ( + { + inputs.current[index] = ref; + }} + value={digit} + onChangeText={text => handleChange(text, index)} + onKeyPress={e => handleKeyPress(e, index)} + keyboardType="numeric" + maxLength={1} + textAlign="center" + fontSize={rf(28)} + fontWeight="700" + borderWidth={2} + borderColor={ + errorMessages ? '$red8' : digit ? '$blue9' : '$gray6' + } + focusStyle={{ + borderColor: errorMessages ? '$red9' : '$blue10', + borderWidth: 2.5, + backgroundColor: '$white', + shadowColor: errorMessages ? '$red8' : '$blue8', + shadowRadius: 8, + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.3, + }} + br="$5" + width={60} + height={64} + bg={errorMessages ? '$red1' : digit ? '$blue1' : '$gray1'} + color="$color12" + /> + ))} + + + {errorMessages && ( + + + ⚠️ + + + {errorMessages} + + + )} + + + + + + Didn't receive the code? + + + + + + + + ); +} diff --git a/frontend/src/screens/auth/SignUpScreenFirst.tsx b/frontend/src/screens/auth/SignUpScreenFirst.tsx index 45c5a0d3..21460991 100644 --- a/frontend/src/screens/auth/SignUpScreenFirst.tsx +++ b/frontend/src/screens/auth/SignUpScreenFirst.tsx @@ -1,594 +1,595 @@ -import React, {useState} from 'react'; -import {Alert} from 'react-native'; -import {ScrollView, YStack, XStack, Text, Input, Button, Image, useTheme} from 'tamagui'; -import Icon from '@expo/vector-icons/MaterialIcons'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import {Dropdown} from 'react-native-element-dropdown'; -import AntDesign from '@expo/vector-icons/AntDesign'; -import { useForm, Controller } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import {SignUpScreenFirstProp, UserDetail} from '../../type'; -import {AxiosError} from 'axios'; -import Snackbar from 'react-native-snackbar'; -import EmailVerifiedModal from '../../components/VerifiedModal'; -import SecurityWarningModal from '../../components/SecurityWarningModal'; -import ImageResizer from '@bam.tech/react-native-image-resizer'; -import Loader from '../../components/Loader'; -import useUploadImage from '../../hooks/useUploadImage'; -import { - ImageLibraryOptions, - launchImageLibrary, - ImagePickerResponse, -} from 'react-native-image-picker'; -import {useCheckUserHandleAvailability} from '@/src/hooks/useCheckUserHandleAvailability'; -import {useVerificationMailMutation} from '@/src/hooks/useMailVerification'; -import {useRegdMutation} from '@/src/hooks/useUserRegistration'; - -const signupSchema = z.object({ - name: z.string().min(1, 'Name is required'), - username: z.string().min(1, 'User Handle is required'), - email: z.string().email('Please enter a valid email'), - password: z.string().min(6, 'Password must be at least 6 characters'), - role: z.string().min(1, 'Please select a role'), -}); -type SignupFormData = z.infer; - -const SignupPageFirst = ({navigation}: SignUpScreenFirstProp) => { - const {uploadImage, loading} = useUploadImage(); - const theme = useTheme(); - const [user_profile_image, setUserProfileImage] = useState(''); - const [verifyBtntext, setVerifyBtntxt] = useState('Request Verification'); - const [verifiedModalVisible, setVerifiedModalVisible] = useState(false); - const [token, setToken] = useState(''); - const [isFocus, setIsFocus] = useState(false); - const [isSecureEntry, setIsSecureEntry] = useState(true); - const [securityWarningVisible, setSecurityWarningVisible] = useState(false); - const [pendingSubmitAction, setPendingSubmitAction] = useState<(() => void) | null>(null); - - const { - control, - handleSubmit, - watch, - formState: { isValid }, - } = useForm({ - resolver: zodResolver(signupSchema), - mode: 'onChange', - defaultValues: { - name: '', - username: '', - email: '', - password: '', - role: '', - }, - }); - - const username = watch('username'); - const userHandle = username?.trim(); - - const {data: handleAvailability, isLoading: isCheckingHandle} = - useCheckUserHandleAvailability(userHandle); - const {mutate: verifyEmailMutation, isPending: verifyEmailPending} = - useVerificationMailMutation(); - - const {mutate: register, isPending: registerPending} = useRegdMutation(); - - const selectImage = async () => { - const options: ImageLibraryOptions = { - mediaType: 'photo', - }; - - launchImageLibrary(options, async (response: ImagePickerResponse) => { - if (response.didCancel) { - // console.log('User cancelled image picker'); - } else if (response.errorMessage) { - console.log('ImagePicker Error: ', response.errorMessage); - } else if (response.assets) { - const {uri, fileSize} = response.assets[0]; - - // Check file size (1 MB limit) - if (fileSize && fileSize > 1024 * 1024) { - Alert.alert('Error', 'File size exceeds 1 MB.'); - return; - } - - if (uri) { - ImageResizer.createResizedImage(uri, 1000, 1000, 'JPEG', 100) - .then(async resizedImageUri => { - setUserProfileImage(resizedImageUri.uri); - }) - .catch(err => { - console.log(err); - Alert.alert('Error', 'Could not resize the image.'); - setUserProfileImage(''); - }); - } - } - }); - }; - - - const handleVerifyModalCallback = () => { - if (token.length > 0) { - verifyEmailMutation( - { - email: email, - token: token, - }, - { - onSuccess: data => { - setVerifyBtntxt(data); - Snackbar.show({ - text: 'Verification email sent!', - duration: Snackbar.LENGTH_LONG, - }); - navigation.navigate('LoginScreen', {}); - }, - - onError: (err: any) => { - if (err.response) { - const statusCode = err.response.status; - switch (statusCode) { - case 400: - Snackbar.show({ - text: err.message, - duration: Snackbar.LENGTH_SHORT, - }); - break; - case 429: - Snackbar.show({ - text: 'Verification email already sent. Please try again after 1 hour.', - duration: Snackbar.LENGTH_SHORT, - }); - break; - case 500: - Snackbar.show({ - text: 'Internal server error. Please try again later.', - duration: Snackbar.LENGTH_SHORT, - }); - break; - default: - Snackbar.show({ - text: 'Something went wrong. Please try again later.', - duration: Snackbar.LENGTH_SHORT, - }); - } - } else { - console.log('Email Verification error', err); - Snackbar.show({ - text: 'An error occured, try again!', - duration: Snackbar.LENGTH_SHORT, - }); - } - }, - }, - ); - } else { - Alert.alert( - 'Failed to authenticate, Token not found', - 'Please try again', - ); - } - }; - - const onSubmit = (data: SignupFormData) => { - if (handleAvailability && !handleAvailability.isAvailable) { - Alert.alert('User handle is not available', 'Please choose a different handle.'); - return; - } - - // Show security warning before proceeding with registration - if (data.role === 'general') { - setPendingSubmitAction(() => () => registerGeneralUser(data)); - } else { - const detail: UserDetail = { - user_name: data.name, - user_handle: data.username.trim(), - email: data.email, - password: data.password, - profile_image: user_profile_image, - }; - setPendingSubmitAction(() => () => { - console.log('General'); - navigation.navigate('SignUpScreenSecond', { - user: detail, - }); - }); - } - setSecurityWarningVisible(true); - }; - - const handleSecurityWarningContinue = () => { - setSecurityWarningVisible(false); - if (pendingSubmitAction) { - pendingSubmitAction(); - setPendingSubmitAction(null); - } - }; - - const handleSecurityWarningCancel = () => { - setSecurityWarningVisible(false); - setPendingSubmitAction(null); - }; - - const callRegisterAPI = (profile_url: string, data: SignupFormData) => { - register( - { - user_name: data.name, - user_handle: data.username.trim(), - email: data.email, - password: data.password, - isDoctor: false, - Profile_image: profile_url, - }, - { - onSuccess: data => { - setToken(data); - setVerifiedModalVisible(true); - }, - - onError: (err: AxiosError) => { - console.log(err.message); - if (err.response) { - const statusCode = err.response.status; - switch (statusCode) { - case 400: - const errorData = err.message; - console.log('Error message', errorData); - Alert.alert('Registration failed', 'Please try again'); - break; - case 409: - Alert.alert( - 'Registration failed', - 'Email or user handle already exists', - ); - break; - case 500: - Alert.alert( - 'Registration failed', - 'Internal server error. Please try again later.', - ); - break; - default: - Alert.alert( - 'Registration failed', - 'Something went wrong. Please try again later.', - ); - } - } else { - console.log('General User Registration Error', err); - Alert.alert('Registration failed', 'Please try again'); - } - }, - }, - ); - }; - - const registerGeneralUser = async (data: SignupFormData) => { - if (user_profile_image === '') { - callRegisterAPI('', data); - } else { - Alert.alert( - '', - 'Are you sure you want to use this image?', - [ - { - text: 'Cancel', - onPress: () => { - // setUserProfileImage(user?.Profile_image || ''); - setUserProfileImage(''); - Snackbar.show({ - text: 'Your profile image will not be uploaded.', - duration: Snackbar.LENGTH_SHORT, - }); - callRegisterAPI('', data); - }, - style: 'cancel', - }, - { - text: 'OK', - onPress: async () => { - try { - // Upload the resized image - const result = await uploadImage(user_profile_image); - - callRegisterAPI(result ?? '', data); - } catch (err) { - console.error('Upload failed'); - Alert.alert('Error', 'Upload failed'); - } - }, - }, - ], - {cancelable: false}, - ); - } - }; - - const data = [ - {label: 'General User', value: 'general'}, - {label: 'Doctor', value: 'doctor'}, - ]; - - if (registerPending || verifyEmailPending || loading) { - return ; - } - - return ( - - - {/* Header */} - - - He who has health has hope and he who has hope has everything. - - - ~ Arabian Proverb. - - - - {/* Profile Image */} - - - - - {/* Title */} - - Sign up - - - {/* Form */} - - {/* Name */} - ( - - - - - - - - {error && {error.message}} - - )} - /> - - {/* User Handle */} - ( - - - - - - - - {error && {error.message}} - - )} - /> - - {/* Handle Availability Feedback */} - {isCheckingHandle && ( - - Checking availability... - - )} - {!isCheckingHandle && handleAvailability && !handleAvailability.isAvailable && ( - - {handleAvailability.message} - - )} - {!isCheckingHandle && handleAvailability?.isAvailable && ( - - {handleAvailability.message} - - )} - - {/* Email */} - ( - - - - - - - - {error && {error.message}} - - )} - /> - - {/* Password */} - ( - - - - - - {error && {error.message}} - - )} - /> - - {/* Role Dropdown */} - ( - - setIsFocus(true)} - onBlur={() => setIsFocus(false)} - onChange={item => { - onChange(item.value); - setIsFocus(false); - }} - /> - {error && {error.message}} - - )} - /> - - {/* Submit Button */} - - - - {/* Security Warning Modal */} - - - {/* Modal */} - { - if (verifyBtntext !== 'Request Verification') { - setVerifiedModalVisible(false); - } - }} - message={verifyBtntext} - /> - - - ); -}; - -export default SignupPageFirst; +import React, {useState} from 'react'; +import {Alert} from 'react-native'; +import {ScrollView, YStack, XStack, Text, Input, Button, Image, useTheme} from 'tamagui'; +import Icon from '@expo/vector-icons/MaterialIcons'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {Dropdown} from 'react-native-element-dropdown'; +import AntDesign from '@expo/vector-icons/AntDesign'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import {SignUpScreenFirstProp, UserDetail} from '../../type'; +import {AxiosError} from 'axios'; +import Snackbar from 'react-native-snackbar'; +import EmailVerifiedModal from '../../components/VerifiedModal'; +import SecurityWarningModal from '../../components/SecurityWarningModal'; +import ImageResizer from '@bam.tech/react-native-image-resizer'; +import Loader from '../../components/Loader'; +import useUploadImage from '../../hooks/useUploadImage'; +import { + ImageLibraryOptions, + launchImageLibrary, + ImagePickerResponse, +} from 'react-native-image-picker'; +import {useCheckUserHandleAvailability} from '@/src/hooks/useCheckUserHandleAvailability'; +import {useVerificationMailMutation} from '@/src/hooks/useMailVerification'; +import {useRegdMutation} from '@/src/hooks/useUserRegistration'; import { rf } from '../../helper/Metric'; + + +const signupSchema = z.object({ + name: z.string().min(1, 'Name is required'), + username: z.string().min(1, 'User Handle is required'), + email: z.string().email('Please enter a valid email'), + password: z.string().min(6, 'Password must be at least 6 characters'), + role: z.string().min(1, 'Please select a role'), +}); +type SignupFormData = z.infer; + +const SignupPageFirst = ({navigation}: SignUpScreenFirstProp) => { + const {uploadImage, loading} = useUploadImage(); + const theme = useTheme(); + const [user_profile_image, setUserProfileImage] = useState(''); + const [verifyBtntext, setVerifyBtntxt] = useState('Request Verification'); + const [verifiedModalVisible, setVerifiedModalVisible] = useState(false); + const [token, setToken] = useState(''); + const [isFocus, setIsFocus] = useState(false); + const [isSecureEntry, setIsSecureEntry] = useState(true); + const [securityWarningVisible, setSecurityWarningVisible] = useState(false); + const [pendingSubmitAction, setPendingSubmitAction] = useState<(() => void) | null>(null); + + const { + control, + handleSubmit, + watch, + formState: { isValid }, + } = useForm({ + resolver: zodResolver(signupSchema), + mode: 'onChange', + defaultValues: { + name: '', + username: '', + email: '', + password: '', + role: '', + }, + }); + + const username = watch('username'); + const userHandle = username?.trim(); + + const {data: handleAvailability, isLoading: isCheckingHandle} = + useCheckUserHandleAvailability(userHandle); + const {mutate: verifyEmailMutation, isPending: verifyEmailPending} = + useVerificationMailMutation(); + + const {mutate: register, isPending: registerPending} = useRegdMutation(); + + const selectImage = async () => { + const options: ImageLibraryOptions = { + mediaType: 'photo', + }; + + launchImageLibrary(options, async (response: ImagePickerResponse) => { + if (response.didCancel) { + // console.log('User cancelled image picker'); + } else if (response.errorMessage) { + console.log('ImagePicker Error: ', response.errorMessage); + } else if (response.assets) { + const {uri, fileSize} = response.assets[0]; + + // Check file size (1 MB limit) + if (fileSize && fileSize > 1024 * 1024) { + Alert.alert('Error', 'File size exceeds 1 MB.'); + return; + } + + if (uri) { + ImageResizer.createResizedImage(uri, 1000, 1000, 'JPEG', 100) + .then(async resizedImageUri => { + setUserProfileImage(resizedImageUri.uri); + }) + .catch(err => { + console.log(err); + Alert.alert('Error', 'Could not resize the image.'); + setUserProfileImage(''); + }); + } + } + }); + }; + + + const handleVerifyModalCallback = () => { + if (token.length > 0) { + verifyEmailMutation( + { + email: email, + token: token, + }, + { + onSuccess: data => { + setVerifyBtntxt(data); + Snackbar.show({ + text: 'Verification email sent!', + duration: Snackbar.LENGTH_LONG, + }); + navigation.navigate('LoginScreen', {}); + }, + + onError: (err: any) => { + if (err.response) { + const statusCode = err.response.status; + switch (statusCode) { + case 400: + Snackbar.show({ + text: err.message, + duration: Snackbar.LENGTH_SHORT, + }); + break; + case 429: + Snackbar.show({ + text: 'Verification email already sent. Please try again after 1 hour.', + duration: Snackbar.LENGTH_SHORT, + }); + break; + case 500: + Snackbar.show({ + text: 'Internal server error. Please try again later.', + duration: Snackbar.LENGTH_SHORT, + }); + break; + default: + Snackbar.show({ + text: 'Something went wrong. Please try again later.', + duration: Snackbar.LENGTH_SHORT, + }); + } + } else { + console.log('Email Verification error', err); + Snackbar.show({ + text: 'An error occured, try again!', + duration: Snackbar.LENGTH_SHORT, + }); + } + }, + }, + ); + } else { + Alert.alert( + 'Failed to authenticate, Token not found', + 'Please try again', + ); + } + }; + + const onSubmit = (data: SignupFormData) => { + if (handleAvailability && !handleAvailability.isAvailable) { + Alert.alert('User handle is not available', 'Please choose a different handle.'); + return; + } + + // Show security warning before proceeding with registration + if (data.role === 'general') { + setPendingSubmitAction(() => () => registerGeneralUser(data)); + } else { + const detail: UserDetail = { + user_name: data.name, + user_handle: data.username.trim(), + email: data.email, + password: data.password, + profile_image: user_profile_image, + }; + setPendingSubmitAction(() => () => { + console.log('General'); + navigation.navigate('SignUpScreenSecond', { + user: detail, + }); + }); + } + setSecurityWarningVisible(true); + }; + + const handleSecurityWarningContinue = () => { + setSecurityWarningVisible(false); + if (pendingSubmitAction) { + pendingSubmitAction(); + setPendingSubmitAction(null); + } + }; + + const handleSecurityWarningCancel = () => { + setSecurityWarningVisible(false); + setPendingSubmitAction(null); + }; + + const callRegisterAPI = (profile_url: string, data: SignupFormData) => { + register( + { + user_name: data.name, + user_handle: data.username.trim(), + email: data.email, + password: data.password, + isDoctor: false, + Profile_image: profile_url, + }, + { + onSuccess: data => { + setToken(data); + setVerifiedModalVisible(true); + }, + + onError: (err: AxiosError) => { + console.log(err.message); + if (err.response) { + const statusCode = err.response.status; + switch (statusCode) { + case 400: + const errorData = err.message; + console.log('Error message', errorData); + Alert.alert('Registration failed', 'Please try again'); + break; + case 409: + Alert.alert( + 'Registration failed', + 'Email or user handle already exists', + ); + break; + case 500: + Alert.alert( + 'Registration failed', + 'Internal server error. Please try again later.', + ); + break; + default: + Alert.alert( + 'Registration failed', + 'Something went wrong. Please try again later.', + ); + } + } else { + console.log('General User Registration Error', err); + Alert.alert('Registration failed', 'Please try again'); + } + }, + }, + ); + }; + + const registerGeneralUser = async (data: SignupFormData) => { + if (user_profile_image === '') { + callRegisterAPI('', data); + } else { + Alert.alert( + '', + 'Are you sure you want to use this image?', + [ + { + text: 'Cancel', + onPress: () => { + // setUserProfileImage(user?.Profile_image || ''); + setUserProfileImage(''); + Snackbar.show({ + text: 'Your profile image will not be uploaded.', + duration: Snackbar.LENGTH_SHORT, + }); + callRegisterAPI('', data); + }, + style: 'cancel', + }, + { + text: 'OK', + onPress: async () => { + try { + // Upload the resized image + const result = await uploadImage(user_profile_image); + + callRegisterAPI(result ?? '', data); + } catch (err) { + console.error('Upload failed'); + Alert.alert('Error', 'Upload failed'); + } + }, + }, + ], + {cancelable: false}, + ); + } + }; + + const data = [ + {label: 'General User', value: 'general'}, + {label: 'Doctor', value: 'doctor'}, + ]; + + if (registerPending || verifyEmailPending || loading) { + return ; + } + + return ( + + + {/* Header */} + + + He who has health has hope and he who has hope has everything. + + + ~ Arabian Proverb. + + + + {/* Profile Image */} + + + + + {/* Title */} + + Sign up + + + {/* Form */} + + {/* Name */} + ( + + + + + + + + {error && {error.message}} + + )} + /> + + {/* User Handle */} + ( + + + + + + + + {error && {error.message}} + + )} + /> + + {/* Handle Availability Feedback */} + {isCheckingHandle && ( + + Checking availability... + + )} + {!isCheckingHandle && handleAvailability && !handleAvailability.isAvailable && ( + + {handleAvailability.message} + + )} + {!isCheckingHandle && handleAvailability?.isAvailable && ( + + {handleAvailability.message} + + )} + + {/* Email */} + ( + + + + + + + + {error && {error.message}} + + )} + /> + + {/* Password */} + ( + + + + + + {error && {error.message}} + + )} + /> + + {/* Role Dropdown */} + ( + + setIsFocus(true)} + onBlur={() => setIsFocus(false)} + onChange={item => { + onChange(item.value); + setIsFocus(false); + }} + /> + {error && {error.message}} + + )} + /> + + {/* Submit Button */} + + + + {/* Security Warning Modal */} + + + {/* Modal */} + { + if (verifyBtntext !== 'Request Verification') { + setVerifiedModalVisible(false); + } + }} + message={verifyBtntext} + /> + + + ); +}; + +export default SignupPageFirst; diff --git a/frontend/src/screens/auth/SignUpScreenSecond.tsx b/frontend/src/screens/auth/SignUpScreenSecond.tsx index 44d7d50d..daa60efa 100644 --- a/frontend/src/screens/auth/SignUpScreenSecond.tsx +++ b/frontend/src/screens/auth/SignUpScreenSecond.tsx @@ -1,432 +1,433 @@ -import React, {useState} from 'react'; -import {Alert} from 'react-native'; +import React, {useState} from 'react'; +import {Alert} from 'react-native'; + +import {ScrollView, YStack, XStack, Text, Input, Button, Image, useTheme} from 'tamagui'; +import Icon from '@expo/vector-icons/MaterialIcons'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import {Contactdetail, SignUpScreenSecondProp} from '../../type'; +import {AxiosError} from 'axios'; +import EmailVerifiedModal from '../../components/VerifiedModal'; +import SecurityWarningModal from '../../components/SecurityWarningModal'; +import Loader from '../../components/Loader'; +import Snackbar from 'react-native-snackbar'; +import useUploadImage from '../../hooks/useUploadImage'; +import {SafeAreaView} from 'react-native-safe-area-context'; +import {useVerificationMailMutation} from '@/src/hooks/useMailVerification'; +import {useRegdMutation} from '@/src/hooks/useUserRegistration'; import { rf } from '../../helper/Metric'; -import {ScrollView, YStack, XStack, Text, Input, Button, Image, useTheme} from 'tamagui'; -import Icon from '@expo/vector-icons/MaterialIcons'; -import { useForm, Controller } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import {Contactdetail, SignUpScreenSecondProp} from '../../type'; -import {AxiosError} from 'axios'; -import EmailVerifiedModal from '../../components/VerifiedModal'; -import SecurityWarningModal from '../../components/SecurityWarningModal'; -import Loader from '../../components/Loader'; -import Snackbar from 'react-native-snackbar'; -import useUploadImage from '../../hooks/useUploadImage'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import {useVerificationMailMutation} from '@/src/hooks/useMailVerification'; -import {useRegdMutation} from '@/src/hooks/useUserRegistration'; -let validator = require('email-validator'); -const signupSecondSchema = z.object({ - specialization: z.string().min(1, 'Specialization is required'), - education: z.string().min(1, 'Educational Qualification is required'), - experience: z.string().min(1, 'Years of Experience is required'), - businessEmail: z.string().email('Please enter a valid email'), - phone: z.string().min(10, 'Phone number must be at least 10 characters'), -}); -type SignupSecondFormData = z.infer; - -const SignupPageSecond = ({navigation, route}: SignUpScreenSecondProp) => { - const {user} = route.params; - const {uploadImage, loading} = useUploadImage(); - const [token, setToken] = useState(''); - const [verifyBtntext, setVerifyBtntxt] = useState('Request Verification'); - const [verifiedModalVisible, setVerifiedModalVisible] = useState(false); - const [securityWarningVisible, setSecurityWarningVisible] = useState(false); - const [pendingSubmitData, setPendingSubmitData] = useState<{ - contactDetail: Contactdetail; - data: SignupSecondFormData; - } | null>(null); - - const { - control, - handleSubmit, - formState: { isValid }, - } = useForm({ - resolver: zodResolver(signupSecondSchema), - mode: 'onChange', - defaultValues: { - specialization: '', - education: '', - experience: '', - businessEmail: '', - phone: '', - }, - }); - const {mutate: verifyEmailMutation, isPending: verifyMutationPending} = - useVerificationMailMutation(); - const {mutate: register, isPending: registerPending} = useRegdMutation(); - const theme = useTheme(); - const callRegisterAPI = ( - profile_url: string, - contactDetail: Contactdetail, - data: SignupSecondFormData, - ) => { - register( - { - user_name: user.user_name, - user_handle: user.user_handle, - email: user.email, - password: user.password, - isDoctor: true, - specialization: data.specialization, - qualification: data.education, - Years_of_experience: data.experience, - Profile_image: profile_url, - contact_detail: contactDetail, - }, - { - onSuccess: data => { - setToken(data); - setVerifiedModalVisible(true); - }, - - onError: (err: AxiosError) => { - if (err.response) { - const statusCode = err.response.status; - switch (statusCode) { - case 400: - const errorData = err.message; - console.log('Error message', errorData); - Alert.alert('Registration failed', 'Please try again'); - break; - case 409: { - Alert.alert( - 'Registration failed', - 'Email or user handle already exists', - ); - break; - } - - case 500: { - Alert.alert( - 'Registration failed', - 'Internal server error. Please try again later.', - ); - break; - } - - default: { - Alert.alert( - 'Registration failed', - 'Something went wrong. Please try again later.', - ); - } - } - } else { - console.log('General User Registration Error', err); - Alert.alert('Registration failed', 'Please try again'); - } - }, - }, - ); - }; - - const handleVerifyModalCallback = () => { - if (token.length > 0) { - verifyEmailMutation( - { - email: user.email, - token, - }, - { - onSuccess: data => { - setVerifyBtntxt(data); - Snackbar.show({ - text: 'Verification email sent!', - duration: Snackbar.LENGTH_LONG, - }); - navigation.navigate('LoginScreen', {}); - }, - - onError: (error: AxiosError) => { - if (error.response) { - const statusCode = error.response.status; - switch (statusCode) { - case 400: - Snackbar.show({ - text: error.message, - duration: Snackbar.LENGTH_SHORT, - }); - break; - case 429: - Snackbar.show({ - text: 'Verification email already sent. Please try again after 1 hour.', - duration: Snackbar.LENGTH_SHORT, - }); - break; - case 500: - Snackbar.show({ - text: 'Internal server error. Please try again later.', - duration: Snackbar.LENGTH_SHORT, - }); - break; - default: - Snackbar.show({ - text: 'Something went wrong. Please try again later.', - duration: Snackbar.LENGTH_SHORT, - }); - } - } else { - Snackbar.show({ - text: 'An error occured, try again!', - duration: Snackbar.LENGTH_SHORT, - }); - } - }, - }, - ); - } else { - Alert.alert( - 'Failed to authenticate, Token not found', - 'Please try again', - ); - } - }; - - const onSubmit = (data: SignupSecondFormData) => { - let contactDetail: Contactdetail = { - email_id: - data.businessEmail && data.businessEmail !== '' ? data.businessEmail : user.email, - phone_no: data.phone, - }; - - // Show security warning before proceeding with registration - setPendingSubmitData({ contactDetail, data }); - setSecurityWarningVisible(true); - }; - - const handleSecurityWarningContinue = () => { - setSecurityWarningVisible(false); - if (pendingSubmitData) { - registerDoctor(pendingSubmitData.contactDetail, pendingSubmitData.data); - setPendingSubmitData(null); - } - }; - - const handleSecurityWarningCancel = () => { - setSecurityWarningVisible(false); - setPendingSubmitData(null); - }; - - const registerDoctor = async (contactDetail: Contactdetail, data: SignupSecondFormData) => { - if (!user.profile_image || user.profile_image === '') { - callRegisterAPI('', contactDetail, data); - } else { - Alert.alert( - '', - 'Are you sure you want to use this image?', - [ - { - text: 'Cancel', - onPress: () => { - // setUserProfileImage(user?.Profile_image || ''); - //setUserProfileImage(''); - Snackbar.show({ - text: 'Your profile image will not be uploaded.', - duration: Snackbar.LENGTH_SHORT, - }); - callRegisterAPI('', contactDetail, data); - }, - style: 'cancel', - }, - { - text: 'OK', - onPress: async () => { - try { - // Upload the resized image - let result; - if (user.profile_image && user.profile_image !== '') { - result = await uploadImage(user.profile_image); - } - - callRegisterAPI(result ?? "", contactDetail, data); - - } catch (err) { - console.error('Registration failed', err); - Alert.alert('Error Occured', 'Registration failed'); - } - }, - }, - ], - {cancelable: false}, - ); - } - }; - - if (registerPending || verifyMutationPending || loading) { - return ; - } - return ( - - - {/* Header Quote */} - - - “Let me congratulate you on the choice of calling which offers a - combination of intellectual and moral interest found in no other - profession.” - - - ~ Sir William Osler - - - - {/* Form Section */} - - {/* Profile Image */} - {user.profile_image && ( - - - - )} - - {/* Input Fields */} - {[ - { - placeholder: 'What is your Specialization?', - name: 'specialization', - icon: 'business', - }, - { - placeholder: 'Educational Qualification', - name: 'education', - icon: 'school', - }, - { - placeholder: 'Years of Experience', - name: 'experience', - icon: 'numbers', - keyboardType: 'numeric', - maxLength: 3, - }, - { - placeholder: 'Professional Email', - name: 'businessEmail', - icon: 'email', - keyboardType: 'email-address', - }, - { - placeholder: 'Phone number with country code', - name: 'phone', - icon: 'phone', - keyboardType: 'phone-pad', - maxLength: 14, - }, - ].map((field, index) => ( - ( - - - - - - - - {error && {error.message}} - - )} - /> - ))} - - {/* Submit Button */} - - - {/* Security Warning Modal */} - - - {/* Modal */} - { - if (verifyBtntext !== 'Request Verification') { - setVerifiedModalVisible(false); - } - }} - message={verifyBtntext} - /> - - - - ); -}; - -export default SignupPageSecond; +let validator = require('email-validator'); +const signupSecondSchema = z.object({ + specialization: z.string().min(1, 'Specialization is required'), + education: z.string().min(1, 'Educational Qualification is required'), + experience: z.string().min(1, 'Years of Experience is required'), + businessEmail: z.string().email('Please enter a valid email'), + phone: z.string().min(10, 'Phone number must be at least 10 characters'), +}); +type SignupSecondFormData = z.infer; + +const SignupPageSecond = ({navigation, route}: SignUpScreenSecondProp) => { + const {user} = route.params; + const {uploadImage, loading} = useUploadImage(); + const [token, setToken] = useState(''); + const [verifyBtntext, setVerifyBtntxt] = useState('Request Verification'); + const [verifiedModalVisible, setVerifiedModalVisible] = useState(false); + const [securityWarningVisible, setSecurityWarningVisible] = useState(false); + const [pendingSubmitData, setPendingSubmitData] = useState<{ + contactDetail: Contactdetail; + data: SignupSecondFormData; + } | null>(null); + + const { + control, + handleSubmit, + formState: { isValid }, + } = useForm({ + resolver: zodResolver(signupSecondSchema), + mode: 'onChange', + defaultValues: { + specialization: '', + education: '', + experience: '', + businessEmail: '', + phone: '', + }, + }); + const {mutate: verifyEmailMutation, isPending: verifyMutationPending} = + useVerificationMailMutation(); + const {mutate: register, isPending: registerPending} = useRegdMutation(); + const theme = useTheme(); + const callRegisterAPI = ( + profile_url: string, + contactDetail: Contactdetail, + data: SignupSecondFormData, + ) => { + register( + { + user_name: user.user_name, + user_handle: user.user_handle, + email: user.email, + password: user.password, + isDoctor: true, + specialization: data.specialization, + qualification: data.education, + Years_of_experience: data.experience, + Profile_image: profile_url, + contact_detail: contactDetail, + }, + { + onSuccess: data => { + setToken(data); + setVerifiedModalVisible(true); + }, + + onError: (err: AxiosError) => { + if (err.response) { + const statusCode = err.response.status; + switch (statusCode) { + case 400: + const errorData = err.message; + console.log('Error message', errorData); + Alert.alert('Registration failed', 'Please try again'); + break; + case 409: { + Alert.alert( + 'Registration failed', + 'Email or user handle already exists', + ); + break; + } + + case 500: { + Alert.alert( + 'Registration failed', + 'Internal server error. Please try again later.', + ); + break; + } + + default: { + Alert.alert( + 'Registration failed', + 'Something went wrong. Please try again later.', + ); + } + } + } else { + console.log('General User Registration Error', err); + Alert.alert('Registration failed', 'Please try again'); + } + }, + }, + ); + }; + + const handleVerifyModalCallback = () => { + if (token.length > 0) { + verifyEmailMutation( + { + email: user.email, + token, + }, + { + onSuccess: data => { + setVerifyBtntxt(data); + Snackbar.show({ + text: 'Verification email sent!', + duration: Snackbar.LENGTH_LONG, + }); + navigation.navigate('LoginScreen', {}); + }, + + onError: (error: AxiosError) => { + if (error.response) { + const statusCode = error.response.status; + switch (statusCode) { + case 400: + Snackbar.show({ + text: error.message, + duration: Snackbar.LENGTH_SHORT, + }); + break; + case 429: + Snackbar.show({ + text: 'Verification email already sent. Please try again after 1 hour.', + duration: Snackbar.LENGTH_SHORT, + }); + break; + case 500: + Snackbar.show({ + text: 'Internal server error. Please try again later.', + duration: Snackbar.LENGTH_SHORT, + }); + break; + default: + Snackbar.show({ + text: 'Something went wrong. Please try again later.', + duration: Snackbar.LENGTH_SHORT, + }); + } + } else { + Snackbar.show({ + text: 'An error occured, try again!', + duration: Snackbar.LENGTH_SHORT, + }); + } + }, + }, + ); + } else { + Alert.alert( + 'Failed to authenticate, Token not found', + 'Please try again', + ); + } + }; + + const onSubmit = (data: SignupSecondFormData) => { + let contactDetail: Contactdetail = { + email_id: + data.businessEmail && data.businessEmail !== '' ? data.businessEmail : user.email, + phone_no: data.phone, + }; + + // Show security warning before proceeding with registration + setPendingSubmitData({ contactDetail, data }); + setSecurityWarningVisible(true); + }; + + const handleSecurityWarningContinue = () => { + setSecurityWarningVisible(false); + if (pendingSubmitData) { + registerDoctor(pendingSubmitData.contactDetail, pendingSubmitData.data); + setPendingSubmitData(null); + } + }; + + const handleSecurityWarningCancel = () => { + setSecurityWarningVisible(false); + setPendingSubmitData(null); + }; + + const registerDoctor = async (contactDetail: Contactdetail, data: SignupSecondFormData) => { + if (!user.profile_image || user.profile_image === '') { + callRegisterAPI('', contactDetail, data); + } else { + Alert.alert( + '', + 'Are you sure you want to use this image?', + [ + { + text: 'Cancel', + onPress: () => { + // setUserProfileImage(user?.Profile_image || ''); + //setUserProfileImage(''); + Snackbar.show({ + text: 'Your profile image will not be uploaded.', + duration: Snackbar.LENGTH_SHORT, + }); + callRegisterAPI('', contactDetail, data); + }, + style: 'cancel', + }, + { + text: 'OK', + onPress: async () => { + try { + // Upload the resized image + let result; + if (user.profile_image && user.profile_image !== '') { + result = await uploadImage(user.profile_image); + } + + callRegisterAPI(result ?? "", contactDetail, data); + + } catch (err) { + console.error('Registration failed', err); + Alert.alert('Error Occured', 'Registration failed'); + } + }, + }, + ], + {cancelable: false}, + ); + } + }; + + if (registerPending || verifyMutationPending || loading) { + return ; + } + return ( + + + {/* Header Quote */} + + + “Let me congratulate you on the choice of calling which offers a + combination of intellectual and moral interest found in no other + profession.” + + + ~ Sir William Osler + + + + {/* Form Section */} + + {/* Profile Image */} + {user.profile_image && ( + + + + )} + + {/* Input Fields */} + {[ + { + placeholder: 'What is your Specialization?', + name: 'specialization', + icon: 'business', + }, + { + placeholder: 'Educational Qualification', + name: 'education', + icon: 'school', + }, + { + placeholder: 'Years of Experience', + name: 'experience', + icon: 'numbers', + keyboardType: 'numeric', + maxLength: 3, + }, + { + placeholder: 'Professional Email', + name: 'businessEmail', + icon: 'email', + keyboardType: 'email-address', + }, + { + placeholder: 'Phone number with country code', + name: 'phone', + icon: 'phone', + keyboardType: 'phone-pad', + maxLength: 14, + }, + ].map((field, index) => ( + ( + + + + + + + + {error && {error.message}} + + )} + /> + ))} + + {/* Submit Button */} + + + {/* Security Warning Modal */} + + + {/* Modal */} + { + if (verifyBtntext !== 'Request Verification') { + setVerifiedModalVisible(false); + } + }} + message={verifyBtntext} + /> + + + + ); +}; + +export default SignupPageSecond; diff --git a/frontend/src/screens/overview/ArticleWorkSpace.tsx b/frontend/src/screens/overview/ArticleWorkSpace.tsx index 87aa7b4d..7840350e 100644 --- a/frontend/src/screens/overview/ArticleWorkSpace.tsx +++ b/frontend/src/screens/overview/ArticleWorkSpace.tsx @@ -1,194 +1,195 @@ -import {useCallback, useEffect, useState} from 'react'; -import { - View, - StyleSheet, - TouchableOpacity, - Text, - FlatList, -} from 'react-native'; -import {ArticleData} from '../../type'; -import {useSelector} from 'react-redux'; -import ReviewCard from '../../components/ReviewCard'; -import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../../helper/Theme'; -import {hp, wp} from '../../helper/Metric'; -import Loader from '../../components/Loader'; -import {useGetAllArticlesForUser} from '@/src/hooks/useGetUserAllArticles'; -import {NoArticleState} from '../../components/EmptyStates'; +import {useCallback, useEffect, useState} from 'react'; +import { + View, + StyleSheet, + TouchableOpacity, + Text, + FlatList, +} from 'react-native'; +import {ArticleData} from '../../type'; +import {useSelector} from 'react-redux'; +import ReviewCard from '../../components/ReviewCard'; +import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../../helper/Theme'; +import {hp, wp} from '../../helper/Metric'; +import Loader from '../../components/Loader'; +import {useGetAllArticlesForUser} from '@/src/hooks/useGetUserAllArticles'; +import {NoArticleState} from '../../components/EmptyStates'; import { rf } from '../../helper/Metric'; -export default function ArticleWorkSpace({ - handleClickAction, -}: { - handleClickAction: (item: ArticleData) => void; -}) { - useSelector((state: any) => state.user); - const [refreshing, setRefreshing] = useState(false); - const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(0); - const [selectedStatus, setSelectedStatus] = useState(1); - const [visit, setVisit] = useState(1); - const [publishedLabel, setPublishedLabel] = useState('Published'); - const [progressLabel, setProgressLabel] = useState('Progress'); - const [discardLabel, setDiscardLabel] = useState('Discard'); - const [articleData, setArticleData] = useState([]); - - const [pageLoading, setPageLoading] = useState(false); - - const {isLoading, refetch} = useGetAllArticlesForUser({ - page, - selectedStatus, - visit, - setVisit, - setTotalPages, - setArticleData, - setPublishedLabel, - setProgressLabel, - setDiscardLabel, - }); - - useEffect(() => { - refetch(); - setPageLoading(false); - }, [page, refetch, selectedStatus]); - - const [selectedCardId, setSelectedCardId] = useState(''); - - const categories = [ - { - label: publishedLabel, - status: 1, - }, - { - label: progressLabel, - status: 2, - }, - { - label: discardLabel, - status: 3, - }, - ]; - - const onRefresh = () => { - setRefreshing(true); - setPage(1); - refetch(); - setRefreshing(false); - }; - - const renderItem = useCallback( - ({item}: {item: ArticleData}) => { - return ( - - ); - }, - [handleClickAction, selectedCardId], - ); - - return ( - - - - {categories.map((item, index) => ( - { - setSelectedStatus(item.status); - setPage(1); - setArticleData([]); - setPageLoading(true); - }}> - - {item.status === 1 - ? publishedLabel - : item.status === 2 - ? progressLabel - : discardLabel} - - - ))} - - - {isLoading || pageLoading ? ( - - ) : ( - - item._id.toString()} - contentContainerStyle={styles.flatListContentContainer} - refreshing={refreshing} - onRefresh={onRefresh} - ListEmptyComponent={} - onEndReached={() => { - if (page < totalPages) { - setPage(prev => prev + 1); - } - }} - onEndReachedThreshold={0.5} - /> - - )} - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - marginTop: hp(10), - }, - buttonContainer: { - flexDirection: 'row', - paddingHorizontal: wp(3), - gap: wp(2), - marginBottom: hp(1), - }, - button: { - flex: 1, - borderRadius: 12, - paddingVertical: hp(1.8), - paddingHorizontal: wp(3), - borderWidth: 2, - justifyContent: 'center', - alignItems: 'center', - shadowColor: PRIMARY_COLOR, - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, - }, - labelStyle: { - fontWeight: '700', - fontSize: 13, - textTransform: 'capitalize', - letterSpacing: 0.3, - }, - articleContainer: { - flex: 1, - width: '100%', - paddingHorizontal: 0, - }, - flatListContentContainer: { - paddingHorizontal: wp(4), - paddingTop: hp(1), - backgroundColor: ON_PRIMARY_COLOR, - paddingBottom: hp(2), - }, -}); + +export default function ArticleWorkSpace({ + handleClickAction, +}: { + handleClickAction: (item: ArticleData) => void; +}) { + useSelector((state: any) => state.user); + const [refreshing, setRefreshing] = useState(false); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [selectedStatus, setSelectedStatus] = useState(1); + const [visit, setVisit] = useState(1); + const [publishedLabel, setPublishedLabel] = useState('Published'); + const [progressLabel, setProgressLabel] = useState('Progress'); + const [discardLabel, setDiscardLabel] = useState('Discard'); + const [articleData, setArticleData] = useState([]); + + const [pageLoading, setPageLoading] = useState(false); + + const {isLoading, refetch} = useGetAllArticlesForUser({ + page, + selectedStatus, + visit, + setVisit, + setTotalPages, + setArticleData, + setPublishedLabel, + setProgressLabel, + setDiscardLabel, + }); + + useEffect(() => { + refetch(); + setPageLoading(false); + }, [page, refetch, selectedStatus]); + + const [selectedCardId, setSelectedCardId] = useState(''); + + const categories = [ + { + label: publishedLabel, + status: 1, + }, + { + label: progressLabel, + status: 2, + }, + { + label: discardLabel, + status: 3, + }, + ]; + + const onRefresh = () => { + setRefreshing(true); + setPage(1); + refetch(); + setRefreshing(false); + }; + + const renderItem = useCallback( + ({item}: {item: ArticleData}) => { + return ( + + ); + }, + [handleClickAction, selectedCardId], + ); + + return ( + + + + {categories.map((item, index) => ( + { + setSelectedStatus(item.status); + setPage(1); + setArticleData([]); + setPageLoading(true); + }}> + + {item.status === 1 + ? publishedLabel + : item.status === 2 + ? progressLabel + : discardLabel} + + + ))} + + + {isLoading || pageLoading ? ( + + ) : ( + + item._id.toString()} + contentContainerStyle={styles.flatListContentContainer} + refreshing={refreshing} + onRefresh={onRefresh} + ListEmptyComponent={} + onEndReached={() => { + if (page < totalPages) { + setPage(prev => prev + 1); + } + }} + onEndReachedThreshold={0.5} + /> + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + marginTop: hp(10), + }, + buttonContainer: { + flexDirection: 'row', + paddingHorizontal: wp(3), + gap: wp(2), + marginBottom: hp(1), + }, + button: { + flex: 1, + borderRadius: 12, + paddingVertical: hp(1.8), + paddingHorizontal: wp(3), + borderWidth: 2, + justifyContent: 'center', + alignItems: 'center', + shadowColor: PRIMARY_COLOR, + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + labelStyle: { + fontWeight: '700', + fontSize: rf(13), + textTransform: 'capitalize', + letterSpacing: 0.3, + }, + articleContainer: { + flex: 1, + width: '100%', + paddingHorizontal: 0, + }, + flatListContentContainer: { + paddingHorizontal: wp(4), + paddingTop: hp(1), + backgroundColor: ON_PRIMARY_COLOR, + paddingBottom: hp(2), + }, +}); diff --git a/frontend/src/screens/overview/ImprovementReviewScreen.tsx b/frontend/src/screens/overview/ImprovementReviewScreen.tsx index 4bd6c67f..5fdd2dd9 100644 --- a/frontend/src/screens/overview/ImprovementReviewScreen.tsx +++ b/frontend/src/screens/overview/ImprovementReviewScreen.tsx @@ -1,690 +1,691 @@ -import { - Image, - Platform, - StyleSheet, - TouchableOpacity, - View, - ScrollView, - FlatList, - Dimensions, -} from 'react-native'; -import {useEffect, useRef, useState} from 'react'; -import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../../helper/Theme'; -import FontAwesome5 from '@expo/vector-icons/FontAwesome5'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; -import {ImpvReviewScreenProp, Comment} from '../../type'; - -import {useDispatch, useSelector} from 'react-redux'; -import {hp, wp} from '../../helper/Metric'; -import {GET_STORAGE_DATA} from '../../helper/APIUtils'; - -//import io from 'socket.io-client'; - -import {useSocket} from '../../contexts/SocketContext'; -//import CommentScreen from '../CommentScreen'; -import {setUserHandle} from '../../store/UserSlice'; -import {handleExternalClick, StatusEnum} from '../../helper/Utils'; -import ReviewItem from '../../components/ReviewItem'; -import {Button, Spinner, TextArea, YStack, Text} from 'tamagui'; -import AutoHeightWebView from '@brown-bear/react-native-autoheight-webview'; -import {useGetImprovementById} from '@/src/hooks/useGetImprovementById'; -import {useGetImprovementContent} from '@/src/hooks/useGetImprovementContent'; -import {useGetProfile} from '@/src/hooks/useGetProfile'; -import {useGetLoadReviewComments} from '@/src/hooks/useGetLoadReviewComments'; - -const ImprovementReviewScreen = ({navigation, route}: ImpvReviewScreenProp) => { - const insets = useSafeAreaInsets(); - const {requestId, authorId, recordId, articleRecordId} = route.params; // requestId - const {user_token, user_handle} = useSelector((state: any) => state.user); - const {isConnected} = useSelector((state: any) => state.network); - - const [feedback, setFeedback] = useState(''); - const [webviewHeight, setWebViewHeight] = useState(0); - - const [loading, setLoading] = useState(false); - - const socket = useSocket(); - const dispatch = useDispatch(); - - const [comments, setComments] = useState([]); - - const flatListRef = useRef>(null); - - const {data: user} = useGetProfile(); - const {data: improvement} = useGetImprovementById(requestId); - const {data: htmlContent} = useGetImprovementContent({ - recordId: recordId, - articleRecordId: articleRecordId, - }); - - const {data: loadComments, isLoading} = useGetLoadReviewComments( - undefined, - requestId, - isConnected, - ); - - useEffect(() => { - setComments(loadComments ?? []); - }, [loadComments]); - - const noDataHtml = '

No Data found

'; - - if (user) { - dispatch(setUserHandle(user.user_handle)); - } - - useEffect(() => { - if (!socket) return; - - // socket.emit('load-review-comments', {requestId: route.params.requestId}); - - socket.on('connect', () => { - console.log('connection established'); - }); - - socket.on('error', data => { - console.log('connection error', data); - }); - - socket.on('review-comments', data => { - // console.log('comment loaded', data); - // setComments(data); - }); - - // Listen for new comments - socket.on('new-feedback', data => { - console.log('new comment loaded', data); - setFeedback(''); - // if (data.articleId === route.params.articleId) { - setComments(prevComments => { - const newComments = [data, ...prevComments]; - // Scroll to the first index after adding the new comment - if (flatListRef.current && newComments.length > 1) { - flatListRef?.current.scrollToIndex({index: 0, animated: true}); - } - - return newComments; - }); - //} - }); - - return () => { - if (socket) { - socket.off('review-comments'); - socket.off('new-feedback'); - socket.off('error'); - } - }; - }, [socket, route.params.requestId]); - - useEffect(() => { - if (htmlContent) { - setWebViewHeight(htmlContent.length); - } else { - setWebViewHeight(noDataHtml.length); - } - }, [htmlContent]); - - // console.log('author id', authorId); - - return ( - - - - {improvement && - improvement.article && - improvement.article?.imageUtils && - improvement.article?.imageUtils.length > 0 ? ( - - ) : ( - - )} - - {improvement?.status !== StatusEnum.DISCARDED && ( - { - if (improvement && improvement.article) { - navigation.navigate('EditorScreen', { - title: improvement?.article.title, - description: improvement?.article.description, - selectedGenres: improvement?.article.tags, - imageUtils: improvement?.article.imageUtils[0], - articleData: improvement?.article, - requestId: improvement?._id, - pb_record_id: improvement?.pb_recordId, - authorName: user_handle, - htmlContent: htmlContent ? htmlContent : noDataHtml, - language: improvement?.article.language, - }); - } - }} - style={[ - styles.likeButton, - { - backgroundColor: 'white', - }, - ]}> - - - )} - - - {improvement?.article && improvement?.article?.tags && ( - - {improvement?.article.tags.map(tag => tag.name).join(' | ')} - - )} - - {improvement?.article && ( - <> - - {improvement?.article?.title} - - - )} - - {/* */} - - console.log(size.height)} - files={[ - { - href: 'cssfileaddress', - type: 'text/css', - rel: 'stylesheet', - }, - ]} - originWhitelist={['*']} - source={{html: htmlContent ?? noDataHtml}} - scalesPageToFit={true} - viewportContent={'width=device-width, user-scalable=no'} - onShouldStartLoadWithRequest={handleExternalClick} - /> - - - {improvement?.status !== StatusEnum.DISCARDED && ( - // - // ( - // - // ), - // [actions.alignLeft]: ({tintColor}) => ( - // - // ), - // [actions.alignCenter]: ({tintColor}) => ( - // - // ), - // [actions.alignRight]: ({tintColor}) => ( - // - // ), - // [actions.undo]: ({tintColor}) => ( - // - // ), - // [actions.redo]: ({tintColor}) => ( - // - // ), - // [actions.heading1]: ({tintColor}) => ( - // H1 - // ), - // [actions.heading2]: ({tintColor}) => ( - // H2 - // ), - // [actions.heading3]: ({tintColor}) => ( - // H3 - // ), - // [actions.heading4]: ({tintColor}) => ( - // H4 - // ), - // [actions.heading5]: ({tintColor}) => ( - // H5 - // ), - // [actions.heading6]: ({tintColor}) => ( - // H6 - // ), - // [actions.blockquote]: ({tintColor}) => ( - // - // ), - // }} - // /> - // setFeedback(text)} - // editorInitializedCallback={editorInitializedCallback} - // onHeightChange={handleHeightChange} - // initialHeight={300} - // /> - - // {feedback.length > 0 && ( - // { - // // emit socket event for feedback - // const ans = createFeebackHTMLStructure(feedback); - // socket.emit('add-review-comment', { - // requestId: improvement?._id, - // reviewer_id: improvement?.reviewer_id, - // feedback: ans, - // isReview: false, - // isNote: true, - // }); - // }}> - // Post - // - // )} - // - - - - 💬 Add a Comment - -