feat: implement full caregiver application feature set including services, availability, profile, and navigation modules

This commit is contained in:
2026-09-02 18:30:45 +05:30
parent 8f2cdfff2f
commit cf82850d1f
28 changed files with 1592 additions and 471 deletions
+3
View File
@@ -8,6 +8,7 @@
import React from 'react';
import { AppNavigator } from './src/navigation/AppNavigator';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { SnackbarProvider } from './src/context/SnackbarContext';
import { LogBox, StatusBar } from 'react-native';
// LogBox.ignoreAllLogs(true);
@@ -16,7 +17,9 @@ function App(): React.JSX.Element {
return (
<SafeAreaProvider>
<StatusBar barStyle="dark-content" backgroundColor="#808080" translucent={false} />
<SnackbarProvider>
<AppNavigator />
</SnackbarProvider>
</SafeAreaProvider>
);
}
+4 -4
View File
@@ -57,7 +57,7 @@ export const Button: React.FC<ButtonProps> = ({
{...props}
>
{loading ? (
<ActivityIndicator color={variant === 'outline' || variant === 'ghost' ? '#4F46E5' : '#FFFFFF'} />
<ActivityIndicator color={variant === 'outline' || variant === 'ghost' ? '#1E3A8A' : '#FFFFFF'} />
) : (
<Typography weight="medium" style={getTextStyle()}>
{title}
@@ -80,14 +80,14 @@ const styles = StyleSheet.create({
backgroundColor: '#D1D5DB',
},
bgPrimary: {
backgroundColor: '#4F46E5',
backgroundColor: '#1E3A8A',
},
bgSecondary: {
backgroundColor: '#10B981',
},
bgOutline: {
backgroundColor: 'transparent',
borderColor: '#4F46E5',
borderColor: '#1E3A8A',
borderWidth: 2,
},
bgGhost: {
@@ -100,6 +100,6 @@ const styles = StyleSheet.create({
color: '#FFFFFF',
},
textPrimary: {
color: '#4F46E5',
color: '#1E3A8A',
},
});
+153
View File
@@ -0,0 +1,153 @@
import React from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
} from 'react-native';
import { ChevronDown } from 'lucide-react-native';
import { Typography } from './Typography';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../../navigation/AppNavigator';
export interface DropdownItem {
label: string;
value: string | number;
}
interface DropdownProps {
data: DropdownItem[];
value?: string | number | (string | number)[];
onChange: (value: any) => void;
placeholder?: string;
searchPlaceholder?: string;
isMulti?: boolean;
searchable?: boolean;
label?: string;
error?: string;
disabled?: boolean;
}
export const Dropdown: React.FC<DropdownProps> = ({
data,
value,
onChange,
placeholder = 'Select an option',
searchPlaceholder = 'Search...',
isMulti = false,
searchable = true,
label,
error,
disabled = false,
}) => {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const handlePress = () => {
if (disabled) return;
navigation.navigate('Selection', {
data,
value,
isMulti,
searchable,
label,
searchPlaceholder,
onSelect: onChange,
});
};
const renderSelectedItemText = () => {
if (isMulti) {
const currentValue = (Array.isArray(value) ? value : []) as (string | number)[];
if (currentValue.length === 0) return placeholder;
const selectedItems = data.filter((item) => currentValue.includes(item.value));
return selectedItems.map((item) => item.label).join(', ');
} else {
if (value == null || value === '') return placeholder;
const selectedItem = data.find((item) => item.value === value);
return selectedItem ? selectedItem.label : placeholder;
}
};
return (
<View style={styles.container}>
{label && (
<Typography variant="caption" weight="bold" style={styles.label}>
{label}
</Typography>
)}
<TouchableOpacity
style={[
styles.dropdownButton,
error && styles.dropdownButtonError,
disabled && styles.dropdownButtonDisabled,
]}
onPress={handlePress}
activeOpacity={0.7}
>
<Text
style={[
styles.dropdownButtonText,
(!value || (Array.isArray(value) && value.length === 0)) && styles.placeholderText,
]}
numberOfLines={1}
>
{renderSelectedItemText()}
</Text>
<ChevronDown color="#6B7280" size={20} />
</TouchableOpacity>
{error ? (
<Typography variant="caption" style={styles.errorText}>
{error}
</Typography>
) : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 16,
width: '100%',
},
label: {
marginBottom: 8,
color: '#374151',
},
dropdownButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: '#D1D5DB',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
minHeight: 52,
},
dropdownButtonError: {
borderColor: '#EF4444',
},
dropdownButtonDisabled: {
backgroundColor: '#F3F4F6',
borderColor: '#E5E7EB',
},
dropdownButtonText: {
flex: 1,
fontSize: 16,
color: '#111827',
marginRight: 8,
},
placeholderText: {
color: '#9CA3AF',
},
errorText: {
color: '#EF4444',
marginTop: 4,
},
});
-2
View File
@@ -62,8 +62,6 @@ const styles = StyleSheet.create({
marginRight: 12,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.2)',
alignItems: 'center',
justifyContent: 'center',
},
+137
View File
@@ -0,0 +1,137 @@
import React, { createContext, useContext, useState, useRef, ReactNode, useEffect } from 'react';
import { View, Text, Animated, StyleSheet, TouchableOpacity } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { THEME } from '../constants/theme';
import { CheckCircle2, AlertCircle, X } from 'lucide-react-native';
type SnackbarType = 'success' | 'error';
interface SnackbarContextProps {
showSnackbar: (message: string, type?: SnackbarType) => void;
}
const SnackbarContext = createContext<SnackbarContextProps | undefined>(undefined);
export const useSnackbar = () => {
const context = useContext(SnackbarContext);
if (!context) {
throw new Error('useSnackbar must be used within a SnackbarProvider');
}
return context;
};
export const SnackbarProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [visible, setVisible] = useState(false);
const [message, setMessage] = useState('');
const [type, setType] = useState<SnackbarType>('success');
const slideAnim = useRef(new Animated.Value(100)).current;
const insets = useSafeAreaInsets();
const timerRef = useRef<NodeJS.Timeout | null>(null);
const showSnackbar = (msg: string, msgType: SnackbarType = 'success') => {
setMessage(msg);
setType(msgType);
setVisible(true);
// Slide up
Animated.spring(slideAnim, {
toValue: 0,
useNativeDriver: true,
speed: 12,
bounciness: 8,
}).start();
// Auto hide
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
hideSnackbar();
}, 3000);
};
const hideSnackbar = () => {
Animated.timing(slideAnim, {
toValue: 150,
duration: 250,
useNativeDriver: true,
}).start(() => {
setVisible(false);
});
};
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return (
<SnackbarContext.Provider value={{ showSnackbar }}>
{children}
{visible && (
<Animated.View
style={[
styles.container,
{
bottom: Math.max(insets.bottom + 16, 16),
transform: [{ translateY: slideAnim }],
},
type === 'success' ? styles.successContainer : styles.errorContainer
]}
>
<View style={styles.content}>
{type === 'success' ? (
<CheckCircle2 color="#FFF" size={24} />
) : (
<AlertCircle color="#FFF" size={24} />
)}
<Text style={styles.messageText}>{message}</Text>
</View>
<TouchableOpacity onPress={hideSnackbar} style={styles.closeBtn}>
<X color="#FFF" size={20} />
</TouchableOpacity>
</Animated.View>
)}
</SnackbarContext.Provider>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
left: 16,
right: 16,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
zIndex: 9999,
},
successContainer: {
backgroundColor: '#059669', // A nice green for success
},
errorContainer: {
backgroundColor: '#EF4444', // Red for errors
},
content: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
messageText: {
color: '#FFF',
fontSize: 16,
fontWeight: '500',
marginLeft: 12,
marginRight: 16,
flexShrink: 1,
},
closeBtn: {
padding: 4,
}
});
+33 -6
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Home, Calendar, Settings } from 'lucide-react-native';
import { HomeScreen } from '../screens/HomeScreen';
@@ -15,13 +16,28 @@ import { AddSkillScreen } from '../screens/AddSkillScreen';
import { AvailabilityListScreen } from '../screens/AvailabilityListScreen';
import { AddAvailabilityScreen } from '../screens/AddAvailabilityScreen';
import { EditAvailabilityScreen } from '../screens/EditAvailabilityScreen';
import { PaymentProfileScreen } from '../screens/PaymentProfileScreen';
import { PaymentProfileListScreen } from '../screens/PaymentProfileListScreen';
import { AddEditPaymentProfileScreen } from '../screens/AddEditPaymentProfileScreen';
import { ManageServicesScreen } from '../screens/ManageServicesScreen';
import { AddEditServiceScreen } from '../screens/AddEditServiceScreen';
import { ProfessionalDocumentsScreen } from '../screens/ProfessionalDocumentsScreen';
import { UploadDocumentScreen } from '../screens/UploadDocumentScreen';
import { ViewDocumentScreen } from '../screens/ViewDocumentScreen';
import { SelectionScreen } from '../screens/SelectionScreen';
import { Service } from '../services/serviceService';
import { navigationRef } from './navigationRef';
export type RootStackParamList = {
Login: undefined;
Selection: {
data: any[];
value: any;
isMulti?: boolean;
searchable?: boolean;
label?: string;
searchPlaceholder?: string;
onSelect: (value: any) => void;
};
MainTabs: undefined;
Profile: { caregiverId: string };
PersonalInformation: undefined;
@@ -34,9 +50,13 @@ export type RootStackParamList = {
start_time: string;
end_time: string;
};
PaymentProfile: undefined;
PaymentProfileList: undefined;
AddEditPaymentProfile: { profile?: any };
ManageServices: undefined;
AddEditService: { service?: Service };
ProfessionalDocuments: undefined;
UploadDocument: undefined;
ViewDocument: { documentId: number };
};
export type TabParamList = {
@@ -49,6 +69,8 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<TabParamList>();
function MainTabs() {
const insets = useSafeAreaInsets();
return (
<Tab.Navigator
id={undefined}
@@ -59,8 +81,8 @@ function MainTabs() {
borderTopWidth: 0,
elevation: 0,
shadowOpacity: 0,
height: 60,
paddingBottom: 8,
height: 60 + insets.bottom,
paddingBottom: 8 + insets.bottom,
paddingTop: 8,
},
tabBarActiveTintColor: 'navy', // Primary color
@@ -106,7 +128,7 @@ const MyTheme = {
export const AppNavigator = () => {
return (
<NavigationContainer theme={MyTheme}>
<NavigationContainer ref={navigationRef} theme={MyTheme}>
<Stack.Navigator
id={undefined}
initialRouteName="Login"
@@ -124,9 +146,14 @@ export const AppNavigator = () => {
<Stack.Screen name="AvailabilityList" component={AvailabilityListScreen} />
<Stack.Screen name="AddAvailability" component={AddAvailabilityScreen} />
<Stack.Screen name="EditAvailability" component={EditAvailabilityScreen} />
<Stack.Screen name="PaymentProfile" component={PaymentProfileScreen} />
<Stack.Screen name="PaymentProfileList" component={PaymentProfileListScreen} />
<Stack.Screen name="AddEditPaymentProfile" component={AddEditPaymentProfileScreen} />
<Stack.Screen name="ManageServices" component={ManageServicesScreen} />
<Stack.Screen name="AddEditService" component={AddEditServiceScreen} />
<Stack.Screen name="ProfessionalDocuments" component={ProfessionalDocumentsScreen} />
<Stack.Screen name="UploadDocument" component={UploadDocumentScreen} />
<Stack.Screen name="ViewDocument" component={ViewDocumentScreen} />
<Stack.Screen name="Selection" component={SelectionScreen} options={{ presentation: 'card' }} />
</Stack.Navigator>
</NavigationContainer>
);
+10
View File
@@ -0,0 +1,10 @@
import { createNavigationContainerRef } from '@react-navigation/native';
import { RootStackParamList } from './AppNavigator';
export const navigationRef = createNavigationContainerRef<RootStackParamList>();
export function navigate(name: keyof RootStackParamList, params?: any) {
if (navigationRef.isReady()) {
navigationRef.navigate(name, params);
}
}
+9 -6
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { View, StyleSheet, TouchableOpacity, Alert, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { Typography } from '../components/ui/Typography';
@@ -8,6 +8,7 @@ import { Header } from '../components/ui/Header';
import { profileService } from '../services/profileService';
import { THEME } from '../constants/theme';
import { Clock, Calendar, Check } from 'lucide-react-native';
import { useSnackbar } from '../context/SnackbarContext';
const DAYS_OF_WEEK = [
{ label: 'Monday', value: 'monday' },
@@ -21,6 +22,8 @@ const DAYS_OF_WEEK = [
export const AddAvailabilityScreen = () => {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { showSnackbar } = useSnackbar();
const [selectedDay, setSelectedDay] = useState('monday');
const [showDayPicker, setShowDayPicker] = useState(false);
@@ -72,9 +75,8 @@ export const AddAvailabilityScreen = () => {
// @ts-ignore
await profileService.addDayAvailability(payload);
Alert.alert('Success', 'Availability added successfully.', [
{ text: 'OK', onPress: () => navigation.goBack() }
]);
showSnackbar('Availability added successfully.');
navigation.goBack();
} catch (e: any) {
Alert.alert('Error', e.response?.data?.message || 'Failed to add availability.');
} finally {
@@ -153,7 +155,7 @@ export const AddAvailabilityScreen = () => {
</View>
</View>
<View style={styles.footer}>
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom + 16, 16) }]}>
<TouchableOpacity
style={styles.saveButton}
onPress={handleSave}
@@ -253,7 +255,8 @@ const styles = StyleSheet.create({
width: 16,
},
footer: {
padding: 16,
paddingHorizontal: 16,
paddingTop: 16,
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#E5E7EB',
@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { View, StyleSheet, ScrollView, Alert, KeyboardAvoidingView, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RouteProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
import { Header } from '../components/ui/Header';
import { Input } from '../components/ui/Input';
@@ -10,56 +11,32 @@ import { Typography } from '../components/ui/Typography';
import { profileService, PaymentProfile } from '../services/profileService';
import { THEME } from '../constants/theme';
import { CreditCard } from 'lucide-react-native';
import { useSnackbar } from '../context/SnackbarContext';
interface PaymentProfileScreenProps {
navigation: NativeStackNavigationProp<RootStackParamList, 'PaymentProfile'>;
interface AddEditPaymentProfileScreenProps {
navigation: NativeStackNavigationProp<RootStackParamList, 'AddEditPaymentProfile'>;
route: RouteProp<RootStackParamList, 'AddEditPaymentProfile'>;
}
export const PaymentProfileScreen: React.FC<PaymentProfileScreenProps> = ({ navigation }) => {
const [isLoading, setIsLoading] = useState(true);
export const AddEditPaymentProfileScreen: React.FC<AddEditPaymentProfileScreenProps> = ({ navigation, route }) => {
const { profile } = route.params || {};
const isEditing = !!profile;
const [isSaving, setIsSaving] = useState(false);
const [existingId, setExistingId] = useState<number | undefined>(undefined);
const insets = useSafeAreaInsets();
const { showSnackbar } = useSnackbar();
const [form, setForm] = useState<Partial<PaymentProfile>>({
payment_method: 'bank',
account_holder_name: '',
account_number: '',
ifsc_code: '',
bank_name: '',
branch_name: '',
account_type: 'savings',
payment_method: profile?.payment_method || 'bank',
account_holder_name: profile?.account_holder_name || '',
account_number: profile?.account_number || '',
ifsc_code: profile?.ifsc_code || '',
bank_name: profile?.bank_name || '',
branch_name: profile?.branch_name || '',
account_type: profile?.account_type || 'savings',
});
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
fetchPaymentProfile();
}, []);
const fetchPaymentProfile = async () => {
try {
setIsLoading(true);
const response = await profileService.getPaymentProfile();
if (response.success && response.data && response.data.id) {
setExistingId(response.data.id);
setForm({
payment_method: response.data.payment_method || 'bank',
account_holder_name: response.data.account_holder_name || '',
account_number: response.data.account_number || '',
ifsc_code: response.data.ifsc_code || '',
bank_name: response.data.bank_name || '',
branch_name: response.data.branch_name || '',
account_type: response.data.account_type || 'savings',
});
}
} catch (error: any) {
// If 404 or not found, it means no profile exists yet.
console.log('No existing payment profile found or error fetching.');
} finally {
setIsLoading(false);
}
};
const handleChange = (field: keyof PaymentProfile, value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
@@ -88,15 +65,13 @@ export const PaymentProfileScreen: React.FC<PaymentProfileScreenProps> = ({ navi
setIsSaving(true);
const payload = form as PaymentProfile;
if (existingId) {
if (isEditing && profile?.id) {
payload.id = profile.id;
await profileService.updatePaymentProfile(payload);
Alert.alert('Success', 'Payment profile updated successfully.');
showSnackbar('Payment profile updated successfully.');
} else {
const response = await profileService.addPaymentProfile(payload);
if (response.data && response.data.id) {
setExistingId(response.data.id);
}
Alert.alert('Success', 'Payment profile added successfully.');
await profileService.addPaymentProfile(payload);
showSnackbar('Payment profile added successfully.');
}
navigation.goBack();
} catch (error: any) {
@@ -110,19 +85,13 @@ export const PaymentProfileScreen: React.FC<PaymentProfileScreenProps> = ({ navi
return (
// @ts-ignore - TS types for SafeAreaView in this version incorrectly omit the style prop
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container as any}>
<Header title="Payment Profile" showBack onBack={() => navigation.goBack()} />
<Header title={isEditing ? "Edit Bank Account" : "Add Bank Account"} showBack onBack={() => navigation.goBack()} />
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
{isLoading ? (
<View style={styles.centerContainer}>
<Typography>Loading...</Typography>
</View>
) : (
<>
<ScrollView style={styles.content} contentContainerStyle={[styles.scrollContent, { paddingBottom: Math.max(insets.bottom + 40, 40) }]}>
<View style={styles.iconContainer}>
<View style={styles.iconWrapper}>
<CreditCard color={THEME.colors.primary} size={32} />
@@ -178,13 +147,11 @@ export const PaymentProfileScreen: React.FC<PaymentProfileScreenProps> = ({ navi
</View>
<Button
title={existingId ? "Update Profile" : "Save Profile"}
title={isEditing ? "Update Profile" : "Save Profile"}
onPress={handleSave}
loading={isSaving}
style={styles.saveButton}
/>
</>
)}
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
@@ -201,7 +168,6 @@ const styles = StyleSheet.create({
},
scrollContent: {
padding: 24,
paddingBottom: 40,
},
centerContainer: {
flex: 1,
+85 -319
View File
@@ -8,18 +8,11 @@ import { Header } from '../components/ui/Header';
import { Input } from '../components/ui/Input';
import { Button } from '../components/ui/Button';
import { THEME } from '../constants/theme';
import { serviceService, Service } from '../services/serviceService';
import { serviceService, Service, AddServicePayload } from '../services/serviceService';
import { profileService } from '../services/profileService';
import { Typography } from '../components/ui/Typography';
import { ChevronDown, Check } from 'lucide-react-native';
const UNIT_OPTIONS = [
'Visit',
'Hour',
'Day',
'Week',
'Month'
];
import { Dropdown } from '../components/ui/Dropdown';
import { useSnackbar } from '../context/SnackbarContext';
interface AddEditServiceScreenProps {
navigation: NativeStackNavigationProp<RootStackParamList, 'AddEditService'>;
@@ -29,75 +22,57 @@ interface AddEditServiceScreenProps {
export const AddEditServiceScreen: React.FC<AddEditServiceScreenProps> = ({ navigation, route }) => {
const existingService = route.params?.service;
const isEditing = !!existingService;
const { showSnackbar } = useSnackbar();
const [form, setForm] = useState<Partial<Service>>({
name: existingService?.name || '',
description: existingService?.description || '',
care_type_slug: existingService?.care_type_slug || '',
care_sub_type_slug: existingService?.care_sub_type_slug || '',
category: existingService?.category || '',
unit: existingService?.unit || 'Visit',
customer_price: existingService?.customer_price?.toString() || '',
caregiver_rate: existingService?.caregiver_rate?.toString() || '',
tax: existingService?.tax?.toString() || '0',
duration_minutes: existingService?.duration_minutes?.toString() || '',
const [form, setForm] = useState<Partial<AddServicePayload>>({
service_id: existingService?.service_id || existingService?.id,
custom_rate: existingService?.custom_rate?.toString() || existingService?.effective_rate?.toString() || '',
notes: existingService?.notes || existingService?.description || '',
is_active: existingService?.is_active ?? true,
caregiver_id: existingService?.caregiver_id || undefined
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
const [careTypes, setCareTypes] = useState<any[]>([]);
const [careSubTypes, setCareSubTypes] = useState<any[]>([]);
const [taxes, setTaxes] = useState<any[]>([]);
const [showCareTypePicker, setShowCareTypePicker] = useState(false);
const [showCareSubTypePicker, setShowCareSubTypePicker] = useState(false);
const [showUnitPicker, setShowUnitPicker] = useState(false);
const [showTaxPicker, setShowTaxPicker] = useState(false);
const [availableServices, setAvailableServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchCareTypes();
fetchInitialData();
}, []);
useEffect(() => {
if (form.care_type_slug) {
fetchCareSubTypes(form.care_type_slug);
}
}, [form.care_type_slug]);
const fetchCareTypes = async () => {
const fetchInitialData = async () => {
try {
const [ownSkillsResponse, data, taxesData] = await Promise.all([
profileService.getOwnSkills(),
profileService.getCareTypes(),
serviceService.getTaxes()
setLoading(true);
const [profileResponse, servicesResponse] = await Promise.all([
profileService.getProfile(),
serviceService.getSkillBasedServices()
]);
setCareTypes(data);
setTaxes(taxesData);
const profileId = profileResponse?.data?.profile?.id || profileResponse?.data?.id;
if (profileId && !form.caregiver_id) {
const numericProfileId = typeof profileId === 'string' ? parseInt(profileId, 10) : profileId;
setForm(prev => ({ ...prev, caregiver_id: numericProfileId }));
}
setAvailableServices(servicesResponse || []);
} catch (error) {
console.error('Error fetching master types', error);
console.error('Error fetching data for service form', error);
// Fallback in case endpoint 500s or fails (so user can still see UI)
if (availableServices.length === 0) {
setAvailableServices([
{ id: 1, name: 'Wound Care Dressing' },
{ id: 2, name: 'IV Care' },
{ id: 3, name: 'Post-op Recovery' },
]);
}
} finally {
setLoading(false);
}
};
const fetchCareSubTypes = async (slug: string) => {
try {
const [ownSkillsResponse, data] = await Promise.all([
profileService.getOwnSkills(),
profileService.getCareSubTypes(slug)
]);
const ownSkills = ownSkillsResponse || [];
const mySkillSlugs = ownSkills.map((s: any) => s.slug);
const allowedSubTypes = data.filter((st: any) => mySkillSlugs.includes(st.slug));
setCareSubTypes(allowedSubTypes.length > 0 ? allowedSubTypes : data);
} catch (error) {
console.error('Error fetching care sub types', error);
}
};
const handleChange = (field: keyof Service, value: string | number) => {
const handleChange = (field: keyof AddServicePayload, value: string | number | boolean) => {
setForm(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
@@ -105,15 +80,14 @@ export const AddEditServiceScreen: React.FC<AddEditServiceScreenProps> = ({ navi
};
const toggleIsActive = () => {
setForm(prev => ({ ...prev, is_active: !prev.is_active }));
handleChange('is_active', !form.is_active);
};
const validate = () => {
const newErrors: Record<string, string> = {};
if (!form.name?.trim()) newErrors.name = 'Service name is required';
if (!form.description?.trim()) newErrors.description = 'Description is required';
if (!form.customer_price) newErrors.customer_price = 'Customer Price is required';
if (!form.caregiver_rate) newErrors.caregiver_rate = 'Caregiver Rate is required';
if (!form.service_id) newErrors.service_id = 'Please select a service';
if (!form.custom_rate || parseFloat(form.custom_rate as string) <= 0) newErrors.custom_rate = 'Valid rate is required';
if (!form.notes?.trim()) newErrors.notes = 'Notes are required';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
@@ -126,20 +100,21 @@ export const AddEditServiceScreen: React.FC<AddEditServiceScreenProps> = ({ navi
try {
setIsSaving(true);
const payload = {
...form,
customer_price: parseFloat(form.customer_price as string),
caregiver_rate: parseFloat(form.caregiver_rate as string),
tax: form.tax ? parseFloat(form.tax as string) : 0,
duration_minutes: form.duration_minutes ? parseInt(form.duration_minutes as string, 10) : undefined,
} as Service;
if (isEditing && existingService.id) {
const payload: AddServicePayload = {
service_id: form.service_id!,
caregiver_id: form.caregiver_id || 1, // Fallback if undefined
custom_rate: parseFloat(form.custom_rate as string),
is_active: form.is_active!,
notes: form.notes!,
};
if (isEditing && existingService?.id) {
await serviceService.updateService(existingService.id, payload);
Alert.alert('Success', 'Service updated successfully.');
showSnackbar('Service updated successfully.');
} else {
await serviceService.addService(payload);
Alert.alert('Success', 'Service added successfully.');
showSnackbar('Service added successfully.');
}
navigation.goBack();
} catch (error: any) {
@@ -160,210 +135,44 @@ export const AddEditServiceScreen: React.FC<AddEditServiceScreenProps> = ({ navi
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
<ScrollView style={styles.content} contentContainerStyle={[styles.scrollContent, { paddingBottom: Math.max(insets.bottom + 40, 40) }]}>
<Typography variant="body" style={styles.helperText}>
{isEditing ? 'Update the details of your service below.' : 'Add a new service that you offer to clients.'}
{isEditing ? 'Update your service details below.' : 'Add a new service matching your skills.'}
</Typography>
<View style={styles.formContainer}>
<Input
label="Service Name *"
placeholder="e.g. Elderly Care, Physiotherapy"
value={form.name}
onChangeText={(val) => handleChange('name', val)}
error={errors.name}
<Dropdown
label="Service *"
placeholder={loading ? "Loading services..." : "Select a Service"}
data={availableServices.map(s => ({ label: s.name || s.service_name || `Service #${s.id}`, value: s.id! }))}
value={form.service_id}
onChange={(val) => handleChange('service_id', val)}
disabled={loading || isEditing}
/>
{errors.service_id && (
<Typography variant="caption" style={styles.errorText}>{errors.service_id}</Typography>
)}
<Input
label="Description *"
placeholder="Describe what the service includes..."
label="Custom Rate ($) *"
placeholder="e.g. 600"
keyboardType="numeric"
value={form.custom_rate as string}
onChangeText={(val) => handleChange('custom_rate', val)}
error={errors.custom_rate}
/>
<Input
label="Notes *"
placeholder="e.g. Experienced in post-op surgical dressing & IV care"
multiline
numberOfLines={4}
value={form.description}
onChangeText={(val) => handleChange('description', val)}
error={errors.description}
value={form.notes}
onChangeText={(val) => handleChange('notes', val)}
error={errors.notes}
style={styles.textArea}
/>
<View style={styles.dropdownContainer}>
<Typography variant="body" weight="medium" style={styles.dropdownLabel}>Care Type</Typography>
<TouchableOpacity
style={styles.dropdownBox}
onPress={() => setShowCareTypePicker(!showCareTypePicker)}
>
<Typography variant="body" style={styles.dropdownText}>
{careTypes.find(c => c.slug === form.care_type_slug)?.name || form.care_type_slug || 'Select Care Type'}
</Typography>
<ChevronDown color="#6B7280" size={20} />
</TouchableOpacity>
{showCareTypePicker && (
<View style={styles.dropdownList}>
{careTypes.map(type => (
<TouchableOpacity
key={type.slug}
style={[styles.dropdownOption, form.care_type_slug === type.slug && styles.dropdownOptionSelected]}
onPress={() => {
handleChange('care_type_slug', type.slug);
handleChange('category', type.name);
handleChange('care_sub_type_slug', '');
setShowCareTypePicker(false);
}}
>
<Typography variant="body" style={form.care_type_slug === type.slug ? styles.dropdownOptionTextSelected : styles.dropdownOptionText}>
{type.name}
</Typography>
{form.care_type_slug === type.slug && <Check color={THEME.colors.primary} size={16} />}
</TouchableOpacity>
))}
</View>
)}
</View>
<View style={styles.dropdownContainer}>
<Typography variant="body" weight="medium" style={styles.dropdownLabel}>Care Sub Type</Typography>
<TouchableOpacity
style={styles.dropdownBox}
onPress={() => setShowCareSubTypePicker(!showCareSubTypePicker)}
>
<Typography variant="body" style={styles.dropdownText}>
{careSubTypes.find(c => c.slug === form.care_sub_type_slug)?.name || form.care_sub_type_slug || 'Select Care Sub Type'}
</Typography>
<ChevronDown color="#6B7280" size={20} />
</TouchableOpacity>
{showCareSubTypePicker && (
<View style={styles.dropdownList}>
{careSubTypes.length === 0 ? (
<View style={{ padding: 14 }}>
<Typography variant="caption" style={{ color: '#9CA3AF' }}>Please select a Care Type first</Typography>
</View>
) : (
careSubTypes.map(type => (
<TouchableOpacity
key={type.slug}
style={[styles.dropdownOption, form.care_sub_type_slug === type.slug && styles.dropdownOptionSelected]}
onPress={() => {
handleChange('care_sub_type_slug', type.slug);
setShowCareSubTypePicker(false);
}}
>
<Typography variant="body" style={form.care_sub_type_slug === type.slug ? styles.dropdownOptionTextSelected : styles.dropdownOptionText}>
{type.name}
</Typography>
{form.care_sub_type_slug === type.slug && <Check color={THEME.colors.primary} size={16} />}
</TouchableOpacity>
))
)}
</View>
)}
</View>
<Input
label="Category"
placeholder="Auto-filled from Care Type"
value={form.category}
onChangeText={(val) => handleChange('category', val)}
/>
<View style={styles.dropdownContainer}>
<Typography variant="body" weight="medium" style={styles.dropdownLabel}>Pricing Unit</Typography>
<TouchableOpacity
style={styles.dropdownBox}
onPress={() => setShowUnitPicker(!showUnitPicker)}
>
<Typography variant="body" style={styles.dropdownText}>
{form.unit || 'Select Unit (e.g. Visit, Hour)'}
</Typography>
<ChevronDown color="#6B7280" size={20} />
</TouchableOpacity>
{showUnitPicker && (
<View style={styles.dropdownList}>
{UNIT_OPTIONS.map(unitOption => (
<TouchableOpacity
key={unitOption}
style={[styles.dropdownOption, form.unit === unitOption && styles.dropdownOptionSelected]}
onPress={() => {
handleChange('unit', unitOption);
setShowUnitPicker(false);
}}
>
<Typography variant="body" style={form.unit === unitOption ? styles.dropdownOptionTextSelected : styles.dropdownOptionText}>
{unitOption}
</Typography>
{form.unit === unitOption && <Check color={THEME.colors.primary} size={16} />}
</TouchableOpacity>
))}
</View>
)}
</View>
<Input
label="Customer Price *"
placeholder="e.g. 900"
keyboardType="numeric"
value={form.customer_price as string}
onChangeText={(val) => handleChange('customer_price', val)}
error={errors.customer_price}
/>
<Input
label="Caregiver Rate *"
placeholder="e.g. 500"
keyboardType="numeric"
value={form.caregiver_rate?.toString()}
onChangeText={(val) => handleChange('caregiver_rate', parseFloat(val) || 0)}
error={errors.caregiver_rate}
/>
<View style={styles.dropdownContainer}>
<Typography variant="body" weight="medium" style={styles.dropdownLabel}>Tax (%)</Typography>
<TouchableOpacity
style={styles.dropdownBox}
onPress={() => setShowTaxPicker(!showTaxPicker)}
>
<Typography variant="body" style={styles.dropdownText}>
{taxes.find(t => t.rate_percent?.toString() === form.tax?.toString())?.name ? `${taxes.find(t => t.rate_percent?.toString() === form.tax?.toString())?.name} (${form.tax}%)` : form.tax ? `${form.tax}%` : 'Select Tax Rate'}
</Typography>
<ChevronDown color="#6B7280" size={20} />
</TouchableOpacity>
{showTaxPicker && (
<View style={styles.dropdownList}>
{taxes.length === 0 ? (
<View style={{ padding: 14 }}>
<Typography variant="caption" style={{ color: '#9CA3AF' }}>Loading taxes...</Typography>
</View>
) : (
taxes.map(taxOption => (
<TouchableOpacity
key={taxOption.code}
style={[styles.dropdownOption, form.tax?.toString() === taxOption.rate_percent?.toString() && styles.dropdownOptionSelected]}
onPress={() => {
handleChange('tax', taxOption.rate_percent);
setShowTaxPicker(false);
}}
>
<View style={{ flex: 1, paddingRight: 8 }}>
<Typography variant="body" style={form.tax?.toString() === taxOption.rate_percent?.toString() ? styles.dropdownOptionTextSelected : styles.dropdownOptionText}>
{taxOption.name} ({taxOption.rate_percent}%)
</Typography>
<Typography variant="caption" style={{ color: '#6B7280', marginTop: 2 }}>
{taxOption.description}
</Typography>
</View>
{form.tax?.toString() === taxOption.rate_percent?.toString() && <Check color={THEME.colors.primary} size={16} />}
</TouchableOpacity>
))
)}
</View>
)}
</View>
<Input
label="Duration (Minutes)"
placeholder="e.g. 90"
keyboardType="numeric"
value={form.duration_minutes as string}
onChangeText={(val) => handleChange('duration_minutes', val)}
/>
<View style={styles.switchContainer}>
<Typography variant="body" weight="medium">Is Active</Typography>
<Switch
@@ -396,7 +205,6 @@ const styles = StyleSheet.create({
},
scrollContent: {
padding: 24,
paddingBottom: 40,
},
helperText: {
color: THEME.colors.text.secondary,
@@ -407,7 +215,7 @@ const styles = StyleSheet.create({
},
textArea: {
minHeight: 100,
textAlignVertical: 'top', // for Android
textAlignVertical: 'top',
},
switchContainer: {
flexDirection: 'row',
@@ -418,55 +226,13 @@ const styles = StyleSheet.create({
borderTopColor: '#E5E7EB',
marginTop: 8,
},
dropdownContainer: {
marginBottom: 20,
},
dropdownLabel: {
color: '#374151',
marginBottom: 6,
},
dropdownBox: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
borderWidth: 1,
borderColor: '#D1D5DB',
borderRadius: 8,
height: 50,
paddingHorizontal: 16,
backgroundColor: '#F9FAFB',
},
dropdownText: {
color: '#111827',
},
dropdownList: {
backgroundColor: '#FFF',
borderWidth: 1,
borderColor: '#E5E7EB',
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
borderTopWidth: 0,
marginTop: 0,
overflow: 'hidden',
},
dropdownOption: {
flexDirection: 'row',
justifyContent: 'space-between',
padding: 14,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
},
dropdownOptionSelected: {
backgroundColor: '#EEF2FF',
},
dropdownOptionText: {
color: '#374151',
},
dropdownOptionTextSelected: {
color: THEME.colors.primary,
fontWeight: 'bold',
},
saveButton: {
backgroundColor: THEME.colors.primary,
},
errorText: {
color: THEME.colors.error,
marginTop: -12,
marginBottom: 16,
marginLeft: 4,
}
});
+9 -6
View File
@@ -1,15 +1,18 @@
import React, { useState, useEffect } from 'react';
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { Typography } from '../components/ui/Typography';
import { Header } from '../components/ui/Header';
import { profileService } from '../services/profileService';
import { THEME } from '../constants/theme';
import { Check, Info } from 'lucide-react-native';
import { useSnackbar } from '../context/SnackbarContext';
export const AddSkillScreen = () => {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { showSnackbar } = useSnackbar();
const [ownSkills, setOwnSkills] = useState<any[]>([]);
const [careTypes, setCareTypes] = useState<any[]>([]);
const [subTypes, setSubTypes] = useState<any[]>([]);
@@ -72,9 +75,8 @@ export const AddSkillScreen = () => {
setIsAdding(true);
try {
await profileService.addSkills(selectedSubTypes);
Alert.alert('Success', 'Skills added successfully!', [
{ text: 'OK', onPress: () => navigation.goBack() }
]);
showSnackbar('Skills added successfully!');
navigation.goBack();
} catch (e: any) {
Alert.alert('Error', e.response?.data?.message || 'Failed to add skills.');
} finally {
@@ -176,7 +178,7 @@ export const AddSkillScreen = () => {
<View style={styles.bottomSpacer} />
</ScrollView>
<View style={styles.footer}>
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom + 16, 16) }]}>
<TouchableOpacity
style={[styles.addButton, selectedSubTypes.length === 0 && styles.addButtonDisabled]}
onPress={handleAddSkills}
@@ -337,7 +339,8 @@ const styles = StyleSheet.create({
height: 20,
},
footer: {
padding: 16,
paddingHorizontal: 16,
paddingTop: 16,
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#E5E7EB',
+3 -4
View File
@@ -1,6 +1,6 @@
import React, { useState, useCallback } from 'react';
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Typography } from '../components/ui/Typography';
@@ -16,6 +16,7 @@ export const AvailabilityListScreen = () => {
const navigation = useNavigation<NavigationProp>();
const [availability, setAvailability] = useState<CaregiverAvailability[]>([]);
const [loading, setLoading] = useState(true);
const insets = useSafeAreaInsets();
const fetchAvailability = async () => {
setLoading(true);
@@ -110,7 +111,7 @@ export const AvailabilityListScreen = () => {
</ScrollView>
<TouchableOpacity
style={styles.fab}
style={[styles.fab, { bottom: Math.max(insets.bottom + 24, 24), right: Math.max(insets.right + 24, 24) }]}
onPress={() => navigation.navigate('AddAvailability')}
>
<Plus color="#FFF" size={24} />
@@ -226,8 +227,6 @@ const styles = StyleSheet.create({
},
fab: {
position: 'absolute',
bottom: 24,
right: 24,
width: 60,
height: 60,
borderRadius: 30,
+10 -6
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { View, StyleSheet, TouchableOpacity, Alert, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { Typography } from '../components/ui/Typography';
@@ -8,6 +8,7 @@ import { Header } from '../components/ui/Header';
import { profileService } from '../services/profileService';
import { THEME } from '../constants/theme';
import { Clock, Calendar, Trash2 } from 'lucide-react-native';
import { useSnackbar } from '../context/SnackbarContext';
import { RootStackParamList } from '../navigation/AppNavigator';
type EditAvailabilityRouteProp = RouteProp<RootStackParamList, 'EditAvailability'>;
@@ -15,6 +16,8 @@ type EditAvailabilityRouteProp = RouteProp<RootStackParamList, 'EditAvailability
export const EditAvailabilityScreen = () => {
const navigation = useNavigation();
const route = useRoute<EditAvailabilityRouteProp>();
const insets = useSafeAreaInsets();
const { showSnackbar } = useSnackbar();
const { day_of_week, start_time, end_time } = route.params;
@@ -74,9 +77,8 @@ export const EditAvailabilityScreen = () => {
// @ts-ignore
await profileService.updateDayAvailability(payload);
Alert.alert('Success', 'Availability updated successfully.', [
{ text: 'OK', onPress: () => navigation.goBack() }
]);
showSnackbar('Availability updated successfully.');
navigation.goBack();
} catch (e: any) {
Alert.alert('Error', e.response?.data?.message || 'Failed to update availability.');
} finally {
@@ -97,6 +99,7 @@ export const EditAvailabilityScreen = () => {
setDeleting(true);
try {
await profileService.deleteDayAvailability(day_of_week);
showSnackbar('Availability removed successfully.');
navigation.goBack();
} catch (e: any) {
Alert.alert('Error', e.response?.data?.message || 'Failed to remove availability.');
@@ -167,7 +170,7 @@ export const EditAvailabilityScreen = () => {
</TouchableOpacity>
</View>
<View style={styles.footer}>
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom + 16, 16) }]}>
<TouchableOpacity
style={styles.saveButton}
onPress={handleUpdate}
@@ -272,7 +275,8 @@ const styles = StyleSheet.create({
marginLeft: 8,
},
footer: {
padding: 16,
paddingHorizontal: 16,
paddingTop: 16,
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#E5E7EB',
+60 -1
View File
@@ -11,7 +11,7 @@ import { Button } from '../components/ui/Button';
import { useCaregivers } from '../hooks/useCaregivers';
import { formatRating, getInitials, getImageUrl } from '../utils/formatters';
import { useResponsiveLayout } from '../hooks/useResponsiveLayout';
import { User, Award, Calendar } from 'lucide-react-native';
import { User, Award, Calendar, Briefcase, ChevronRight, FileText } from 'lucide-react-native';
import { profileService, CaregiverProfileResponse } from '../services/profileService';
import { useState, useEffect } from 'react';
@@ -108,6 +108,40 @@ export const HomeScreen: React.FC<HomeScreenProps> = ({ navigation }) => {
</TouchableOpacity>
</View>
<TouchableOpacity onPress={() => navigation.navigate('ManageServices')} style={{marginBottom: 24}}>
<Card noPadding style={styles.menuItem}>
<View style={styles.menuIconContainer}>
<Briefcase color="#4F46E5" size={24} />
</View>
<View style={styles.menuTextContainer}>
<Typography variant="body" weight="bold" style={styles.menuTitle}>
Manage Services
</Typography>
<Typography variant="caption" style={styles.menuSubtitle}>
Add or edit your offered services
</Typography>
</View>
<ChevronRight color="#9CA3AF" size={20} />
</Card>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('ProfessionalDocuments' as any)} style={{marginBottom: 24}}>
<Card noPadding style={styles.menuItem}>
<View style={styles.menuIconContainer}>
<FileText color="#4F46E5" size={24} />
</View>
<View style={styles.menuTextContainer}>
<Typography variant="body" weight="bold" style={styles.menuTitle}>
Professional Documents
</Typography>
<Typography variant="caption" style={styles.menuSubtitle}>
Upload and manage your documents
</Typography>
</View>
<ChevronRight color="#9CA3AF" size={20} />
</Card>
</TouchableOpacity>
<View style={styles.sectionHeader}>
<Typography variant="h3" weight="bold">
Featured Caregivers
@@ -310,5 +344,30 @@ const styles = StyleSheet.create({
},
tabletGridItem: {
width: '48%', // Leaves some space for margins between the two columns
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 24,
},
menuIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#EEF2FF',
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
},
menuTextContainer: {
flex: 1,
},
menuTitle: {
color: '#111827',
marginBottom: 2,
},
menuSubtitle: {
color: '#6B7280',
}
});
+6 -5
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { View, StyleSheet, FlatList, Alert, TouchableOpacity, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/AppNavigator';
import { Header } from '../components/ui/Header';
@@ -9,6 +9,7 @@ import { THEME } from '../constants/theme';
import { serviceService, Service } from '../services/serviceService';
import { Edit2, Trash2, Plus } from 'lucide-react-native';
import { useIsFocused } from '@react-navigation/native';
import { useSnackbar } from '../context/SnackbarContext';
interface ManageServicesScreenProps {
navigation: NativeStackNavigationProp<RootStackParamList, 'ManageServices'>;
@@ -18,6 +19,8 @@ export const ManageServicesScreen: React.FC<ManageServicesScreenProps> = ({ navi
const [services, setServices] = useState<Service[]>([]);
const [isLoading, setIsLoading] = useState(true);
const isFocused = useIsFocused();
const insets = useSafeAreaInsets();
const { showSnackbar } = useSnackbar();
useEffect(() => {
if (isFocused) {
@@ -53,7 +56,7 @@ export const ManageServicesScreen: React.FC<ManageServicesScreenProps> = ({ navi
onPress: async () => {
try {
await serviceService.deleteService(id);
Alert.alert('Success', 'Service deleted successfully.');
showSnackbar('Service deleted successfully.');
fetchServices();
} catch (error) {
console.error('Error deleting service:', error);
@@ -141,7 +144,7 @@ export const ManageServicesScreen: React.FC<ManageServicesScreenProps> = ({ navi
)}
<TouchableOpacity
style={styles.fab}
style={[styles.fab, { bottom: Math.max(insets.bottom + 24, 24), right: Math.max(insets.right + 24, 24) }]}
onPress={() => navigation.navigate('AddEditService', {})}
>
<Plus color="#FFF" size={24} />
@@ -232,8 +235,6 @@ const styles = StyleSheet.create({
},
fab: {
position: 'absolute',
bottom: 24,
right: 24,
width: 56,
height: 56,
borderRadius: 28,
+41 -6
View File
@@ -1,6 +1,6 @@
import React, { useState, useCallback } from 'react';
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, Text, Alert } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Typography } from '../components/ui/Typography';
@@ -8,7 +8,8 @@ import { Header } from '../components/ui/Header';
import { profileService } from '../services/profileService';
import { THEME } from '../constants/theme';
import { RootStackParamList } from '../navigation/AppNavigator';
import { Plus, Award } from 'lucide-react-native';
import { Plus, Award, Trash2 } from 'lucide-react-native';
import { useSnackbar } from '../context/SnackbarContext';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'ManageSkills'>;
@@ -16,6 +17,8 @@ export const ManageSkillsScreen = () => {
const navigation = useNavigation<NavigationProp>();
const [ownSkills, setOwnSkills] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const insets = useSafeAreaInsets();
const { showSnackbar } = useSnackbar();
const fetchSkills = async () => {
try {
@@ -35,6 +38,30 @@ export const ManageSkillsScreen = () => {
}, [])
);
const handleDelete = (skillSlug: string, skillName: string) => {
Alert.alert(
"Remove Skill",
`Are you sure you want to remove ${skillName}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Remove",
onPress: async () => {
try {
await profileService.removeSkills([skillSlug]);
showSnackbar('Skill removed successfully.');
fetchSkills();
} catch (error) {
console.error('Failed to remove skill', error);
Alert.alert('Error', 'Failed to remove skill. Please try again.');
}
},
style: "destructive"
}
]
);
};
return (
// @ts-ignore
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container as any}>
@@ -89,6 +116,12 @@ export const ManageSkillsScreen = () => {
</Typography>
</View>
</View>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => handleDelete(skill.slug, skill.name)}
>
<Trash2 color="#EF4444" size={20} />
</TouchableOpacity>
</View>
))
)}
@@ -97,7 +130,7 @@ export const ManageSkillsScreen = () => {
</ScrollView>
<TouchableOpacity
style={styles.fab}
style={[styles.fab, { bottom: Math.max(insets.bottom + 24, 24), right: Math.max(insets.right + 24, 24) }]}
onPress={() => navigation.navigate('AddSkill')}
>
<Plus color="#FFF" size={24} />
@@ -231,13 +264,15 @@ const styles = StyleSheet.create({
color: THEME.colors.primary,
fontWeight: 'bold',
},
deleteButton: {
padding: 8,
marginLeft: 8,
},
bottomSpacer: {
height: 100,
},
fab: {
position: 'absolute',
bottom: 24,
right: 24,
width: 60,
height: 60,
borderRadius: 30,
+234
View File
@@ -0,0 +1,234 @@
import React, { useState, useCallback } from 'react';
import { View, StyleSheet, ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useFocusEffect } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
import { Header } from '../components/ui/Header';
import { Typography } from '../components/ui/Typography';
import { THEME } from '../constants/theme';
import { profileService, PaymentProfile } from '../services/profileService';
import { Plus, CreditCard, Landmark } from 'lucide-react-native';
interface PaymentProfileListScreenProps {
navigation: NativeStackNavigationProp<RootStackParamList, 'PaymentProfileList'>;
}
export const PaymentProfileListScreen: React.FC<PaymentProfileListScreenProps> = ({ navigation }) => {
const [profiles, setProfiles] = useState<PaymentProfile[]>([]);
const [isLoading, setIsLoading] = useState(true);
const insets = useSafeAreaInsets();
const fetchProfiles = async () => {
try {
setIsLoading(true);
const response = await profileService.getPaymentProfile();
// Handle both array and single object responses for backward compatibility
let profilesData: PaymentProfile[] = [];
if (response && response.data) {
if (Array.isArray(response.data)) {
profilesData = response.data;
} else if (response.data.id) {
profilesData = [response.data];
}
}
setProfiles(profilesData);
} catch (error) {
console.log('No payment profiles found or error fetching.');
setProfiles([]);
} finally {
setIsLoading(false);
}
};
useFocusEffect(
useCallback(() => {
fetchProfiles();
}, [])
);
const maskAccountNumber = (accountNumber: string) => {
if (!accountNumber) return '**** **** **** ****';
const last4 = accountNumber.slice(-4);
return `**** **** **** ${last4}`;
};
const renderCard = (profile: PaymentProfile) => {
return (
<TouchableOpacity
key={profile.id}
style={styles.cardContainer}
activeOpacity={0.9}
onPress={() => navigation.navigate('AddEditPaymentProfile', { profile })}
>
<View style={styles.cardHeader}>
<View style={styles.bankNameRow}>
<Landmark color="#FFF" size={20} />
<Typography variant="body" weight="bold" style={styles.bankName}>
{profile.bank_name || 'Bank Account'}
</Typography>
</View>
<CreditCard color="#FFF" size={24} />
</View>
<View style={styles.cardNumberContainer}>
<Typography variant="h3" weight="bold" style={styles.cardNumber}>
{maskAccountNumber(profile.account_number)}
</Typography>
</View>
<View style={styles.cardFooter}>
<View>
<Typography variant="caption" style={styles.cardLabel}>Cardholder Name</Typography>
<Typography variant="body" weight="bold" style={styles.cardValue}>
{profile.account_holder_name?.toUpperCase() || 'UNKNOWN'}
</Typography>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Typography variant="caption" style={styles.cardLabel}>IFSC Code</Typography>
<Typography variant="body" weight="bold" style={styles.cardValue}>
{profile.ifsc_code || '---'}
</Typography>
</View>
</View>
</TouchableOpacity>
);
};
return (
<View style={[styles.container, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<Header title="Payment Profiles" showBack />
<ScrollView
contentContainerStyle={styles.scrollContent}
refreshControl={<RefreshControl refreshing={isLoading} onRefresh={fetchProfiles} />}
>
{profiles.length === 0 && !isLoading ? (
<View style={styles.emptyContainer}>
<View style={styles.iconWrapper}>
<CreditCard color={THEME.colors.primary} size={40} />
</View>
<Typography variant="h3" weight="bold" style={styles.emptyTitle}>
No Accounts Added
</Typography>
<Typography variant="body" style={styles.emptySubtitle}>
Add your bank account details to receive your payouts securely.
</Typography>
</View>
) : (
profiles.map(renderCard)
)}
</ScrollView>
<TouchableOpacity
style={[styles.fab, { bottom: Math.max(insets.bottom + 20, 20), right: Math.max(insets.right + 20, 20) }]}
onPress={() => navigation.navigate('AddEditPaymentProfile', { profile: undefined })}
>
<Plus color="#FFF" size={24} />
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: THEME.colors.background,
},
scrollContent: {
padding: 16,
paddingBottom: 100, // Extra padding for FAB
flexGrow: 1,
},
cardContainer: {
backgroundColor: '#0F172A', // Very dark slate, almost black, looks premium
borderRadius: 16,
padding: 20,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
overflow: 'hidden',
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 32,
},
bankNameRow: {
flexDirection: 'row',
alignItems: 'center',
},
bankName: {
color: '#FFF',
marginLeft: 8,
fontSize: 16,
},
cardNumberContainer: {
marginBottom: 32,
},
cardNumber: {
color: '#FFF',
letterSpacing: 2,
fontSize: 22,
},
cardFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
},
cardLabel: {
color: '#94A3B8', // Slate 400
marginBottom: 4,
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: 1,
},
cardValue: {
color: '#FFF',
fontSize: 14,
letterSpacing: 1,
},
fab: {
position: 'absolute',
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: THEME.colors.primary,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 6,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
marginTop: 60,
},
iconWrapper: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: THEME.colors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
emptyTitle: {
color: '#111827',
marginBottom: 8,
},
emptySubtitle: {
color: THEME.colors.text.secondary,
textAlign: 'center',
paddingHorizontal: 32,
},
});
+7 -3
View File
@@ -8,12 +8,14 @@ import { profileService, CaregiverProfileResponse } from '../services/profileSer
import { getImageUrl } from '../utils/formatters';
import { launchImageLibrary } from 'react-native-image-picker';
import { Edit2, Camera } from 'lucide-react-native';
import { useSnackbar } from '../context/SnackbarContext';
export const PersonalInformationScreen = () => {
const [profile, setProfile] = useState<CaregiverProfileResponse['data'] | null>(null);
const [loading, setLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [saving, setSaving] = useState(false);
const { showSnackbar } = useSnackbar();
// Form State
const [formData, setFormData] = useState({
@@ -109,8 +111,10 @@ export const PersonalInformationScreen = () => {
await profileService.updateProfile(payload);
await fetchProfile();
setIsEditing(false);
showSnackbar('Personal information updated successfully.');
} catch (err) {
console.error('Failed to save profile', err);
showSnackbar('Failed to update personal information.', 'error');
} finally {
setSaving(false);
}
@@ -120,13 +124,13 @@ export const PersonalInformationScreen = () => {
if (isEditing) {
return (
<TouchableOpacity onPress={handleSave} disabled={saving} style={styles.headerButton}>
{saving ? <ActivityIndicator size="small" color="#4F46E5" /> : <Typography variant="body" weight="bold" style={styles.headerButtonText}>Save</Typography>}
{saving ? <ActivityIndicator size="small" color="#FFFFFF" /> : <Typography variant="body" weight="bold" style={styles.headerButtonText}>Save</Typography>}
</TouchableOpacity>
);
}
return (
<TouchableOpacity onPress={() => setIsEditing(true)} style={styles.headerButton}>
<Edit2 color="#4F46E5" size={20} />
<Edit2 color="#FFFFFF" size={20} />
</TouchableOpacity>
);
};
@@ -289,7 +293,7 @@ const styles = StyleSheet.create({
padding: 8,
},
headerButtonText: {
color: '#4F46E5',
color: '#FFFFFF',
},
card: {
padding: 0,
+155
View File
@@ -0,0 +1,155 @@
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, FlatList, ActivityIndicator, Alert, TouchableOpacity, Image } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/AppNavigator';
import { Header } from '../components/ui/Header';
import { Typography } from '../components/ui/Typography';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { documentService, ProfessionalDocument } from '../services/documentService';
import { FileText, Trash2, Plus } from 'lucide-react-native';
import { getImageUrl } from '../utils/formatters';
import { useSnackbar } from '../context/SnackbarContext';
type Props = NativeStackScreenProps<RootStackParamList, 'ProfessionalDocuments'>;
export const ProfessionalDocumentsScreen: React.FC<Props> = ({ navigation }) => {
const [documents, setDocuments] = useState<ProfessionalDocument[]>([]);
const [isLoading, setIsLoading] = useState(true);
const insets = useSafeAreaInsets();
const { showSnackbar } = useSnackbar();
const fetchDocuments = async () => {
try {
setIsLoading(true);
const response = await documentService.getDocuments();
setDocuments(response.data || []);
} catch (error) {
console.error('Failed to fetch documents', error);
Alert.alert('Error', 'Failed to load documents');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
fetchDocuments();
});
return unsubscribe;
}, [navigation]);
const handleDelete = (id: number) => {
Alert.alert(
"Delete Document",
"Are you sure you want to delete this document?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
onPress: async () => {
try {
await documentService.deleteDocument(id);
showSnackbar('Document deleted successfully.');
fetchDocuments();
} catch (error) {
console.error('Failed to delete', error);
Alert.alert('Error', 'Failed to delete document');
}
},
style: "destructive"
}
]
);
};
const renderItem = ({ item }: { item: ProfessionalDocument }) => (
<TouchableOpacity activeOpacity={0.8} onPress={() => navigation.navigate('ViewDocument', { documentId: item.id })}>
<Card style={styles.documentCard}>
<View style={styles.docHeader}>
<View style={styles.titleContainer}>
<FileText color="#1E3A8A" size={24} style={styles.docIcon} />
<View style={{ flex: 1 }}>
<Typography variant="body" weight="bold" style={styles.docTitle}>
{item.addition_info?.title || 'Untitled Document'}
</Typography>
<Typography variant="caption" style={styles.docDate}>
{new Date(item.created_at).toLocaleDateString()}
</Typography>
</View>
</View>
<TouchableOpacity onPress={() => handleDelete(item.id)} style={styles.deleteBtn}>
<Trash2 color="#EF4444" size={20} />
</TouchableOpacity>
</View>
{item.addition_info?.notes ? (
<Typography variant="caption" style={styles.docNotes}>
{item.addition_info.notes}
</Typography>
) : null}
{item.properties?.url ? (
<Image source={{ uri: getImageUrl(item.properties.url) }} style={styles.previewImage} />
) : null}
</Card>
</TouchableOpacity>
);
return (
// @ts-ignore
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container}>
<Header title="Professional Documents" showBack={true} />
<View style={styles.content}>
{isLoading ? (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#1E3A8A" />
</View>
) : documents.length === 0 ? (
<View style={styles.centerContainer}>
<FileText color="#D1D5DB" size={64} style={{ marginBottom: 16 }} />
<Typography variant="body" style={styles.emptyText}>
No professional documents found.
</Typography>
<Typography variant="caption" style={styles.emptySubText}>
Upload your licenses, certificates, and other professional documents here.
</Typography>
</View>
) : (
<FlatList
data={documents}
keyExtractor={(item) => item.id.toString()}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
/>
)}
<View style={[styles.fabContainer, { bottom: Math.max(insets.bottom + 16, 16) }]}>
<Button
title="Upload Document"
onPress={() => navigation.navigate('UploadDocument')}
/>
</View>
</View>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F9FAFB' },
content: { flex: 1, padding: 16 },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
listContent: { paddingBottom: 100 },
documentCard: { marginBottom: 16, padding: 16 },
docHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 },
titleContainer: { flexDirection: 'row', alignItems: 'center', flex: 1 },
docIcon: { marginRight: 12 },
docTitle: { color: '#111827', marginBottom: 4 },
docDate: { color: '#6B7280' },
deleteBtn: { padding: 8, marginLeft: 8 },
docNotes: { color: '#4B5563', marginBottom: 12, backgroundColor: '#F3F4F6', padding: 8, borderRadius: 6 },
previewImage: { width: '100%', height: 150, borderRadius: 8, resizeMode: 'cover', marginTop: 8 },
emptyText: { color: '#4B5563', fontWeight: '500', marginBottom: 8, textAlign: 'center' },
emptySubText: { color: '#9CA3AF', textAlign: 'center' },
fabContainer: { position: 'absolute', left: 16, right: 16 },
});
+207
View File
@@ -0,0 +1,207 @@
import React, { useState, useMemo } from 'react';
import {
View,
Text,
TouchableOpacity,
FlatList,
TextInput,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Search, Check, X } from 'lucide-react-native';
import { Typography } from '../components/ui/Typography';
import { Header } from '../components/ui/Header';
import { THEME } from '../constants/theme';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RouteProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
type SelectionScreenNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Selection'>;
type SelectionScreenRouteProp = RouteProp<RootStackParamList, 'Selection'>;
interface SelectionScreenProps {
navigation: SelectionScreenNavigationProp;
route: SelectionScreenRouteProp;
}
export const SelectionScreen: React.FC<SelectionScreenProps> = ({ navigation, route }) => {
const {
data,
value,
isMulti,
searchable,
label,
searchPlaceholder = 'Search...',
onSelect,
} = route.params;
const [searchQuery, setSearchQuery] = useState('');
const filteredData = useMemo(() => {
if (!searchQuery) return data;
return data.filter((item: any) =>
item.label.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [data, searchQuery]);
const handleSelect = (item: any) => {
if (isMulti) {
const currentValue = (Array.isArray(value) ? value : []) as (string | number)[];
const isSelected = currentValue.includes(item.value);
let newValue;
if (isSelected) {
newValue = currentValue.filter((val) => val !== item.value);
} else {
newValue = [...currentValue, item.value];
}
// Instantly update route params for immediate visual feedback
navigation.setParams({ value: newValue });
onSelect(newValue);
} else {
onSelect(item.value);
navigation.goBack();
}
};
const isItemSelected = (item: any) => {
if (isMulti) {
const currentValue = (Array.isArray(value) ? value : []) as (string | number)[];
return currentValue.includes(item.value);
}
return value === item.value;
};
const insets = useSafeAreaInsets();
return (
<View style={[styles.container, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<Header title={label || 'Select'} showBack />
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.content}
>
{searchable && (
<View style={styles.searchContainer}>
<Search color="#9CA3AF" size={20} style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder={searchPlaceholder}
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus={true}
/>
{searchQuery.length > 0 && (
<TouchableOpacity onPress={() => setSearchQuery('')}>
<X color="#9CA3AF" size={16} />
</TouchableOpacity>
)}
</View>
)}
<FlatList
data={filteredData}
keyExtractor={(item, index) => item.value.toString() + index}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContainer}
renderItem={({ item }) => {
const selected = isItemSelected(item);
return (
<TouchableOpacity
style={[
styles.itemContainer,
selected && styles.itemSelected,
]}
onPress={() => handleSelect(item)}
>
<Text style={[styles.itemText, selected && styles.itemTextSelected]}>
{item.label}
</Text>
{selected && <Check color={THEME.colors.primary} size={20} />}
</TouchableOpacity>
);
}}
ListEmptyComponent={() => (
<View style={styles.emptyContainer}>
<Typography variant="body" style={styles.emptyText}>
No options found
</Typography>
</View>
)}
/>
</KeyboardAvoidingView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
content: {
flex: 1,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 12,
marginHorizontal: 16,
marginTop: 16,
paddingHorizontal: 12,
height: 48,
},
searchIcon: {
marginRight: 8,
},
searchInput: {
flex: 1,
fontSize: 16,
color: '#111827',
},
listContainer: {
paddingHorizontal: 16,
paddingVertical: 12,
},
itemContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
backgroundColor: '#FFFFFF',
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
borderRadius: 12,
marginBottom: 8,
},
itemSelected: {
backgroundColor: THEME.colors.primaryLighter,
borderColor: THEME.colors.primaryLight,
borderWidth: 1,
borderBottomWidth: 1,
},
itemText: {
fontSize: 16,
color: '#374151',
},
itemTextSelected: {
color: THEME.colors.primary,
fontWeight: 'bold',
},
emptyContainer: {
padding: 24,
alignItems: 'center',
},
emptyText: {
color: '#6B7280',
},
});
+2 -19
View File
@@ -4,7 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { Typography } from '../components/ui/Typography';
import { Header } from '../components/ui/Header';
import { Card } from '../components/ui/Card';
import { User, ChevronRight, LogOut, CreditCard, Briefcase } from 'lucide-react-native';
import { User, ChevronRight, LogOut, CreditCard } from 'lucide-react-native';
import { CompositeNavigationProp } from '@react-navigation/native';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
@@ -68,7 +68,7 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({ navigation }) =>
</Card>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('PaymentProfile')}>
<TouchableOpacity onPress={() => navigation.navigate('PaymentProfileList')}>
<Card noPadding style={styles.menuItem}>
<View style={styles.menuIconContainer}>
<CreditCard color="#4F46E5" size={24} />
@@ -85,23 +85,6 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({ navigation }) =>
</Card>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('ManageServices')}>
<Card noPadding style={styles.menuItem}>
<View style={styles.menuIconContainer}>
<Briefcase color="#4F46E5" size={24} />
</View>
<View style={styles.menuTextContainer}>
<Typography variant="body" weight="bold" style={styles.menuTitle}>
Manage Services
</Typography>
<Typography variant="caption" style={styles.menuSubtitle}>
Add or edit your offered services
</Typography>
</View>
<ChevronRight color="#9CA3AF" size={20} />
</Card>
</TouchableOpacity>
<TouchableOpacity onPress={handleLogout}>
<Card noPadding style={styles.menuItem}>
<View style={[styles.menuIconContainer, { backgroundColor: '#FEE2E2' }]}>
+158
View File
@@ -0,0 +1,158 @@
import React, { useState } from 'react';
import { View, StyleSheet, TouchableOpacity, Image, Alert, ScrollView, ActivityIndicator } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/AppNavigator';
import { Header } from '../components/ui/Header';
import { Typography } from '../components/ui/Typography';
import { Input } from '../components/ui/Input';
import { Button } from '../components/ui/Button';
import { documentService } from '../services/documentService';
import { launchImageLibrary, ImagePickerResponse, Asset } from 'react-native-image-picker';
import { Upload } from 'lucide-react-native';
import { useSnackbar } from '../context/SnackbarContext';
type Props = NativeStackScreenProps<RootStackParamList, 'UploadDocument'>;
export const UploadDocumentScreen: React.FC<Props> = ({ navigation }) => {
const [title, setTitle] = useState('');
const [notes, setNotes] = useState('');
const [document, setDocument] = useState<Asset | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const insets = useSafeAreaInsets();
const { showSnackbar } = useSnackbar();
const handleSelectDocument = () => {
launchImageLibrary(
{
mediaType: 'photo',
quality: 0.8,
},
(response: ImagePickerResponse) => {
if (response.didCancel) {
console.log('User cancelled image picker');
} else if (response.errorCode) {
console.log('ImagePicker Error: ', response.errorMessage);
Alert.alert('Error', response.errorMessage || 'Failed to pick image');
} else if (response.assets && response.assets.length > 0) {
setDocument(response.assets[0]);
}
}
);
};
const handleUpload = async () => {
if (!title.trim() || !document) {
Alert.alert('Error', 'Please provide a title and select a document.');
return;
}
try {
setIsSubmitting(true);
const formData = new FormData();
formData.append('title', title);
formData.append('notes', notes);
formData.append('document', {
uri: document.uri,
type: document.type || 'image/jpeg',
name: document.fileName || `document-${Date.now()}.jpg`,
} as any);
await documentService.uploadDocument(formData);
showSnackbar('Document uploaded successfully');
navigation.goBack();
} catch (error: any) {
console.error('Upload Error', error);
Alert.alert('Error', error.response?.data?.message || 'Failed to upload document. Please try again.');
} finally {
setIsSubmitting(false);
}
};
return (
// @ts-ignore
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container}>
<Header title="Upload Document" showBack={true} />
<ScrollView style={styles.content} keyboardShouldPersistTaps="handled">
<Typography variant="body" weight="medium" style={styles.label}>
Document Title *
</Typography>
<Input
placeholder="e.g. Nursing License"
value={title}
onChangeText={setTitle}
/>
<Typography variant="body" weight="medium" style={styles.label}>
Notes
</Typography>
<Input
placeholder="e.g. Registered Nurse License exp 2028"
value={notes}
onChangeText={setNotes}
multiline
numberOfLines={4}
style={styles.textArea}
/>
<Typography variant="body" weight="medium" style={styles.label}>
Attachment *
</Typography>
<TouchableOpacity style={styles.uploadContainer} onPress={handleSelectDocument}>
{document ? (
<Image source={{ uri: document.uri }} style={styles.previewImage} />
) : (
<View style={styles.uploadPlaceholder}>
<Upload color="#1E3A8A" size={32} />
<Typography variant="body" style={styles.uploadText}>
Tap to select a document
</Typography>
</View>
)}
</TouchableOpacity>
{document && (
<TouchableOpacity onPress={() => setDocument(null)} style={styles.removeBtn}>
<Typography variant="caption" style={styles.removeBtnText}>Remove</Typography>
</TouchableOpacity>
)}
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom + 40, 40) }]}>
<Button
title={isSubmitting ? "Uploading..." : "Upload Document"}
onPress={handleUpload}
disabled={isSubmitting}
/>
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F9FAFB' },
content: { flex: 1, padding: 16 },
label: { marginBottom: 8, marginTop: 16, color: '#374151' },
textArea: { height: 100, textAlignVertical: 'top' },
uploadContainer: {
borderWidth: 2,
borderColor: '#E5E7EB',
borderStyle: 'dashed',
borderRadius: 12,
height: 200,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
backgroundColor: '#FFFFFF',
marginBottom: 8,
},
uploadPlaceholder: {
alignItems: 'center',
justifyContent: 'center',
},
uploadText: { marginTop: 12, color: '#6B7280' },
previewImage: { width: '100%', height: '100%', resizeMode: 'cover' },
removeBtn: { alignSelf: 'flex-end', marginBottom: 24 },
removeBtnText: { color: '#EF4444' },
footer: { marginTop: 32 },
});
+104
View File
@@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, ActivityIndicator, Alert, Image, ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/AppNavigator';
import { Header } from '../components/ui/Header';
import { Typography } from '../components/ui/Typography';
import { documentService, ProfessionalDocument } from '../services/documentService';
import { FileText } from 'lucide-react-native';
import { getImageUrl } from '../utils/formatters';
type Props = NativeStackScreenProps<RootStackParamList, 'ViewDocument'>;
export const ViewDocumentScreen: React.FC<Props> = ({ route, navigation }) => {
const { documentId } = route.params;
const [document, setDocument] = useState<ProfessionalDocument | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchDocument = async () => {
try {
setIsLoading(true);
const response = await documentService.getDocumentById(documentId);
setDocument(response.data);
} catch (error) {
console.error('Failed to fetch document', error);
Alert.alert('Error', 'Failed to load document details');
navigation.goBack();
} finally {
setIsLoading(false);
}
};
fetchDocument();
}, [documentId, navigation]);
return (
// @ts-ignore
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container}>
<Header title="View Document" showBack={true} />
{isLoading ? (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#1E3A8A" />
</View>
) : document ? (
<ScrollView style={styles.content} contentContainerStyle={{ paddingBottom: 40 }}>
<View style={styles.headerSection}>
<FileText color="#1E3A8A" size={32} style={styles.icon} />
<View style={styles.headerText}>
<Typography variant="h2" weight="bold">
{document.addition_info?.title || 'Untitled Document'}
</Typography>
<Typography variant="caption" style={styles.date}>
Uploaded on {new Date(document.created_at).toLocaleDateString()}
</Typography>
</View>
</View>
{document.addition_info?.notes ? (
<View style={styles.notesSection}>
<Typography variant="body" weight="medium" style={styles.sectionTitle}>
Notes
</Typography>
<Typography variant="body" style={styles.notesText}>
{document.addition_info.notes}
</Typography>
</View>
) : null}
{document.properties?.url ? (
<View style={styles.imageSection}>
<Typography variant="body" weight="medium" style={styles.sectionTitle}>
Document Preview
</Typography>
<Image
source={{ uri: getImageUrl(document.properties.url) }}
style={styles.fullImage}
resizeMode="contain"
/>
</View>
) : (
<View style={styles.centerContainer}>
<Typography variant="body" style={{color: '#6B7280'}}>No image preview available</Typography>
</View>
)}
</ScrollView>
) : null}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F9FAFB' },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
content: { flex: 1, padding: 16 },
headerSection: { flexDirection: 'row', alignItems: 'center', marginBottom: 24, paddingBottom: 16, borderBottomWidth: 1, borderBottomColor: '#E5E7EB' },
icon: { marginRight: 16 },
headerText: { flex: 1 },
date: { color: '#6B7280', marginTop: 4 },
notesSection: { marginBottom: 24, backgroundColor: '#F3F4F6', padding: 16, borderRadius: 8 },
sectionTitle: { marginBottom: 8, color: '#374151' },
notesText: { color: '#4B5563' },
imageSection: { flex: 1 },
fullImage: { width: '100%', height: 400, backgroundColor: '#E5E7EB', borderRadius: 8, marginTop: 8 },
});
+9 -6
View File
@@ -13,6 +13,7 @@ export const apiClient = axios.create({
});
import AsyncStorage from '@react-native-async-storage/async-storage';
import { navigate } from '../navigation/navigationRef';
// Optionally, we can add interceptors here later to inject Bearer tokens
apiClient.interceptors.request.use(
@@ -37,9 +38,10 @@ apiClient.interceptors.response.use(
const originalRequest = error.config;
// Check if error is 401 and we have not already retried
const errorCode = error.response?.data?.errors?.code;
if (
error.response?.status === 401 &&
error.response?.data?.errors?.code === 'TOKEN_EXPIRED' &&
(errorCode === 'TOKEN_EXPIRED' || errorCode === 'UNAUTHENTICATED') &&
!originalRequest._retry
) {
originalRequest._retry = true; // Mark as retried to prevent infinite loops
@@ -53,7 +55,7 @@ apiClient.interceptors.response.use(
// Call your refresh endpoint
const refreshResponse = await axios.post(`${ENV.API_BASE_URL}/v1/caregiver/auth/refresh`, {
refresh_token: refreshToken,
refreshToken: refreshToken,
}, {
headers: {
'Content-Type': 'application/json',
@@ -63,9 +65,9 @@ apiClient.interceptors.response.use(
});
// The exact path depends on your backend response structure
// Usually it's refreshResponse.data.data.access_token
const newAccessToken = refreshResponse.data?.data?.access_token || refreshResponse.data?.access_token;
const newRefreshToken = refreshResponse.data?.data?.refresh_token || refreshResponse.data?.refresh_token;
// Usually it's refreshResponse.data.data.accessToken
const newAccessToken = refreshResponse.data?.data?.accessToken || refreshResponse.data?.accessToken;
const newRefreshToken = refreshResponse.data?.data?.refreshToken || refreshResponse.data?.refreshToken;
if (newAccessToken) {
await AsyncStorage.setItem('token', newAccessToken); // Ensure 'token' key matches what you use
@@ -87,7 +89,8 @@ apiClient.interceptors.response.use(
// Force user to log out by clearing tokens
await AsyncStorage.removeItem('token');
await AsyncStorage.removeItem('refresh_token');
// If you are using Context or Redux, you may need to dispatch a logout action here
// Force navigate to Login screen
navigate('Login');
}
}
+58
View File
@@ -0,0 +1,58 @@
import { apiClient } from './apiClient';
export interface ProfessionalDocument {
id: number;
properties?: {
ext?: string;
url?: string;
name?: string;
mime_type?: string;
};
addition_info?: {
title?: string;
notes?: string;
};
created_at: string;
}
export interface DocumentResponse {
success: boolean;
data: ProfessionalDocument[];
message?: string;
}
export interface SingleDocumentResponse {
success: boolean;
data: ProfessionalDocument;
message?: string;
}
export const documentService = {
getDocuments: async () => {
const response = await apiClient.get<DocumentResponse>('/v1/resource/caregiver/professional-documents');
return response.data;
},
getDocumentById: async (id: number) => {
const response = await apiClient.get<SingleDocumentResponse>(`/v1/resource/caregiver/professional-documents/${id}`);
return response.data;
},
uploadDocument: async (formData: FormData) => {
const response = await apiClient.post<SingleDocumentResponse>(
'/v1/resource/caregiver/professional-documents',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
return response.data;
},
deleteDocument: async (id: number) => {
const response = await apiClient.delete(`/v1/resource/caregiver/professional-documents/${id}`);
return response.data;
},
};
+17
View File
@@ -37,6 +37,10 @@ export interface CaregiverProfileResponse {
profile_image: string;
isActive: boolean;
};
profile?: {
id: number;
[key: string]: any;
};
};
errors: any;
meta: any;
@@ -149,6 +153,19 @@ export const profileService = {
}
},
removeSkills: async (skills: string[]): Promise<any> => {
try {
const response = await apiClient.post('/v1/resource/caregiver/skills/remove', {
skills,
force: 'yes'
});
return response.data;
} catch (error) {
console.error('Error removing skills:', error);
throw error;
}
},
getAvailability: async (): Promise<CaregiverAvailability[]> => {
try {
const response = await apiClient.get('/v1/resource/caregiver/availability');
+33 -7
View File
@@ -2,19 +2,33 @@ import { apiClient } from './apiClient';
export interface Service {
id?: number;
name: string;
service_id?: number;
caregiver_id?: number;
service_name?: string;
name?: string; // Sometimes APIs map this interchangeably
care_type_slug?: string;
care_sub_type_slug?: string;
category?: string;
description: string;
description?: string;
notes?: string;
unit?: string;
customer_price?: number | string;
caregiver_rate?: number | string;
custom_rate?: number | string;
effective_rate?: number | string;
tax?: number | string;
duration_minutes?: number | string;
is_active?: boolean;
}
export interface AddServicePayload {
service_id: number;
caregiver_id: number;
custom_rate: number | string;
is_active: boolean;
notes: string;
}
export const serviceService = {
getServices: async (filters?: { skills?: string }): Promise<Service[]> => {
try {
@@ -31,6 +45,16 @@ export const serviceService = {
}
},
getSkillBasedServices: async (): Promise<Service[]> => {
try {
const response = await apiClient.get('/v1/caregivers/services/my-skills');
return response.data?.data || response.data || [];
} catch (error) {
console.error('Error fetching skill-based services:', error);
throw error;
}
},
getTaxes: async (): Promise<any[]> => {
try {
const response = await apiClient.get('/v1/resource/taxes');
@@ -51,9 +75,11 @@ export const serviceService = {
}
},
addService: async (data: Service): Promise<any> => {
addService: async (data: AddServicePayload): Promise<any> => {
try {
const response = await apiClient.post('/v1/caregiver/services', data);
// According to backend routes, it can be POST /v1/resource/caregiver/services
// which delegates to httpAddOwnService
const response = await apiClient.post('/v1/resource/caregiver/services', data);
return response.data;
} catch (error) {
console.error('Error adding service:', error);
@@ -61,9 +87,9 @@ export const serviceService = {
}
},
updateService: async (id: number, data: Partial<Service>): Promise<any> => {
updateService: async (id: number, data: Partial<AddServicePayload>): Promise<any> => {
try {
const response = await apiClient.patch(`/v1/caregiver/services/${id}`, data);
const response = await apiClient.patch(`/v1/resource/caregiver/services/${id}`, data);
return response.data;
} catch (error) {
console.error(`Error updating service with id ${id}:`, error);
@@ -73,7 +99,7 @@ export const serviceService = {
deleteService: async (id: number): Promise<any> => {
try {
const response = await apiClient.delete(`/v1/caregiver/services/${id}`);
const response = await apiClient.delete(`/v1/resource/caregiver/services/${id}`);
return response.data;
} catch (error) {
console.error(`Error deleting service with id ${id}:`, error);
+11 -3
View File
@@ -21,10 +21,18 @@ import { ENV } from '../config/env';
*/
export const getImageUrl = (path?: string | null): string | undefined => {
if (!path) return undefined;
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('file://') || path.startsWith('content://') || path.startsWith('data:')) {
return path;
let processedPath = path;
if (processedPath.startsWith('http://localhost')) {
const baseUrl = ENV.API_BASE_URL.endsWith('/') ? ENV.API_BASE_URL.slice(0, -1) : ENV.API_BASE_URL;
// Replace http://localhost with our API base url
processedPath = processedPath.replace('http://localhost', baseUrl);
}
if (processedPath.startsWith('http://') || processedPath.startsWith('https://') || processedPath.startsWith('file://') || processedPath.startsWith('content://') || processedPath.startsWith('data:')) {
return processedPath;
}
const baseUrl = ENV.API_BASE_URL.endsWith('/') ? ENV.API_BASE_URL.slice(0, -1) : ENV.API_BASE_URL;
const imagePath = path.startsWith('/') ? path : `/${path}`;
const imagePath = processedPath.startsWith('/') ? processedPath : `/${processedPath}`;
return `${baseUrl}${imagePath}`;
};