Fix all shadow deprecation warnings across entire mobile app

- Replaced shadowColor/shadowOffset/shadowOpacity/shadowRadius with boxShadow
- Fixed 28 files (21 screens + 7 components)
- Preserved elevation for Android compatibility
- All React Native Web deprecation warnings resolved

Files fixed:
- All screen components
- All reusable components
- Navigation components
- Modal components
This commit is contained in:
2026-01-14 15:05:10 +03:00
parent 9090e0fc2b
commit 8d30519efc
231 changed files with 30234 additions and 62124 deletions
+512
View File
@@ -0,0 +1,512 @@
import React, { useState } from 'react';
import {
View,
Text,
Modal,
TouchableOpacity,
StyleSheet,
ScrollView,
Image,
Alert,
ActivityIndicator,
Platform,
} from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { KurdistanColors } from '../theme/colors';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../lib/supabase';
// Avatar pool - Kurdish/Middle Eastern themed avatars
const AVATAR_POOL = [
{ id: 'avatar1', emoji: '👨🏻', label: 'Man 1' },
{ id: 'avatar2', emoji: '👨🏼', label: 'Man 2' },
{ id: 'avatar3', emoji: '👨🏽', label: 'Man 3' },
{ id: 'avatar4', emoji: '👨🏾', label: 'Man 4' },
{ id: 'avatar5', emoji: '👩🏻', label: 'Woman 1' },
{ id: 'avatar6', emoji: '👩🏼', label: 'Woman 2' },
{ id: 'avatar7', emoji: '👩🏽', label: 'Woman 3' },
{ id: 'avatar8', emoji: '👩🏾', label: 'Woman 4' },
{ id: 'avatar9', emoji: '🧔🏻', label: 'Beard 1' },
{ id: 'avatar10', emoji: '🧔🏼', label: 'Beard 2' },
{ id: 'avatar11', emoji: '🧔🏽', label: 'Beard 3' },
{ id: 'avatar12', emoji: '🧔🏾', label: 'Beard 4' },
{ id: 'avatar13', emoji: '👳🏻‍♂️', label: 'Turban 1' },
{ id: 'avatar14', emoji: '👳🏼‍♂️', label: 'Turban 2' },
{ id: 'avatar15', emoji: '👳🏽‍♂️', label: 'Turban 3' },
{ id: 'avatar16', emoji: '🧕🏻', label: 'Hijab 1' },
{ id: 'avatar17', emoji: '🧕🏼', label: 'Hijab 2' },
{ id: 'avatar18', emoji: '🧕🏽', label: 'Hijab 3' },
{ id: 'avatar19', emoji: '👴🏻', label: 'Elder 1' },
{ id: 'avatar20', emoji: '👴🏼', label: 'Elder 2' },
{ id: 'avatar21', emoji: '👵🏻', label: 'Elder Woman 1' },
{ id: 'avatar22', emoji: '👵🏼', label: 'Elder Woman 2' },
{ id: 'avatar23', emoji: '👦🏻', label: 'Boy 1' },
{ id: 'avatar24', emoji: '👦🏼', label: 'Boy 2' },
{ id: 'avatar25', emoji: '👧🏻', label: 'Girl 1' },
{ id: 'avatar26', emoji: '👧🏼', label: 'Girl 2' },
];
interface AvatarPickerModalProps {
visible: boolean;
onClose: () => void;
currentAvatar?: string;
onAvatarSelected?: (avatarUrl: string) => void;
}
const AvatarPickerModal: React.FC<AvatarPickerModalProps> = ({
visible,
onClose,
currentAvatar,
onAvatarSelected,
}) => {
const { user } = useAuth();
const [selectedAvatar, setSelectedAvatar] = useState<string | null>(currentAvatar || null);
const [uploadedImageUri, setUploadedImageUri] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const handleAvatarSelect = (avatarId: string) => {
setSelectedAvatar(avatarId);
setUploadedImageUri(null); // Clear uploaded image when selecting from pool
};
const requestPermissions = async () => {
if (Platform.OS !== 'web') {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Permission Required',
'Sorry, we need camera roll permissions to upload your photo!'
);
return false;
}
}
return true;
};
const handlePickImage = async () => {
const hasPermission = await requestPermissions();
if (!hasPermission) return;
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setIsUploading(true);
const imageUri = result.assets[0].uri;
if (__DEV__) console.log('[AvatarPicker] Uploading image:', imageUri);
// Upload to Supabase Storage
const uploadedUrl = await uploadImageToSupabase(imageUri);
setIsUploading(false);
if (uploadedUrl) {
if (__DEV__) console.log('[AvatarPicker] Upload successful:', uploadedUrl);
setUploadedImageUri(uploadedUrl);
setSelectedAvatar(null); // Clear emoji selection
Alert.alert('Success', 'Photo uploaded successfully!');
} else {
if (__DEV__) console.error('[AvatarPicker] Upload failed: no URL returned');
Alert.alert('Upload Failed', 'Could not upload your photo. Please check your internet connection and try again.');
}
}
} catch (error) {
setIsUploading(false);
if (__DEV__) console.error('[AvatarPicker] Error picking image:', error);
Alert.alert('Error', 'Failed to pick image. Please try again.');
}
};
const uploadImageToSupabase = async (imageUri: string): Promise<string | null> => {
if (!user) {
if (__DEV__) console.error('[AvatarPicker] No user found');
return null;
}
try {
if (__DEV__) console.log('[AvatarPicker] Fetching image blob...');
// Convert image URI to blob for web, or use file for native
const response = await fetch(imageUri);
const blob = await response.blob();
if (__DEV__) console.log('[AvatarPicker] Blob size:', blob.size, 'bytes');
// Generate unique filename
const fileExt = imageUri.split('.').pop()?.toLowerCase() || 'jpg';
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `avatars/${fileName}`;
if (__DEV__) console.log('[AvatarPicker] Uploading to:', filePath);
// Upload to Supabase Storage
const { data: uploadData, error: uploadError } = await supabase.storage
.from('profiles')
.upload(filePath, blob, {
contentType: `image/${fileExt}`,
upsert: false,
});
if (uploadError) {
if (__DEV__) console.error('[AvatarPicker] Upload error:', uploadError);
return null;
}
if (__DEV__) console.log('[AvatarPicker] Upload successful:', uploadData);
// Get public URL
const { data } = supabase.storage
.from('profiles')
.getPublicUrl(filePath);
if (__DEV__) console.log('[AvatarPicker] Public URL:', data.publicUrl);
return data.publicUrl;
} catch (error) {
if (__DEV__) console.error('[AvatarPicker] Error uploading to Supabase:', error);
return null;
}
};
const handleSave = async () => {
const avatarToSave = uploadedImageUri || selectedAvatar;
if (!avatarToSave || !user) {
Alert.alert('Error', 'Please select an avatar or upload a photo');
return;
}
if (__DEV__) console.log('[AvatarPicker] Saving avatar:', avatarToSave);
setIsSaving(true);
try {
// Update avatar in Supabase profiles table
const { data, error } = await supabase
.from('profiles')
.update({ avatar_url: avatarToSave })
.eq('id', user.id)
.select();
if (error) {
if (__DEV__) console.error('[AvatarPicker] Save error:', error);
throw error;
}
if (__DEV__) console.log('[AvatarPicker] Avatar saved successfully:', data);
Alert.alert('Success', 'Avatar updated successfully!');
if (onAvatarSelected) {
onAvatarSelected(avatarToSave);
}
onClose();
} catch (error) {
if (__DEV__) console.error('[AvatarPicker] Error updating avatar:', error);
Alert.alert('Error', 'Failed to update avatar. Please try again.');
} finally {
setIsSaving(false);
}
};
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContainer}>
{/* Header */}
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Choose Your Avatar</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}></Text>
</TouchableOpacity>
</View>
{/* Upload Photo Button */}
<View style={styles.uploadSection}>
<TouchableOpacity
style={[styles.uploadButton, isUploading && styles.uploadButtonDisabled]}
onPress={handlePickImage}
disabled={isUploading}
>
{isUploading ? (
<ActivityIndicator color={KurdistanColors.spi} size="small" />
) : (
<>
<Text style={styles.uploadButtonIcon}>📷</Text>
<Text style={styles.uploadButtonText}>Upload Your Photo</Text>
</>
)}
</TouchableOpacity>
{/* Uploaded Image Preview */}
{uploadedImageUri && (
<View style={styles.uploadedPreview}>
<Image source={{ uri: uploadedImageUri }} style={styles.uploadedImage} />
<TouchableOpacity
style={styles.removeUploadButton}
onPress={() => setUploadedImageUri(null)}
>
<Text style={styles.removeUploadText}></Text>
</TouchableOpacity>
</View>
)}
</View>
{/* Divider */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>OR CHOOSE FROM POOL</Text>
<View style={styles.dividerLine} />
</View>
{/* Avatar Grid */}
<ScrollView style={styles.avatarScroll} showsVerticalScrollIndicator={false}>
<View style={styles.avatarGrid}>
{AVATAR_POOL.map((avatar) => (
<TouchableOpacity
key={avatar.id}
style={[
styles.avatarOption,
selectedAvatar === avatar.id && styles.avatarOptionSelected,
]}
onPress={() => handleAvatarSelect(avatar.id)}
>
<Text style={styles.avatarEmoji}>{avatar.emoji}</Text>
{selectedAvatar === avatar.id && (
<View style={styles.selectedBadge}>
<Text style={styles.selectedBadgeText}></Text>
</View>
)}
</TouchableOpacity>
))}
</View>
</ScrollView>
{/* Footer Actions */}
<View style={styles.modalFooter}>
<TouchableOpacity
style={styles.cancelButton}
onPress={onClose}
disabled={isSaving}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={isSaving}
>
{isSaving ? (
<ActivityIndicator color={KurdistanColors.spi} size="small" />
) : (
<Text style={styles.saveButtonText}>Save Avatar</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContainer: {
backgroundColor: KurdistanColors.spi,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: '80%',
paddingBottom: 20,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: KurdistanColors.reş,
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F0F0F0',
justifyContent: 'center',
alignItems: 'center',
},
closeButtonText: {
fontSize: 18,
color: '#666',
},
avatarScroll: {
padding: 20,
},
avatarGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
avatarOption: {
width: '22%',
aspectRatio: 1,
backgroundColor: '#F8F9FA',
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 12,
borderWidth: 3,
borderColor: 'transparent',
},
avatarOptionSelected: {
borderColor: KurdistanColors.kesk,
backgroundColor: '#E8F5E9',
},
avatarEmoji: {
fontSize: 36,
},
selectedBadge: {
position: 'absolute',
top: -4,
right: -4,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: KurdistanColors.kesk,
justifyContent: 'center',
alignItems: 'center',
},
selectedBadgeText: {
fontSize: 14,
color: KurdistanColors.spi,
fontWeight: 'bold',
},
modalFooter: {
flexDirection: 'row',
paddingHorizontal: 20,
paddingTop: 16,
gap: 12,
},
cancelButton: {
flex: 1,
padding: 16,
borderRadius: 12,
backgroundColor: '#F0F0F0',
alignItems: 'center',
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#666',
},
saveButton: {
flex: 1,
padding: 16,
borderRadius: 12,
backgroundColor: KurdistanColors.kesk,
alignItems: 'center',
},
saveButtonDisabled: {
opacity: 0.6,
},
saveButtonText: {
fontSize: 16,
fontWeight: 'bold',
color: KurdistanColors.spi,
},
uploadSection: {
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
uploadButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: KurdistanColors.kesk,
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: 12,
gap: 8,
},
uploadButtonDisabled: {
opacity: 0.6,
},
uploadButtonIcon: {
fontSize: 20,
},
uploadButtonText: {
fontSize: 16,
fontWeight: '600',
color: KurdistanColors.spi,
},
uploadedPreview: {
marginTop: 12,
alignItems: 'center',
position: 'relative',
},
uploadedImage: {
width: 100,
height: 100,
borderRadius: 50,
borderWidth: 3,
borderColor: KurdistanColors.kesk,
},
removeUploadButton: {
position: 'absolute',
top: -4,
right: '38%',
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: KurdistanColors.sor,
justifyContent: 'center',
alignItems: 'center',
boxShadow: '0px 2px 3px rgba(0, 0, 0, 0.3)',
elevation: 4,
},
removeUploadText: {
color: KurdistanColors.spi,
fontSize: 14,
fontWeight: 'bold',
},
divider: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: '#E0E0E0',
},
dividerText: {
fontSize: 11,
fontWeight: '600',
color: '#999',
marginHorizontal: 12,
letterSpacing: 0.5,
},
});
export default AvatarPickerModal;
@@ -0,0 +1,515 @@
import React, { useState } from 'react';
import {
View,
Text,
Modal,
TouchableOpacity,
StyleSheet,
ScrollView,
Image,
Alert,
ActivityIndicator,
Platform,
} from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { KurdistanColors } from '../theme/colors';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../lib/supabase';
// Avatar pool - Kurdish/Middle Eastern themed avatars
const AVATAR_POOL = [
{ id: 'avatar1', emoji: '👨🏻', label: 'Man 1' },
{ id: 'avatar2', emoji: '👨🏼', label: 'Man 2' },
{ id: 'avatar3', emoji: '👨🏽', label: 'Man 3' },
{ id: 'avatar4', emoji: '👨🏾', label: 'Man 4' },
{ id: 'avatar5', emoji: '👩🏻', label: 'Woman 1' },
{ id: 'avatar6', emoji: '👩🏼', label: 'Woman 2' },
{ id: 'avatar7', emoji: '👩🏽', label: 'Woman 3' },
{ id: 'avatar8', emoji: '👩🏾', label: 'Woman 4' },
{ id: 'avatar9', emoji: '🧔🏻', label: 'Beard 1' },
{ id: 'avatar10', emoji: '🧔🏼', label: 'Beard 2' },
{ id: 'avatar11', emoji: '🧔🏽', label: 'Beard 3' },
{ id: 'avatar12', emoji: '🧔🏾', label: 'Beard 4' },
{ id: 'avatar13', emoji: '👳🏻‍♂️', label: 'Turban 1' },
{ id: 'avatar14', emoji: '👳🏼‍♂️', label: 'Turban 2' },
{ id: 'avatar15', emoji: '👳🏽‍♂️', label: 'Turban 3' },
{ id: 'avatar16', emoji: '🧕🏻', label: 'Hijab 1' },
{ id: 'avatar17', emoji: '🧕🏼', label: 'Hijab 2' },
{ id: 'avatar18', emoji: '🧕🏽', label: 'Hijab 3' },
{ id: 'avatar19', emoji: '👴🏻', label: 'Elder 1' },
{ id: 'avatar20', emoji: '👴🏼', label: 'Elder 2' },
{ id: 'avatar21', emoji: '👵🏻', label: 'Elder Woman 1' },
{ id: 'avatar22', emoji: '👵🏼', label: 'Elder Woman 2' },
{ id: 'avatar23', emoji: '👦🏻', label: 'Boy 1' },
{ id: 'avatar24', emoji: '👦🏼', label: 'Boy 2' },
{ id: 'avatar25', emoji: '👧🏻', label: 'Girl 1' },
{ id: 'avatar26', emoji: '👧🏼', label: 'Girl 2' },
];
interface AvatarPickerModalProps {
visible: boolean;
onClose: () => void;
currentAvatar?: string;
onAvatarSelected?: (avatarUrl: string) => void;
}
const AvatarPickerModal: React.FC<AvatarPickerModalProps> = ({
visible,
onClose,
currentAvatar,
onAvatarSelected,
}) => {
const { user } = useAuth();
const [selectedAvatar, setSelectedAvatar] = useState<string | null>(currentAvatar || null);
const [uploadedImageUri, setUploadedImageUri] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const handleAvatarSelect = (avatarId: string) => {
setSelectedAvatar(avatarId);
setUploadedImageUri(null); // Clear uploaded image when selecting from pool
};
const requestPermissions = async () => {
if (Platform.OS !== 'web') {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Permission Required',
'Sorry, we need camera roll permissions to upload your photo!'
);
return false;
}
}
return true;
};
const handlePickImage = async () => {
const hasPermission = await requestPermissions();
if (!hasPermission) return;
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setIsUploading(true);
const imageUri = result.assets[0].uri;
if (__DEV__) console.log('[AvatarPicker] Uploading image:', imageUri);
// Upload to Supabase Storage
const uploadedUrl = await uploadImageToSupabase(imageUri);
setIsUploading(false);
if (uploadedUrl) {
if (__DEV__) console.log('[AvatarPicker] Upload successful:', uploadedUrl);
setUploadedImageUri(uploadedUrl);
setSelectedAvatar(null); // Clear emoji selection
Alert.alert('Success', 'Photo uploaded successfully!');
} else {
if (__DEV__) console.error('[AvatarPicker] Upload failed: no URL returned');
Alert.alert('Upload Failed', 'Could not upload your photo. Please check your internet connection and try again.');
}
}
} catch (error) {
setIsUploading(false);
if (__DEV__) console.error('[AvatarPicker] Error picking image:', error);
Alert.alert('Error', 'Failed to pick image. Please try again.');
}
};
const uploadImageToSupabase = async (imageUri: string): Promise<string | null> => {
if (!user) {
if (__DEV__) console.error('[AvatarPicker] No user found');
return null;
}
try {
if (__DEV__) console.log('[AvatarPicker] Fetching image blob...');
// Convert image URI to blob for web, or use file for native
const response = await fetch(imageUri);
const blob = await response.blob();
if (__DEV__) console.log('[AvatarPicker] Blob size:', blob.size, 'bytes');
// Generate unique filename
const fileExt = imageUri.split('.').pop()?.toLowerCase() || 'jpg';
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
const filePath = `avatars/${fileName}`;
if (__DEV__) console.log('[AvatarPicker] Uploading to:', filePath);
// Upload to Supabase Storage
const { data: uploadData, error: uploadError } = await supabase.storage
.from('profiles')
.upload(filePath, blob, {
contentType: `image/${fileExt}`,
upsert: false,
});
if (uploadError) {
if (__DEV__) console.error('[AvatarPicker] Upload error:', uploadError);
return null;
}
if (__DEV__) console.log('[AvatarPicker] Upload successful:', uploadData);
// Get public URL
const { data } = supabase.storage
.from('profiles')
.getPublicUrl(filePath);
if (__DEV__) console.log('[AvatarPicker] Public URL:', data.publicUrl);
return data.publicUrl;
} catch (error) {
if (__DEV__) console.error('[AvatarPicker] Error uploading to Supabase:', error);
return null;
}
};
const handleSave = async () => {
const avatarToSave = uploadedImageUri || selectedAvatar;
if (!avatarToSave || !user) {
Alert.alert('Error', 'Please select an avatar or upload a photo');
return;
}
if (__DEV__) console.log('[AvatarPicker] Saving avatar:', avatarToSave);
setIsSaving(true);
try {
// Update avatar in Supabase profiles table
const { data, error } = await supabase
.from('profiles')
.update({ avatar_url: avatarToSave })
.eq('id', user.id)
.select();
if (error) {
if (__DEV__) console.error('[AvatarPicker] Save error:', error);
throw error;
}
if (__DEV__) console.log('[AvatarPicker] Avatar saved successfully:', data);
Alert.alert('Success', 'Avatar updated successfully!');
if (onAvatarSelected) {
onAvatarSelected(avatarToSave);
}
onClose();
} catch (error) {
if (__DEV__) console.error('[AvatarPicker] Error updating avatar:', error);
Alert.alert('Error', 'Failed to update avatar. Please try again.');
} finally {
setIsSaving(false);
}
};
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContainer}>
{/* Header */}
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Choose Your Avatar</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}>✕</Text>
</TouchableOpacity>
</View>
{/* Upload Photo Button */}
<View style={styles.uploadSection}>
<TouchableOpacity
style={[styles.uploadButton, isUploading && styles.uploadButtonDisabled]}
onPress={handlePickImage}
disabled={isUploading}
>
{isUploading ? (
<ActivityIndicator color={KurdistanColors.spi} size="small" />
) : (
<>
<Text style={styles.uploadButtonIcon}>📷</Text>
<Text style={styles.uploadButtonText}>Upload Your Photo</Text>
</>
)}
</TouchableOpacity>
{/* Uploaded Image Preview */}
{uploadedImageUri && (
<View style={styles.uploadedPreview}>
<Image source={{ uri: uploadedImageUri }} style={styles.uploadedImage} />
<TouchableOpacity
style={styles.removeUploadButton}
onPress={() => setUploadedImageUri(null)}
>
<Text style={styles.removeUploadText}>✕</Text>
</TouchableOpacity>
</View>
)}
</View>
{/* Divider */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>OR CHOOSE FROM POOL</Text>
<View style={styles.dividerLine} />
</View>
{/* Avatar Grid */}
<ScrollView style={styles.avatarScroll} showsVerticalScrollIndicator={false}>
<View style={styles.avatarGrid}>
{AVATAR_POOL.map((avatar) => (
<TouchableOpacity
key={avatar.id}
style={[
styles.avatarOption,
selectedAvatar === avatar.id && styles.avatarOptionSelected,
]}
onPress={() => handleAvatarSelect(avatar.id)}
>
<Text style={styles.avatarEmoji}>{avatar.emoji}</Text>
{selectedAvatar === avatar.id && (
<View style={styles.selectedBadge}>
<Text style={styles.selectedBadgeText}>✓</Text>
</View>
)}
</TouchableOpacity>
))}
</View>
</ScrollView>
{/* Footer Actions */}
<View style={styles.modalFooter}>
<TouchableOpacity
style={styles.cancelButton}
onPress={onClose}
disabled={isSaving}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={isSaving}
>
{isSaving ? (
<ActivityIndicator color={KurdistanColors.spi} size="small" />
) : (
<Text style={styles.saveButtonText}>Save Avatar</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContainer: {
backgroundColor: KurdistanColors.spi,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: '80%',
paddingBottom: 20,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: KurdistanColors.reş,
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F0F0F0',
justifyContent: 'center',
alignItems: 'center',
},
closeButtonText: {
fontSize: 18,
color: '#666',
},
avatarScroll: {
padding: 20,
},
avatarGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
avatarOption: {
width: '22%',
aspectRatio: 1,
backgroundColor: '#F8F9FA',
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 12,
borderWidth: 3,
borderColor: 'transparent',
},
avatarOptionSelected: {
borderColor: KurdistanColors.kesk,
backgroundColor: '#E8F5E9',
},
avatarEmoji: {
fontSize: 36,
},
selectedBadge: {
position: 'absolute',
top: -4,
right: -4,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: KurdistanColors.kesk,
justifyContent: 'center',
alignItems: 'center',
},
selectedBadgeText: {
fontSize: 14,
color: KurdistanColors.spi,
fontWeight: 'bold',
},
modalFooter: {
flexDirection: 'row',
paddingHorizontal: 20,
paddingTop: 16,
gap: 12,
},
cancelButton: {
flex: 1,
padding: 16,
borderRadius: 12,
backgroundColor: '#F0F0F0',
alignItems: 'center',
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#666',
},
saveButton: {
flex: 1,
padding: 16,
borderRadius: 12,
backgroundColor: KurdistanColors.kesk,
alignItems: 'center',
},
saveButtonDisabled: {
opacity: 0.6,
},
saveButtonText: {
fontSize: 16,
fontWeight: 'bold',
color: KurdistanColors.spi,
},
uploadSection: {
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
uploadButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: KurdistanColors.kesk,
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: 12,
gap: 8,
},
uploadButtonDisabled: {
opacity: 0.6,
},
uploadButtonIcon: {
fontSize: 20,
},
uploadButtonText: {
fontSize: 16,
fontWeight: '600',
color: KurdistanColors.spi,
},
uploadedPreview: {
marginTop: 12,
alignItems: 'center',
position: 'relative',
},
uploadedImage: {
width: 100,
height: 100,
borderRadius: 50,
borderWidth: 3,
borderColor: KurdistanColors.kesk,
},
removeUploadButton: {
position: 'absolute',
top: -4,
right: '38%',
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: KurdistanColors.sor,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 3,
elevation: 4,
},
removeUploadText: {
color: KurdistanColors.spi,
fontSize: 14,
fontWeight: 'bold',
},
divider: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: '#E0E0E0',
},
dividerText: {
fontSize: 11,
fontWeight: '600',
color: '#999',
marginHorizontal: 12,
letterSpacing: 0.5,
},
});
export default AvatarPickerModal;
+1 -4
View File
@@ -66,10 +66,7 @@ const styles = StyleSheet.create({
padding: 16,
borderRadius: 12,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)',
elevation: 3,
},
row: {
+120
View File
@@ -0,0 +1,120 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { TokenIcon } from './TokenIcon';
import { KurdistanColors } from '../theme/colors';
interface BalanceCardProps {
symbol: string;
name: string;
balance: string;
value?: string;
change?: string;
onPress?: () => void;
}
export const BalanceCard: React.FC<BalanceCardProps> = ({
symbol,
name,
balance,
value,
change,
onPress,
}) => {
const changeValue = parseFloat(change || '0');
const isPositive = changeValue >= 0;
return (
<TouchableOpacity
style={styles.container}
onPress={onPress}
disabled={!onPress}
activeOpacity={0.7}
>
<View style={styles.row}>
<TokenIcon symbol={symbol} size={40} />
<View style={styles.info}>
<View style={styles.nameRow}>
<Text style={styles.symbol}>{symbol}</Text>
<Text style={styles.balance}>{balance}</Text>
</View>
<View style={styles.detailsRow}>
<Text style={styles.name}>{name}</Text>
{value && <Text style={styles.value}>{value}</Text>}
</View>
</View>
</View>
{change && (
<View style={styles.changeContainer}>
<Text
style={[
styles.change,
{ color: isPositive ? KurdistanColors.kesk : KurdistanColors.sor },
]}
>
{isPositive ? '+' : ''}
{change}
</Text>
</View>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
padding: 16,
borderRadius: 12,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
row: {
flexDirection: 'row',
alignItems: 'center',
},
info: {
flex: 1,
marginLeft: 12,
},
nameRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
symbol: {
fontSize: 18,
fontWeight: '700',
color: '#000',
},
balance: {
fontSize: 18,
fontWeight: '600',
color: '#000',
},
detailsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
name: {
fontSize: 14,
color: '#666',
},
value: {
fontSize: 14,
color: '#666',
},
changeContainer: {
marginTop: 8,
alignItems: 'flex-end',
},
change: {
fontSize: 12,
fontWeight: '600',
},
});
+1 -4
View File
@@ -130,10 +130,7 @@ const styles = StyleSheet.create({
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingBottom: 34, // Safe area
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.15,
shadowRadius: 12,
boxShadow: '0px -4px 12px rgba(0, 0, 0, 0.15)',
elevation: 20,
},
handleContainer: {
+165
View File
@@ -0,0 +1,165 @@
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
Modal,
Animated,
Pressable,
StyleSheet,
Dimensions,
PanResponder,
} from 'react-native';
import { AppColors } from '../theme/colors';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
interface BottomSheetProps {
visible: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
height?: number;
showHandle?: boolean;
}
/**
* Modern Bottom Sheet Component
* Swipe to dismiss, smooth animations
*/
export const BottomSheet: React.FC<BottomSheetProps> = ({
visible,
onClose,
title,
children,
height = SCREEN_HEIGHT * 0.6,
showHandle = true,
}) => {
const translateY = useRef(new Animated.Value(height)).current;
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: (_, gestureState) => {
return gestureState.dy > 5;
},
onPanResponderMove: (_, gestureState) => {
if (gestureState.dy > 0) {
translateY.setValue(gestureState.dy);
}
},
onPanResponderRelease: (_, gestureState) => {
if (gestureState.dy > 100) {
closeSheet();
} else {
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
}).start();
}
},
})
).current;
const openSheet = React.useCallback(() => {
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
damping: 20,
}).start();
}, [translateY]);
useEffect(() => {
if (visible) {
openSheet();
}
}, [visible, openSheet]);
const closeSheet = () => {
Animated.timing(translateY, {
toValue: height,
duration: 250,
useNativeDriver: true,
}).start(() => {
onClose();
});
};
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={closeSheet}
>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={closeSheet} />
<Animated.View
style={[
styles.sheet,
{ height, transform: [{ translateY }] },
]}
{...panResponder.panHandlers}
>
{showHandle && (
<View style={styles.handleContainer}>
<View style={styles.handle} />
</View>
)}
{title && (
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
</View>
)}
<View style={styles.content}>{children}</View>
</Animated.View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
sheet: {
backgroundColor: AppColors.surface,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingBottom: 34, // Safe area
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 20,
},
handleContainer: {
alignItems: 'center',
paddingTop: 12,
paddingBottom: 8,
},
handle: {
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: AppColors.border,
},
header: {
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
title: {
fontSize: 20,
fontWeight: '700',
color: AppColors.text,
},
content: {
flex: 1,
padding: 20,
},
});
+4 -13
View File
@@ -95,18 +95,12 @@ const styles = StyleSheet.create({
// Variants
primary: {
backgroundColor: KurdistanColors.kesk,
shadowColor: KurdistanColors.kesk,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
boxShadow: '0px 4px 8px rgba(0, 128, 0, 0.3)',
elevation: 4,
},
secondary: {
backgroundColor: KurdistanColors.zer,
shadowColor: KurdistanColors.zer,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 6,
boxShadow: '0px 4px 6px rgba(255, 215, 0, 0.2)',
elevation: 3,
},
outline: {
@@ -119,10 +113,7 @@ const styles = StyleSheet.create({
},
danger: {
backgroundColor: KurdistanColors.sor,
shadowColor: KurdistanColors.sor,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
boxShadow: '0px 4px 8px rgba(255, 0, 0, 0.3)',
elevation: 4,
},
// Sizes
@@ -146,7 +137,7 @@ const styles = StyleSheet.create({
},
disabled: {
opacity: 0.5,
shadowOpacity: 0,
boxShadow: 'none',
elevation: 0,
},
pressed: {
+188
View File
@@ -0,0 +1,188 @@
import React from 'react';
import {
Pressable,
Text,
StyleSheet,
ActivityIndicator,
ViewStyle,
TextStyle,
} from 'react-native';
import { AppColors, KurdistanColors } from '../theme/colors';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'small' | 'medium' | 'large';
loading?: boolean;
disabled?: boolean;
fullWidth?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
icon?: React.ReactNode;
testID?: string;
}
/**
* Modern Button Component
* Uses Kurdistan colors for primary branding
*/
export const Button: React.FC<ButtonProps> = ({
title,
onPress,
variant = 'primary',
size = 'medium',
loading = false,
disabled = false,
fullWidth = false,
style,
textStyle,
icon,
testID,
}) => {
const isDisabled = disabled || loading;
const buttonStyle = [
styles.base,
styles[variant],
styles[`${size}Size`],
fullWidth && styles.fullWidth,
isDisabled && styles.disabled,
style,
];
const textStyles = [
styles.text,
styles[`${variant}Text`],
styles[`${size}Text`],
isDisabled && styles.disabledText,
textStyle,
];
return (
<Pressable
testID={testID}
onPress={onPress}
disabled={isDisabled}
style={({ pressed }) => [
...buttonStyle,
pressed && !isDisabled && styles.pressed,
]}
>
{loading ? (
<ActivityIndicator
color={variant === 'primary' || variant === 'danger' ? '#FFFFFF' : AppColors.primary}
/>
) : (
<>
{icon}
<Text style={textStyles}>{title}</Text>
</>
)}
</Pressable>
);
};
const styles = StyleSheet.create({
base: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
paddingHorizontal: 24,
paddingVertical: 12,
},
// Variants
primary: {
backgroundColor: KurdistanColors.kesk,
shadowColor: KurdistanColors.kesk,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
secondary: {
backgroundColor: KurdistanColors.zer,
shadowColor: KurdistanColors.zer,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 6,
elevation: 3,
},
outline: {
backgroundColor: 'transparent',
borderWidth: 2,
borderColor: KurdistanColors.kesk,
},
ghost: {
backgroundColor: 'transparent',
},
danger: {
backgroundColor: KurdistanColors.sor,
shadowColor: KurdistanColors.sor,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
// Sizes
smallSize: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
mediumSize: {
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 12,
},
largeSize: {
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 16,
},
fullWidth: {
width: '100%',
},
disabled: {
opacity: 0.5,
shadowOpacity: 0,
elevation: 0,
},
pressed: {
opacity: 0.8,
transform: [{ scale: 0.97 }],
},
// Text styles
text: {
fontWeight: '600',
textAlign: 'center',
},
primaryText: {
color: '#FFFFFF',
},
secondaryText: {
color: '#000000',
},
outlineText: {
color: KurdistanColors.kesk,
},
ghostText: {
color: KurdistanColors.kesk,
},
dangerText: {
color: '#FFFFFF',
},
smallText: {
fontSize: 14,
},
mediumText: {
fontSize: 16,
},
largeText: {
fontSize: 18,
},
disabledText: {
opacity: 0.7,
},
});
+3 -6
View File
@@ -72,21 +72,18 @@ const styles = StyleSheet.create({
marginBottom: 12,
},
elevated: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.1)',
elevation: 4,
},
outlined: {
borderWidth: 1,
borderColor: AppColors.border,
shadowOpacity: 0,
boxShadow: 'none',
elevation: 0,
},
filled: {
backgroundColor: AppColors.background,
shadowOpacity: 0,
boxShadow: 'none',
elevation: 0,
},
pressed: {
+96
View File
@@ -0,0 +1,96 @@
import React from 'react';
import { View, StyleSheet, ViewStyle, Pressable, Text } from 'react-native';
import { AppColors } from '../theme/colors';
interface CardProps {
children: React.ReactNode;
title?: string;
style?: ViewStyle;
onPress?: () => void;
variant?: 'elevated' | 'outlined' | 'filled';
testID?: string;
elevation?: number;
}
/**
* Modern Card Component
* Inspired by Material Design 3 and Kurdistan aesthetics
*/
export const Card: React.FC<CardProps> = ({
children,
title,
style,
onPress,
variant = 'elevated',
testID,
elevation,
}) => {
const cardStyle = [
styles.card,
variant === 'elevated' && styles.elevated,
variant === 'outlined' && styles.outlined,
variant === 'filled' && styles.filled,
elevation && { elevation },
style,
];
const content = (
<>
{title && <Text style={styles.title}>{title}</Text>}
{children}
</>
);
if (onPress) {
return (
<Pressable
testID={testID}
onPress={onPress}
style={({ pressed }) => [
...cardStyle,
pressed && styles.pressed,
]}
>
{content}
</Pressable>
);
}
return <View testID={testID} style={cardStyle}>{content}</View>;
};
const styles = StyleSheet.create({
card: {
borderRadius: 16,
padding: 16,
backgroundColor: AppColors.surface,
},
title: {
fontSize: 18,
fontWeight: '600',
color: AppColors.text,
marginBottom: 12,
},
elevated: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
outlined: {
borderWidth: 1,
borderColor: AppColors.border,
shadowOpacity: 0,
elevation: 0,
},
filled: {
backgroundColor: AppColors.background,
shadowOpacity: 0,
elevation: 0,
},
pressed: {
opacity: 0.7,
transform: [{ scale: 0.98 }],
},
});
+1 -4
View File
@@ -115,10 +115,7 @@ const styles = StyleSheet.create({
inputContainerFocused: {
borderColor: KurdistanColors.kesk,
borderWidth: 2,
shadowColor: KurdistanColors.kesk,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
boxShadow: '0px 2px 4px rgba(0, 128, 0, 0.1)',
elevation: 2,
},
inputContainerError: {
+154
View File
@@ -0,0 +1,154 @@
import React, { useState } from 'react';
import {
TextInput,
View,
Text,
StyleSheet,
TextInputProps,
Pressable,
} from 'react-native';
import { AppColors, KurdistanColors } from '../theme/colors';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
helperText?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
onRightIconPress?: () => void;
}
/**
* Modern Input Component
* Floating label, validation states, icons
*/
export const Input: React.FC<InputProps> = ({
label,
error,
helperText,
leftIcon,
rightIcon,
onRightIconPress,
style,
...props
}) => {
const [isFocused, setIsFocused] = useState(false);
const hasValue = props.value && props.value.length > 0;
return (
<View style={styles.container}>
{label && (
<Text
style={[
styles.label,
(isFocused || hasValue) && styles.labelFocused,
error && styles.labelError,
]}
>
{label}
</Text>
)}
<View
style={[
styles.inputContainer,
isFocused && styles.inputContainerFocused,
error && styles.inputContainerError,
]}
>
{leftIcon && <View style={styles.leftIcon}>{leftIcon}</View>}
<TextInput
{...props}
editable={props.editable !== undefined ? props.editable : !props.disabled}
style={[styles.input, leftIcon && styles.inputWithLeftIcon, style]}
onFocus={(e) => {
setIsFocused(true);
props.onFocus?.(e);
}}
onBlur={(e) => {
setIsFocused(false);
props.onBlur?.(e);
}}
placeholderTextColor={AppColors.textSecondary}
/>
{rightIcon && (
<Pressable onPress={onRightIconPress} style={styles.rightIcon}>
{rightIcon}
</Pressable>
)}
</View>
{(error || helperText) && (
<Text style={[styles.helperText, error && styles.errorText]}>
{error || helperText}
</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '500',
color: AppColors.textSecondary,
marginBottom: 8,
transition: 'all 0.2s',
},
labelFocused: {
color: KurdistanColors.kesk,
},
labelError: {
color: KurdistanColors.sor,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surface,
borderWidth: 1.5,
borderColor: AppColors.border,
borderRadius: 12,
paddingHorizontal: 16,
minHeight: 52,
},
inputContainerFocused: {
borderColor: KurdistanColors.kesk,
borderWidth: 2,
shadowColor: KurdistanColors.kesk,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
inputContainerError: {
borderColor: KurdistanColors.sor,
},
input: {
flex: 1,
fontSize: 16,
color: AppColors.text,
paddingVertical: 12,
},
inputWithLeftIcon: {
marginLeft: 12,
},
leftIcon: {
justifyContent: 'center',
alignItems: 'center',
},
rightIcon: {
padding: 8,
justifyContent: 'center',
alignItems: 'center',
},
helperText: {
fontSize: 12,
color: AppColors.textSecondary,
marginTop: 4,
marginLeft: 16,
},
errorText: {
color: KurdistanColors.sor,
},
});
@@ -0,0 +1,81 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { usePezkuwi } from '../contexts/PezkuwiContext';
import { KurdistanColors } from '../theme/colors';
import { supabaseHelpers } from '../lib/supabase';
interface NotificationBellProps {
onPress: () => void;
style?: any;
}
export const NotificationBell: React.FC<NotificationBellProps> = ({ onPress, style }) => {
const { selectedAccount, api, isApiReady } = usePezkuwi();
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
if (!api || !isApiReady || !selectedAccount) {
setUnreadCount(0);
return;
}
// Fetch unread notification count from Supabase
const fetchUnreadCount = async () => {
try {
const count = await supabaseHelpers.getUnreadNotificationsCount(selectedAccount.address);
setUnreadCount(count);
} catch (error) {
console.error('Failed to fetch unread count:', error);
// If tables don't exist yet, set to 0
setUnreadCount(0);
}
};
fetchUnreadCount();
// Refresh every 30 seconds
const interval = setInterval(fetchUnreadCount, 30000);
return () => clearInterval(interval);
}, [api, isApiReady, selectedAccount]);
return (
<TouchableOpacity onPress={onPress} style={[styles.container, style]}>
<Text style={styles.bellIcon}>🔔</Text>
{unreadCount > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{unreadCount > 9 ? '9+' : unreadCount}</Text>
</View>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
position: 'relative',
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
bellIcon: {
fontSize: 24,
},
badge: {
position: 'absolute',
top: 4,
right: 4,
backgroundColor: KurdistanColors.sor,
borderRadius: 10,
minWidth: 20,
height: 20,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 4,
},
badgeText: {
color: '#FFFFFF',
fontSize: 11,
fontWeight: 'bold',
},
});
@@ -0,0 +1,455 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
Modal,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
} from 'react-native';
import { KurdistanColors } from '../theme/colors';
import { usePezkuwi } from '../contexts/PezkuwiContext';
import { supabaseHelpers } from '../lib/supabase';
interface Notification {
id: string;
type: 'transaction' | 'governance' | 'p2p' | 'referral' | 'system';
title: string;
message: string;
read: boolean;
timestamp: string;
}
interface NotificationCenterModalProps {
visible: boolean;
onClose: () => void;
}
// Notifications are stored in Supabase database
export const NotificationCenterModal: React.FC<NotificationCenterModalProps> = ({
visible,
onClose,
}) => {
const { selectedAccount } = usePezkuwi();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (visible && selectedAccount) {
const fetchNotifications = async () => {
try {
setLoading(true);
// Fetch notifications from Supabase
const data = await supabaseHelpers.getUserNotifications(selectedAccount.address);
// Transform to match component interface
const transformed = data.map(n => ({
...n,
timestamp: n.created_at,
}));
setNotifications(transformed);
} catch (error) {
console.error('Failed to fetch notifications:', error);
// If tables don't exist yet, show empty state
setNotifications([]);
} finally {
setLoading(false);
}
};
fetchNotifications();
}
}, [visible, selectedAccount]);
const handleMarkAsRead = async (notificationId: string) => {
try {
// Update UI immediately
setNotifications(prev =>
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n))
);
// Update in Supabase
await supabaseHelpers.markNotificationAsRead(notificationId);
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
const handleMarkAllAsRead = async () => {
if (!selectedAccount) return;
try {
// Update UI immediately
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
// Update in Supabase
await supabaseHelpers.markAllNotificationsAsRead(selectedAccount.address);
Alert.alert('Success', 'All notifications marked as read');
} catch (error) {
console.error('Failed to mark all as read:', error);
Alert.alert('Error', 'Failed to update notifications');
}
};
const handleClearAll = () => {
Alert.alert(
'Clear All Notifications',
'Are you sure you want to clear all notifications?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
try {
setNotifications([]);
// TODO: Implement delete from Supabase when needed
// For now, just clear from UI
} catch (error) {
console.error('Failed to clear notifications:', error);
}
},
},
]
);
};
const getNotificationIcon = (type: string): string => {
const icons: Record<string, string> = {
transaction: '💰',
governance: '🏛️',
p2p: '🤝',
referral: '👥',
system: '⚙️',
};
return icons[type] || '📬';
};
const getNotificationColor = (type: string): string => {
const colors: Record<string, string> = {
transaction: KurdistanColors.kesk,
governance: '#3B82F6',
p2p: '#F59E0B',
referral: '#8B5CF6',
system: '#6B7280',
};
return colors[type] || '#666';
};
const formatTimestamp = (timestamp: string): string => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
const unreadCount = notifications.filter(n => !n.read).length;
const groupedNotifications = {
today: notifications.filter(n => {
const date = new Date(n.timestamp);
const today = new Date();
return date.toDateString() === today.toDateString();
}),
earlier: notifications.filter(n => {
const date = new Date(n.timestamp);
const today = new Date();
return date.toDateString() !== today.toDateString();
}),
};
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
{/* Header */}
<View style={styles.header}>
<View>
<Text style={styles.headerTitle}>Notifications</Text>
{unreadCount > 0 && (
<Text style={styles.headerSubtitle}>{unreadCount} unread</Text>
)}
</View>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}></Text>
</TouchableOpacity>
</View>
{/* Actions */}
{notifications.length > 0 && (
<View style={styles.actions}>
{unreadCount > 0 && (
<TouchableOpacity onPress={handleMarkAllAsRead} style={styles.actionButton}>
<Text style={styles.actionButtonText}>Mark all as read</Text>
</TouchableOpacity>
)}
<TouchableOpacity onPress={handleClearAll} style={styles.actionButton}>
<Text style={[styles.actionButtonText, styles.actionButtonDanger]}>Clear all</Text>
</TouchableOpacity>
</View>
)}
{/* Notifications List */}
<ScrollView style={styles.notificationsList} showsVerticalScrollIndicator={false}>
{notifications.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyStateIcon}>📬</Text>
<Text style={styles.emptyStateText}>No notifications</Text>
<Text style={styles.emptyStateSubtext}>You're all caught up!</Text>
</View>
) : (
<>
{/* Today */}
{groupedNotifications.today.length > 0 && (
<>
<Text style={styles.sectionTitle}>Today</Text>
{groupedNotifications.today.map((notification) => (
<TouchableOpacity
key={notification.id}
style={[
styles.notificationCard,
!notification.read && styles.notificationCardUnread,
]}
onPress={() => handleMarkAsRead(notification.id)}
>
<View
style={[
styles.notificationIcon,
{ backgroundColor: `${getNotificationColor(notification.type)}15` },
]}
>
<Text style={styles.notificationIconText}>
{getNotificationIcon(notification.type)}
</Text>
</View>
<View style={styles.notificationContent}>
<View style={styles.notificationHeader}>
<Text style={styles.notificationTitle}>{notification.title}</Text>
{!notification.read && <View style={styles.unreadDot} />}
</View>
<Text style={styles.notificationMessage} numberOfLines={2}>
{notification.message}
</Text>
<Text style={styles.notificationTime}>
{formatTimestamp(notification.timestamp)}
</Text>
</View>
</TouchableOpacity>
))}
</>
)}
{/* Earlier */}
{groupedNotifications.earlier.length > 0 && (
<>
<Text style={styles.sectionTitle}>Earlier</Text>
{groupedNotifications.earlier.map((notification) => (
<TouchableOpacity
key={notification.id}
style={[
styles.notificationCard,
!notification.read && styles.notificationCardUnread,
]}
onPress={() => handleMarkAsRead(notification.id)}
>
<View
style={[
styles.notificationIcon,
{ backgroundColor: `${getNotificationColor(notification.type)}15` },
]}
>
<Text style={styles.notificationIconText}>
{getNotificationIcon(notification.type)}
</Text>
</View>
<View style={styles.notificationContent}>
<View style={styles.notificationHeader}>
<Text style={styles.notificationTitle}>{notification.title}</Text>
{!notification.read && <View style={styles.unreadDot} />}
</View>
<Text style={styles.notificationMessage} numberOfLines={2}>
{notification.message}
</Text>
<Text style={styles.notificationTime}>
{formatTimestamp(notification.timestamp)}
</Text>
</View>
</TouchableOpacity>
))}
</>
)}
</>
)}
</ScrollView>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: '85%',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
borderBottomWidth: 1,
borderBottomColor: '#F0F0F0',
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#333',
},
headerSubtitle: {
fontSize: 14,
color: KurdistanColors.kesk,
marginTop: 2,
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F5F5F5',
justifyContent: 'center',
alignItems: 'center',
},
closeButtonText: {
fontSize: 18,
color: '#666',
},
actions: {
flexDirection: 'row',
gap: 12,
paddingHorizontal: 20,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#F0F0F0',
},
actionButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
backgroundColor: '#F5F5F5',
},
actionButtonText: {
fontSize: 13,
fontWeight: '500',
color: '#666',
},
actionButtonDanger: {
color: '#EF4444',
},
notificationsList: {
flex: 1,
padding: 20,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
color: '#999',
marginBottom: 12,
marginTop: 8,
},
notificationCard: {
flexDirection: 'row',
backgroundColor: '#FFFFFF',
borderRadius: 12,
padding: 12,
marginBottom: 12,
borderWidth: 1,
borderColor: '#F0F0F0',
},
notificationCardUnread: {
backgroundColor: '#F8F9FA',
borderColor: KurdistanColors.kesk,
},
notificationIcon: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
notificationIconText: {
fontSize: 20,
},
notificationContent: {
flex: 1,
},
notificationHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
},
notificationTitle: {
fontSize: 14,
fontWeight: '600',
color: '#333',
flex: 1,
},
unreadDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: KurdistanColors.kesk,
marginLeft: 8,
},
notificationMessage: {
fontSize: 13,
color: '#666',
lineHeight: 18,
marginBottom: 4,
},
notificationTime: {
fontSize: 11,
color: '#999',
},
emptyState: {
alignItems: 'center',
paddingVertical: 60,
},
emptyStateIcon: {
fontSize: 64,
marginBottom: 16,
},
emptyStateText: {
fontSize: 18,
fontWeight: '600',
color: '#333',
marginBottom: 8,
},
emptyStateSubtext: {
fontSize: 14,
color: '#999',
},
});
+406
View File
@@ -0,0 +1,406 @@
import React, { useRef, useState, useCallback } from 'react';
import {
View,
StyleSheet,
ActivityIndicator,
Text,
TouchableOpacity,
BackHandler,
Platform,
} from 'react-native';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { useFocusEffect } from '@react-navigation/native';
import { KurdistanColors } from '../theme/colors';
import { usePezkuwi } from '../contexts/PezkuwiContext';
// Base URL for the web app
const WEB_BASE_URL = 'https://pezkuwichain.io';
export interface PezkuwiWebViewProps {
// The path to load (e.g., '/p2p', '/forum', '/elections')
path: string;
// Optional title for the header
title?: string;
// Callback when navigation state changes
onNavigationStateChange?: (canGoBack: boolean) => void;
}
interface WebViewMessage {
type: string;
payload?: unknown;
}
const PezkuwiWebView: React.FC<PezkuwiWebViewProps> = ({
path,
title,
onNavigationStateChange,
}) => {
const webViewRef = useRef<WebView>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [canGoBack, setCanGoBack] = useState(false);
const { selectedAccount, getKeyPair } = usePezkuwi();
// JavaScript to inject into the WebView
// This creates a bridge between the web app and native app
const injectedJavaScript = `
(function() {
// Mark this as mobile app
window.PEZKUWI_MOBILE = true;
window.PEZKUWI_PLATFORM = '${Platform.OS}';
// Inject wallet address if connected
${selectedAccount ? `window.PEZKUWI_ADDRESS = '${selectedAccount.address}';` : ''}
${selectedAccount ? `window.PEZKUWI_ACCOUNT_NAME = '${selectedAccount.meta?.name || 'Mobile Wallet'}';` : ''}
// Override console.log to send to React Native (for debugging)
const originalConsoleLog = console.log;
console.log = function(...args) {
originalConsoleLog.apply(console, args);
window.ReactNativeWebView?.postMessage(JSON.stringify({
type: 'CONSOLE_LOG',
payload: args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ')
}));
};
// Create native bridge for wallet operations
window.PezkuwiNativeBridge = {
// Request transaction signing from native wallet
signTransaction: function(extrinsicHex, callback) {
window.__pendingSignCallback = callback;
window.ReactNativeWebView?.postMessage(JSON.stringify({
type: 'SIGN_TRANSACTION',
payload: { extrinsicHex }
}));
},
// Request wallet connection
connectWallet: function() {
window.ReactNativeWebView?.postMessage(JSON.stringify({
type: 'CONNECT_WALLET'
}));
},
// Navigate back in native app
goBack: function() {
window.ReactNativeWebView?.postMessage(JSON.stringify({
type: 'GO_BACK'
}));
},
// Check if wallet is connected
isWalletConnected: function() {
return !!window.PEZKUWI_ADDRESS;
},
// Get connected address
getAddress: function() {
return window.PEZKUWI_ADDRESS || null;
}
};
// Notify web app that native bridge is ready
window.dispatchEvent(new CustomEvent('pezkuwi-native-ready', {
detail: {
address: window.PEZKUWI_ADDRESS,
platform: window.PEZKUWI_PLATFORM
}
}));
true; // Required for injectedJavaScript
})();
`;
// Handle messages from WebView
const handleMessage = useCallback(async (event: WebViewMessageEvent) => {
try {
const message: WebViewMessage = JSON.parse(event.nativeEvent.data);
switch (message.type) {
case 'SIGN_TRANSACTION':
// Handle transaction signing
if (!selectedAccount) {
// Send error back to WebView
webViewRef.current?.injectJavaScript(`
if (window.__pendingSignCallback) {
window.__pendingSignCallback(null, 'Wallet not connected');
delete window.__pendingSignCallback;
}
`);
return;
}
try {
const { extrinsicHex } = message.payload as { extrinsicHex: string };
const keyPair = await getKeyPair(selectedAccount.address);
if (!keyPair) {
throw new Error('Could not retrieve key pair');
}
// Sign the transaction
const signature = keyPair.sign(extrinsicHex);
const signatureHex = Buffer.from(signature).toString('hex');
// Send signature back to WebView
webViewRef.current?.injectJavaScript(`
if (window.__pendingSignCallback) {
window.__pendingSignCallback('${signatureHex}', null);
delete window.__pendingSignCallback;
}
`);
} catch (signError) {
webViewRef.current?.injectJavaScript(`
if (window.__pendingSignCallback) {
window.__pendingSignCallback(null, '${(signError as Error).message}');
delete window.__pendingSignCallback;
}
`);
}
break;
case 'CONNECT_WALLET':
// Trigger native wallet connection
// This would open a modal or navigate to wallet screen
if (__DEV__) console.log('WebView requested wallet connection');
break;
case 'GO_BACK':
// Handle back navigation from web
if (canGoBack && webViewRef.current) {
webViewRef.current.goBack();
}
break;
case 'CONSOLE_LOG':
// Forward console logs from WebView (debug only)
if (__DEV__) {
console.log('[WebView]:', message.payload);
}
break;
default:
if (__DEV__) {
console.log('Unknown message type:', message.type);
}
}
} catch (parseError) {
if (__DEV__) {
console.error('Failed to parse WebView message:', parseError);
}
}
}, [selectedAccount, getKeyPair, canGoBack]);
// Handle Android back button
useFocusEffect(
useCallback(() => {
const onBackPress = () => {
if (canGoBack && webViewRef.current) {
webViewRef.current.goBack();
return true; // Prevent default behavior
}
return false; // Allow default behavior
};
const subscription = BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => subscription.remove();
}, [canGoBack])
);
// Reload the WebView
const handleReload = () => {
setError(null);
setLoading(true);
webViewRef.current?.reload();
};
// Go back in WebView history
const handleGoBack = () => {
if (canGoBack && webViewRef.current) {
webViewRef.current.goBack();
}
};
// Build the full URL
const fullUrl = `${WEB_BASE_URL}${path}`;
// Error view
if (error) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorIcon}>!</Text>
<Text style={styles.errorTitle}>Connection Error</Text>
<Text style={styles.errorMessage}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={handleReload}>
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
{/* Optional header with back button */}
{title && (
<View style={styles.header}>
{canGoBack && (
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
<Text style={styles.backButtonText}>{'<'}</Text>
</TouchableOpacity>
)}
<Text style={styles.headerTitle}>{title}</Text>
<TouchableOpacity style={styles.reloadButton} onPress={handleReload}>
<Text style={styles.reloadButtonText}>Reload</Text>
</TouchableOpacity>
</View>
)}
{/* WebView */}
<WebView
ref={webViewRef}
source={{ uri: fullUrl }}
style={styles.webView}
injectedJavaScript={injectedJavaScript}
onMessage={handleMessage}
onLoadStart={() => setLoading(true)}
onLoadEnd={() => setLoading(false)}
onError={(syntheticEvent) => {
const { nativeEvent } = syntheticEvent;
setError(nativeEvent.description || 'Failed to load page');
setLoading(false);
}}
onHttpError={(syntheticEvent) => {
const { nativeEvent } = syntheticEvent;
if (nativeEvent.statusCode >= 400) {
setError(`HTTP Error: ${nativeEvent.statusCode}`);
}
}}
onNavigationStateChange={(navState) => {
setCanGoBack(navState.canGoBack);
onNavigationStateChange?.(navState.canGoBack);
}}
// Security settings
javaScriptEnabled={true}
domStorageEnabled={true}
sharedCookiesEnabled={true}
thirdPartyCookiesEnabled={true}
// Performance settings
cacheEnabled={true}
cacheMode="LOAD_DEFAULT"
// UI settings
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={true}
bounces={true}
pullToRefreshEnabled={true}
// Behavior settings
allowsBackForwardNavigationGestures={true}
allowsInlineMediaPlayback={true}
mediaPlaybackRequiresUserAction={false}
// Debugging (dev only)
webviewDebuggingEnabled={__DEV__}
/>
{/* Loading overlay */}
{loading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
<Text style={styles.loadingText}>Loading...</Text>
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#FFFFFF',
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
headerTitle: {
flex: 1,
fontSize: 18,
fontWeight: '700',
color: '#000',
textAlign: 'center',
},
backButton: {
padding: 8,
marginRight: 8,
},
backButtonText: {
fontSize: 24,
color: KurdistanColors.kesk,
fontWeight: '600',
},
reloadButton: {
padding: 8,
},
reloadButtonText: {
fontSize: 14,
color: KurdistanColors.kesk,
fontWeight: '600',
},
webView: {
flex: 1,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 14,
color: '#666',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
backgroundColor: '#FFFFFF',
},
errorIcon: {
fontSize: 48,
color: KurdistanColors.sor,
marginBottom: 16,
fontWeight: '700',
},
errorTitle: {
fontSize: 20,
fontWeight: '700',
color: '#000',
marginBottom: 8,
},
errorMessage: {
fontSize: 14,
color: '#666',
textAlign: 'center',
marginBottom: 24,
},
retryButton: {
backgroundColor: KurdistanColors.kesk,
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
retryButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
});
export default PezkuwiWebView;
@@ -0,0 +1,215 @@
import React from 'react';
import {
Modal,
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
SafeAreaView,
} from 'react-native';
import { KurdistanColors } from '../theme/colors';
interface PrivacyPolicyModalProps {
visible: boolean;
onClose: () => void;
}
const PrivacyPolicyModal: React.FC<PrivacyPolicyModalProps> = ({ visible, onClose }) => {
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Privacy Policy</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}></Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
<Text style={styles.sectionTitle}>Data Minimization Principle</Text>
<Text style={styles.paragraph}>
Pezkuwi collects the MINIMUM data necessary to provide blockchain wallet functionality.
We operate on a "your keys, your coins, your responsibility" model.
</Text>
<Text style={styles.sectionTitle}>What Data We Collect</Text>
<Text style={styles.subsectionTitle}>Stored LOCALLY on Your Device (NOT sent to Pezkuwi servers):</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Private Keys / Seed Phrase:</Text> Encrypted and stored in device secure storage</Text>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Account Balance:</Text> Cached from blockchain queries</Text>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Transaction History:</Text> Cached from blockchain queries</Text>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Settings:</Text> Language preference, theme, biometric settings</Text>
</View>
<Text style={styles.subsectionTitle}>Stored on Supabase (Third-party service):</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Profile Information:</Text> Username, email (if provided), avatar image</Text>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Citizenship Applications:</Text> Application data if you apply for citizenship</Text>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Forum Posts:</Text> Public posts and comments</Text>
</View>
<Text style={styles.subsectionTitle}>Stored on Blockchain (Public, immutable):</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Transactions:</Text> All transactions are publicly visible on PezkuwiChain</Text>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Account Address:</Text> Your public address is visible to all</Text>
</View>
<Text style={styles.subsectionTitle}>Never Collected:</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Browsing History:</Text> We don't track which screens you visit</Text>
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Device Identifiers:</Text> No IMEI, MAC address, or advertising ID collection</Text>
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Location Data:</Text> No GPS or location tracking</Text>
<Text style={styles.bulletItem}>• <Text style={styles.bold}>Contact Lists:</Text> We don't access your contacts</Text>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Third-party Analytics:</Text> No Google Analytics, Facebook Pixel, or similar trackers</Text>
</View>
<Text style={styles.sectionTitle}>Why We Need Permissions</Text>
<Text style={styles.subsectionTitle}>Internet (REQUIRED)</Text>
<Text style={styles.paragraph}>
Connect to PezkuwiChain blockchain RPC endpoint{'\n'}
Query balances and transaction history{'\n'}
Submit transactions
</Text>
<Text style={styles.subsectionTitle}>Storage (REQUIRED)</Text>
<Text style={styles.paragraph}>
Save encrypted seed phrase locally{'\n'}
Cache account data for offline viewing{'\n'}
Store profile avatar
</Text>
<Text style={styles.subsectionTitle}>Camera (OPTIONAL)</Text>
<Text style={styles.paragraph}>
Take profile photos{'\n'}
Scan QR codes for payments{'\n'}
Capture NFT images
</Text>
<Text style={styles.subsectionTitle}>Biometric (OPTIONAL)</Text>
<Text style={styles.paragraph}>
Secure authentication for transactions{'\n'}
Protect seed phrase viewing{'\n'}
Alternative to password entry
</Text>
<Text style={styles.subsectionTitle}>Notifications (OPTIONAL)</Text>
<Text style={styles.paragraph}>
Alert you to incoming transfers{'\n'}
Notify staking reward claims{'\n'}
Governance proposal notifications
</Text>
<Text style={styles.sectionTitle}>Zero-Knowledge Proofs & Encryption</Text>
<Text style={styles.paragraph}>
Citizenship applications are encrypted using ZK-proofs (Zero-Knowledge Proofs).
This means:
</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}> Your personal data is encrypted before storage</Text>
<Text style={styles.bulletItem}> Only a cryptographic hash is stored on the blockchain</Text>
<Text style={styles.bulletItem}> Your data is uploaded to IPFS (decentralized storage) in encrypted form</Text>
<Text style={styles.bulletItem}> Even if someone accesses the data, they cannot decrypt it without your private key</Text>
</View>
<Text style={styles.sectionTitle}>Your Data Rights</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Export Data:</Text> You can export your seed phrase and account data anytime</Text>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Delete Data:</Text> Delete your local data by uninstalling the app</Text>
<Text style={styles.bulletItem}> <Text style={styles.bold}>Supabase Data:</Text> Contact support@pezkuwichain.io to delete profile data</Text>
</View>
<Text style={styles.sectionTitle}>Contact</Text>
<Text style={styles.paragraph}>
For privacy concerns: privacy@pezkuwichain.io{'\n'}
General support: info@pezkuwichain.io
</Text>
<Text style={styles.footer}>
Last updated: {new Date().toLocaleDateString()}{'\n'}
© {new Date().getFullYear()} PezkuwiChain
</Text>
</ScrollView>
</SafeAreaView>
</Modal>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
color: KurdistanColors.reş,
},
closeButton: {
padding: 8,
},
closeButtonText: {
fontSize: 24,
color: KurdistanColors.reş,
},
content: {
flex: 1,
padding: 20,
},
sectionTitle: {
fontSize: 18,
fontWeight: 'bold',
color: KurdistanColors.kesk,
marginTop: 24,
marginBottom: 12,
},
subsectionTitle: {
fontSize: 16,
fontWeight: '600',
color: KurdistanColors.reş,
marginTop: 16,
marginBottom: 8,
},
paragraph: {
fontSize: 14,
lineHeight: 22,
color: '#333',
marginBottom: 12,
},
bulletList: {
marginBottom: 12,
},
bulletItem: {
fontSize: 14,
lineHeight: 22,
color: '#333',
marginBottom: 6,
},
bold: {
fontWeight: '600',
},
footer: {
fontSize: 12,
color: '#999',
textAlign: 'center',
marginTop: 32,
marginBottom: 32,
},
});
export default PrivacyPolicyModal;
@@ -0,0 +1,249 @@
import React from 'react';
import {
Modal,
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
SafeAreaView,
} from 'react-native';
import { KurdistanColors } from '../theme/colors';
interface TermsOfServiceModalProps {
visible: boolean;
onClose: () => void;
}
const TermsOfServiceModal: React.FC<TermsOfServiceModalProps> = ({ visible, onClose }) => {
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Terms of Service</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}></Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
<Text style={styles.sectionTitle}>1. Acceptance of Terms</Text>
<Text style={styles.paragraph}>
By accessing or using the Pezkuwi mobile application ("App"), you agree to be bound by these
Terms of Service ("Terms"). If you do not agree to these Terms, do not use the App.
</Text>
<Text style={styles.sectionTitle}>2. Description of Service</Text>
<Text style={styles.paragraph}>
Pezkuwi is a non-custodial blockchain wallet and governance platform that allows users to:
</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}> Manage blockchain accounts and private keys</Text>
<Text style={styles.bulletItem}> Send and receive cryptocurrency tokens</Text>
<Text style={styles.bulletItem}> Participate in decentralized governance</Text>
<Text style={styles.bulletItem}> Apply for digital citizenship</Text>
<Text style={styles.bulletItem}> Access educational content and earn rewards</Text>
</View>
<Text style={styles.sectionTitle}>3. User Responsibilities</Text>
<Text style={styles.subsectionTitle}>3.1 Account Security</Text>
<Text style={styles.paragraph}>
You are solely responsible for:
</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}> Maintaining the confidentiality of your seed phrase and private keys</Text>
<Text style={styles.bulletItem}> All activities that occur under your account</Text>
<Text style={styles.bulletItem}> Securing your device with appropriate passcodes and biometric authentication</Text>
</View>
<Text style={styles.subsectionTitle}>3.2 Prohibited Activities</Text>
<Text style={styles.paragraph}>
You agree NOT to:
</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}> Use the App for any illegal or unauthorized purpose</Text>
<Text style={styles.bulletItem}> Attempt to gain unauthorized access to other users' accounts</Text>
<Text style={styles.bulletItem}>• Interfere with or disrupt the App or servers</Text>
<Text style={styles.bulletItem}>• Upload malicious code or viruses</Text>
<Text style={styles.bulletItem}>• Engage in fraudulent transactions or money laundering</Text>
<Text style={styles.bulletItem}>• Create fake identities or impersonate others</Text>
</View>
<Text style={styles.sectionTitle}>4. Non-Custodial Nature</Text>
<Text style={styles.paragraph}>
Pezkuwi is a non-custodial wallet. This means:
</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}>• We DO NOT have access to your private keys or funds</Text>
<Text style={styles.bulletItem}>• We CANNOT recover your funds if you lose your seed phrase</Text>
<Text style={styles.bulletItem}>• We CANNOT reverse transactions or freeze accounts</Text>
<Text style={styles.bulletItem}>• You have full control and full responsibility for your assets</Text>
</View>
<Text style={styles.sectionTitle}>5. Blockchain Transactions</Text>
<Text style={styles.paragraph}>
When you submit a transaction:
</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}>• Transactions are irreversible once confirmed on the blockchain</Text>
<Text style={styles.bulletItem}>• Transaction fees (gas) are determined by network demand</Text>
<Text style={styles.bulletItem}>• We are not responsible for transaction failures due to insufficient fees</Text>
<Text style={styles.bulletItem}>• You acknowledge the risks of blockchain technology</Text>
</View>
<Text style={styles.sectionTitle}>6. Digital Citizenship</Text>
<Text style={styles.paragraph}>
Citizenship applications:
</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}>• Require KYC (Know Your Customer) verification</Text>
<Text style={styles.bulletItem}>• Are subject to approval by governance mechanisms</Text>
<Text style={styles.bulletItem}>• Involve storing encrypted personal data on IPFS</Text>
<Text style={styles.bulletItem}>• Can be revoked if fraudulent information is detected</Text>
</View>
<Text style={styles.sectionTitle}>7. Disclaimer of Warranties</Text>
<Text style={styles.paragraph}>
THE APP IS PROVIDED "AS IS" WITHOUT WARRANTIES OF ANY KIND. WE DO NOT GUARANTEE:
</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}>• Uninterrupted or error-free service</Text>
<Text style={styles.bulletItem}>• Accuracy of displayed data or prices</Text>
<Text style={styles.bulletItem}>• Security from unauthorized access or hacking</Text>
<Text style={styles.bulletItem}>• Protection from loss of funds due to user error</Text>
</View>
<Text style={styles.sectionTitle}>8. Limitation of Liability</Text>
<Text style={styles.paragraph}>
TO THE MAXIMUM EXTENT PERMITTED BY LAW, PEZKUWI SHALL NOT BE LIABLE FOR:
</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}>• Loss of funds due to forgotten seed phrases</Text>
<Text style={styles.bulletItem}>• Unauthorized transactions from compromised devices</Text>
<Text style={styles.bulletItem}>• Network congestion or blockchain failures</Text>
<Text style={styles.bulletItem}>• Price volatility of cryptocurrencies</Text>
<Text style={styles.bulletItem}>• Third-party services (IPFS, Supabase, RPC providers)</Text>
</View>
<Text style={styles.sectionTitle}>9. Intellectual Property</Text>
<Text style={styles.paragraph}>
The Pezkuwi App, including its design, code, and content, is protected by copyright and trademark laws.
You may not:
</Text>
<View style={styles.bulletList}>
<Text style={styles.bulletItem}>• Copy, modify, or distribute the App without permission</Text>
<Text style={styles.bulletItem}>• Reverse engineer or decompile the App</Text>
<Text style={styles.bulletItem}>• Use the Pezkuwi name or logo without authorization</Text>
</View>
<Text style={styles.sectionTitle}>10. Governing Law</Text>
<Text style={styles.paragraph}>
These Terms shall be governed by the laws of decentralized autonomous organizations (DAOs)
and international arbitration. Disputes will be resolved through community governance mechanisms
when applicable.
</Text>
<Text style={styles.sectionTitle}>11. Changes to Terms</Text>
<Text style={styles.paragraph}>
We reserve the right to modify these Terms at any time. Changes will be effective upon posting
in the App. Your continued use of the App constitutes acceptance of modified Terms.
</Text>
<Text style={styles.sectionTitle}>12. Termination</Text>
<Text style={styles.paragraph}>
We may terminate or suspend your access to the App at any time for violations of these Terms.
You may stop using the App at any time by deleting it from your device.
</Text>
<Text style={styles.sectionTitle}>13. Contact</Text>
<Text style={styles.paragraph}>
For questions about these Terms:{'\n'}
Email: legal@pezkuwichain.io{'\n'}
Support: info@pezkuwichain.io{'\n'}
Website: https://pezkuwichain.io
</Text>
<Text style={styles.footer}>
Last updated: {new Date().toLocaleDateString()}{'\n'}
© {new Date().getFullYear()} PezkuwiChain
</Text>
</ScrollView>
</SafeAreaView>
</Modal>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
color: KurdistanColors.reş,
},
closeButton: {
padding: 8,
},
closeButtonText: {
fontSize: 24,
color: KurdistanColors.reş,
},
content: {
flex: 1,
padding: 20,
},
sectionTitle: {
fontSize: 18,
fontWeight: 'bold',
color: KurdistanColors.kesk,
marginTop: 24,
marginBottom: 12,
},
subsectionTitle: {
fontSize: 16,
fontWeight: '600',
color: KurdistanColors.reş,
marginTop: 16,
marginBottom: 8,
},
paragraph: {
fontSize: 14,
lineHeight: 22,
color: '#333',
marginBottom: 12,
},
bulletList: {
marginBottom: 12,
},
bulletItem: {
fontSize: 14,
lineHeight: 22,
color: '#333',
marginBottom: 6,
},
footer: {
fontSize: 12,
color: '#999',
textAlign: 'center',
marginTop: 32,
marginBottom: 32,
},
});
export default TermsOfServiceModal;
@@ -0,0 +1,199 @@
import React, { useState, useEffect } from 'react';
import { View, Text, FlatList, ActivityIndicator, Alert, StyleSheet, TouchableOpacity } from 'react-native';
import { BottomSheet, Button } from './index'; // Assuming these are exported from index.ts or index.tsx in the same folder
import { usePezkuwi } from '../contexts/PezkuwiContext';
import { KurdistanColors, AppColors } from '../theme/colors';
interface Validator {
address: string;
commission: number;
totalStake: string; // Formatted balance
selfStake: string; // Formatted balance
nominators: number;
// Add other relevant validator info
}
interface ValidatorSelectionSheetProps {
visible: boolean;
onClose: () => void;
onConfirmNominations: (validators: string[]) => void;
// Add other props like currentNominations if needed
}
export function ValidatorSelectionSheet({
visible,
onClose,
onConfirmNominations,
}: ValidatorSelectionSheetProps) {
const { api, isApiReady, selectedAccount } = usePezkuwi();
const [validators, setValidators] = useState<Validator[]>([]);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState(false);
const [selectedValidators, setSelectedValidators] = useState<string[]>([]);
// Fetch real validators from chain
useEffect(() => {
const fetchValidators = async () => {
if (!api || !isApiReady) return;
setLoading(true);
try {
const chainValidators: Validator[] = [];
// Attempt to fetch from pallet-validator-pool first
if (api.query.validatorPool && api.query.validatorPool.validators) {
const rawValidators = await api.query.validatorPool.validators();
// Assuming rawValidators is a list of validator addresses or objects
// This parsing logic will need adjustment based on the exact structure returned
for (const rawValidator of rawValidators.toHuman() as any[]) { // Adjust 'any' based on actual type
// Placeholder: Assume rawValidator is just an address for now
chainValidators.push({
address: rawValidator.toString(), // or rawValidator.address if it's an object
commission: 0.05, // Placeholder: Fetch actual commission
totalStake: '0 HEZ', // Placeholder: Fetch actual stake
selfStake: '0 HEZ', // Placeholder: Fetch actual self stake
nominators: 0, // Placeholder: Fetch actual nominators
});
}
} else {
// Fallback to general staking validators if validatorPool pallet is not found/used
const rawStakingValidators = await api.query.staking.validators();
for (const validatorAddress of rawStakingValidators.keys) {
const address = validatorAddress.args[0].toString();
// Fetch more details about each validator if needed, e.g., commission, total stake
const validatorPrefs = await api.query.staking.validators(address);
const commission = validatorPrefs.commission.toNumber() / 10_000_000; // Assuming 10^7 for percentage
// For simplicity, total stake and nominators are placeholders for now
// A more complete implementation would query for detailed exposure
chainValidators.push({
address: address,
commission: commission,
totalStake: 'Fetching...',
selfStake: 'Fetching...',
nominators: 0,
});
}
}
setValidators(chainValidators);
} catch (error) {
if (__DEV__) console.error('Error fetching validators:', error);
Alert.alert('Error', 'Failed to fetch validators.');
} finally {
setLoading(false);
}
};
fetchValidators();
}, [api, isApiReady]);
const toggleValidatorSelection = (address: string) => {
setSelectedValidators(prev =>
prev.includes(address)
? prev.filter(item => item !== address)
: [...prev, address]
);
};
const handleConfirm = () => {
if (selectedValidators.length === 0) {
Alert.alert('Selection Required', 'Please select at least one validator.');
return;
}
// Pass selected validators to parent component to initiate transaction
onConfirmNominations(selectedValidators);
onClose();
};
const renderValidatorItem = ({ item }: { item: Validator }) => (
<TouchableOpacity
style={[
styles.validatorItem,
selectedValidators.includes(item.address) && styles.selectedValidatorItem,
]}
onPress={() => toggleValidatorSelection(item.address)}
>
<View>
<Text style={styles.validatorAddress}>
{item.address.substring(0, 8)}...{item.address.substring(item.address.length - 6)}
</Text>
<Text style={styles.validatorDetail}>Commission: {item.commission * 100}%</Text>
<Text style={styles.validatorDetail}>Total Stake: {item.totalStake}</Text>
<Text style={styles.validatorDetail}>Self Stake: {item.selfStake}</Text>
<Text style={styles.validatorDetail}>Nominators: {item.nominators}</Text>
</View>
{selectedValidators.includes(item.address) && (
<View style={styles.selectedIndicator}>
<Text style={styles.selectedIndicatorText}></Text>
</View>
)}
</TouchableOpacity>
);
return (
<BottomSheet visible={visible} onClose={onClose} title="Select Validators">
{loading ? (
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
) : (
<FlatList
data={validators}
keyExtractor={item => item.address}
renderItem={renderValidatorItem}
style={styles.list}
/>
)}
<Button
title={processing ? 'Confirming...' : 'Confirm Nominations'}
onPress={handleConfirm}
loading={processing}
disabled={processing || selectedValidators.length === 0}
fullWidth
style={{ marginTop: 20 }}
/>
</BottomSheet>
);
}
const styles = StyleSheet.create({
list: {
maxHeight: 400, // Adjust as needed
},
validatorItem: {
padding: 15,
borderBottomWidth: 1,
borderBottomColor: '#eee',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: KurdistanColors.spi,
borderRadius: 8,
marginBottom: 8,
},
selectedValidatorItem: {
borderColor: KurdistanColors.kesk,
borderWidth: 2,
},
validatorAddress: {
fontSize: 16,
fontWeight: 'bold',
color: KurdistanColors.reş,
},
validatorDetail: {
fontSize: 12,
color: AppColors.textSecondary,
},
selectedIndicator: {
backgroundColor: KurdistanColors.kesk,
width: 24,
height: 24,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
selectedIndicatorText: {
color: KurdistanColors.spi,
fontSize: 14,
fontWeight: 'bold',
},
});
+2
View File
@@ -15,3 +15,5 @@ export { AddressDisplay } from './AddressDisplay';
export { BalanceCard } from './BalanceCard';
export { TokenSelector } from './TokenSelector';
export type { Token } from './TokenSelector';
export { default as PezkuwiWebView } from './PezkuwiWebView';
export type { PezkuwiWebViewProps } from './PezkuwiWebView';
@@ -0,0 +1,226 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { KurdistanColors } from '../../theme/colors';
import type { StackHeaderProps } from '@react-navigation/stack';
interface GradientHeaderProps extends StackHeaderProps {
subtitle?: string;
rightButtons?: React.ReactNode;
gradientColors?: [string, string];
}
export const GradientHeader: React.FC<GradientHeaderProps> = ({
navigation,
options,
route,
subtitle,
rightButtons,
gradientColors = [KurdistanColors.kesk, '#008f43'],
}) => {
const title = options.headerTitle !== undefined
? options.headerTitle
: options.title !== undefined
? options.title
: route.name;
const canGoBack = navigation.canGoBack();
return (
<LinearGradient colors={gradientColors} style={styles.gradientHeader}>
<View style={styles.headerContent}>
<View style={styles.headerLeft}>
{canGoBack && (
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
>
<Text style={styles.backButtonText}></Text>
</TouchableOpacity>
)}
</View>
<View style={styles.headerCenter}>
<Text style={styles.headerTitle}>{title}</Text>
{subtitle && <Text style={styles.headerSubtitle}>{subtitle}</Text>}
</View>
<View style={styles.headerRight}>{rightButtons}</View>
</View>
</LinearGradient>
);
};
interface SimpleHeaderProps extends StackHeaderProps {
rightButtons?: React.ReactNode;
}
export const SimpleHeader: React.FC<SimpleHeaderProps> = ({
navigation,
options,
route,
rightButtons,
}) => {
const title = options.headerTitle !== undefined
? options.headerTitle
: options.title !== undefined
? options.title
: route.name;
const canGoBack = navigation.canGoBack();
return (
<View style={styles.simpleHeader}>
<View style={styles.headerContent}>
<View style={styles.headerLeft}>
{canGoBack && (
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
>
<Text style={styles.simpleBackButtonText}></Text>
</TouchableOpacity>
)}
</View>
<View style={styles.headerCenter}>
<Text style={styles.simpleHeaderTitle}>{title}</Text>
</View>
<View style={styles.headerRight}>{rightButtons}</View>
</View>
</View>
);
};
export const BackButton: React.FC<{ onPress?: () => void; color?: string }> = ({
onPress,
color = '#FFFFFF',
}) => {
return (
<TouchableOpacity onPress={onPress} style={styles.backButton}>
<Text style={[styles.backButtonText, { color }]}></Text>
</TouchableOpacity>
);
};
interface AppBarHeaderProps extends StackHeaderProps {
rightButtons?: React.ReactNode;
}
export const AppBarHeader: React.FC<AppBarHeaderProps> = ({
navigation,
options,
route,
rightButtons,
}) => {
const title = options.headerTitle !== undefined
? options.headerTitle
: options.title !== undefined
? options.title
: route.name;
const canGoBack = navigation.canGoBack();
return (
<View style={styles.appBarHeader}>
<View style={styles.headerContent}>
<View style={styles.headerLeft}>
{canGoBack && (
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
>
<Text style={styles.appBarBackButtonText}></Text>
</TouchableOpacity>
)}
</View>
<View style={styles.headerCenter}>
<Text style={styles.appBarTitle}>{title}</Text>
</View>
<View style={styles.headerRight}>{rightButtons}</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
gradientHeader: {
paddingTop: 50,
paddingBottom: 16,
paddingHorizontal: 16,
},
simpleHeader: {
paddingTop: 50,
paddingBottom: 16,
paddingHorizontal: 16,
backgroundColor: '#FFFFFF',
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerLeft: {
width: 60,
alignItems: 'flex-start',
},
headerCenter: {
flex: 1,
alignItems: 'center',
},
headerRight: {
width: 60,
alignItems: 'flex-end',
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#FFFFFF',
},
headerSubtitle: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 2,
},
simpleHeaderTitle: {
fontSize: 18,
fontWeight: '600',
color: KurdistanColors.reş,
},
backButton: {
padding: 8,
},
backButtonText: {
fontSize: 28,
fontWeight: 'bold',
color: '#FFFFFF',
},
simpleBackButtonText: {
fontSize: 28,
fontWeight: 'bold',
color: KurdistanColors.reş,
},
appBarHeader: {
paddingTop: 50,
paddingBottom: 16,
paddingHorizontal: 16,
backgroundColor: '#FFFFFF',
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)',
elevation: 4,
},
appBarTitle: {
fontSize: 18,
fontWeight: '600',
color: KurdistanColors.reş,
},
appBarBackButtonText: {
fontSize: 28,
fontWeight: 'bold',
color: KurdistanColors.kesk,
},
});
@@ -0,0 +1,229 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { KurdistanColors } from '../../theme/colors';
import type { StackHeaderProps } from '@react-navigation/stack';
interface GradientHeaderProps extends StackHeaderProps {
subtitle?: string;
rightButtons?: React.ReactNode;
gradientColors?: [string, string];
}
export const GradientHeader: React.FC<GradientHeaderProps> = ({
navigation,
options,
route,
subtitle,
rightButtons,
gradientColors = [KurdistanColors.kesk, '#008f43'],
}) => {
const title = options.headerTitle !== undefined
? options.headerTitle
: options.title !== undefined
? options.title
: route.name;
const canGoBack = navigation.canGoBack();
return (
<LinearGradient colors={gradientColors} style={styles.gradientHeader}>
<View style={styles.headerContent}>
<View style={styles.headerLeft}>
{canGoBack && (
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
>
<Text style={styles.backButtonText}>←</Text>
</TouchableOpacity>
)}
</View>
<View style={styles.headerCenter}>
<Text style={styles.headerTitle}>{title}</Text>
{subtitle && <Text style={styles.headerSubtitle}>{subtitle}</Text>}
</View>
<View style={styles.headerRight}>{rightButtons}</View>
</View>
</LinearGradient>
);
};
interface SimpleHeaderProps extends StackHeaderProps {
rightButtons?: React.ReactNode;
}
export const SimpleHeader: React.FC<SimpleHeaderProps> = ({
navigation,
options,
route,
rightButtons,
}) => {
const title = options.headerTitle !== undefined
? options.headerTitle
: options.title !== undefined
? options.title
: route.name;
const canGoBack = navigation.canGoBack();
return (
<View style={styles.simpleHeader}>
<View style={styles.headerContent}>
<View style={styles.headerLeft}>
{canGoBack && (
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
>
<Text style={styles.simpleBackButtonText}>←</Text>
</TouchableOpacity>
)}
</View>
<View style={styles.headerCenter}>
<Text style={styles.simpleHeaderTitle}>{title}</Text>
</View>
<View style={styles.headerRight}>{rightButtons}</View>
</View>
</View>
);
};
export const BackButton: React.FC<{ onPress?: () => void; color?: string }> = ({
onPress,
color = '#FFFFFF',
}) => {
return (
<TouchableOpacity onPress={onPress} style={styles.backButton}>
<Text style={[styles.backButtonText, { color }]}>←</Text>
</TouchableOpacity>
);
};
interface AppBarHeaderProps extends StackHeaderProps {
rightButtons?: React.ReactNode;
}
export const AppBarHeader: React.FC<AppBarHeaderProps> = ({
navigation,
options,
route,
rightButtons,
}) => {
const title = options.headerTitle !== undefined
? options.headerTitle
: options.title !== undefined
? options.title
: route.name;
const canGoBack = navigation.canGoBack();
return (
<View style={styles.appBarHeader}>
<View style={styles.headerContent}>
<View style={styles.headerLeft}>
{canGoBack && (
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
>
<Text style={styles.appBarBackButtonText}>←</Text>
</TouchableOpacity>
)}
</View>
<View style={styles.headerCenter}>
<Text style={styles.appBarTitle}>{title}</Text>
</View>
<View style={styles.headerRight}>{rightButtons}</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
gradientHeader: {
paddingTop: 50,
paddingBottom: 16,
paddingHorizontal: 16,
},
simpleHeader: {
paddingTop: 50,
paddingBottom: 16,
paddingHorizontal: 16,
backgroundColor: '#FFFFFF',
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerLeft: {
width: 60,
alignItems: 'flex-start',
},
headerCenter: {
flex: 1,
alignItems: 'center',
},
headerRight: {
width: 60,
alignItems: 'flex-end',
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#FFFFFF',
},
headerSubtitle: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 2,
},
simpleHeaderTitle: {
fontSize: 18,
fontWeight: '600',
color: KurdistanColors.reş,
},
backButton: {
padding: 8,
},
backButtonText: {
fontSize: 28,
fontWeight: 'bold',
color: '#FFFFFF',
},
simpleBackButtonText: {
fontSize: 28,
fontWeight: 'bold',
color: KurdistanColors.reş,
},
appBarHeader: {
paddingTop: 50,
paddingBottom: 16,
paddingHorizontal: 16,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 4,
},
appBarTitle: {
fontSize: 18,
fontWeight: '600',
color: KurdistanColors.reş,
},
appBarBackButtonText: {
fontSize: 28,
fontWeight: 'bold',
color: KurdistanColors.kesk,
},
});
@@ -0,0 +1,411 @@
import React, { useState, useMemo } from 'react';
import {
View,
Text,
Modal,
StyleSheet,
TouchableOpacity,
TextInput,
ScrollView,
Share,
Clipboard,
Alert,
Linking,
} from 'react-native';
import { KurdistanColors } from '../../theme/colors';
import { usePezkuwi } from '../../contexts/PezkuwiContext';
interface InviteModalProps {
visible: boolean;
onClose: () => void;
}
export const InviteModal: React.FC<InviteModalProps> = ({ visible, onClose }) => {
const { selectedAccount, api } = usePezkuwi();
const [copied, setCopied] = useState(false);
const [inviteeAddress, setInviteeAddress] = useState('');
// Generate referral link
const referralLink = useMemo(() => {
if (!selectedAccount?.address) return '';
// TODO: Update with actual app deep link or web URL
return `https://pezkuwi.net/be-citizen?ref=${selectedAccount.address}`;
}, [selectedAccount?.address]);
const shareText = useMemo(() => {
return `Join me on Digital Kurdistan (PezkuwiChain)! 🏛️\n\nBecome a citizen and get your Welati Tiki NFT.\n\nUse my referral link:\n${referralLink}`;
}, [referralLink]);
const handleCopy = () => {
Clipboard.setString(referralLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
Alert.alert('Copied!', 'Referral link copied to clipboard');
};
const handleNativeShare = async () => {
try {
await Share.share({
message: shareText,
title: 'Join Digital Kurdistan',
});
} catch (error) {
if (__DEV__) console.error('Share error:', error);
}
};
const handleSharePlatform = (platform: string) => {
const encodedText = encodeURIComponent(shareText);
const encodedUrl = encodeURIComponent(referralLink);
const urls: Record<string, string> = {
whatsapp: `whatsapp://send?text=${encodedText}`,
telegram: `tg://msg?text=${encodedText}`,
twitter: `twitter://post?message=${encodedText}`,
email: `mailto:?subject=${encodeURIComponent('Join Digital Kurdistan')}&body=${encodedText}`,
};
if (urls[platform]) {
Linking.openURL(urls[platform]).catch(() => {
// Fallback to web URL if app not installed
const webUrls: Record<string, string> = {
whatsapp: `https://wa.me/?text=${encodedText}`,
telegram: `https://t.me/share/url?url=${encodedUrl}&text=${encodeURIComponent('Join Digital Kurdistan! 🏛️')}`,
twitter: `https://twitter.com/intent/tweet?text=${encodedText}`,
};
if (webUrls[platform]) {
Linking.openURL(webUrls[platform]);
}
});
}
};
const handleInitiateReferral = async () => {
if (!api || !selectedAccount || !inviteeAddress) {
Alert.alert('Error', 'Please enter a valid address');
return;
}
try {
// TODO: Implement on-chain referral initiation
// const tx = api.tx.referral.initiateReferral(inviteeAddress);
// await tx.signAndSend(selectedAccount.address);
Alert.alert('Success', 'Referral initiated successfully!');
setInviteeAddress('');
} catch (error) {
if (__DEV__) console.error('Initiate referral error:', error);
Alert.alert('Error', 'Failed to initiate referral');
}
};
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
{/* Header */}
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Invite Friends</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}></Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.modalBody} showsVerticalScrollIndicator={false}>
<Text style={styles.modalDescription}>
Share your referral link. When your friends complete KYC, you'll earn trust score points!
</Text>
{/* Referral Link */}
<View style={styles.section}>
<Text style={styles.sectionLabel}>Your Referral Link</Text>
<View style={styles.linkContainer}>
<TextInput
style={styles.linkInput}
value={referralLink}
editable={false}
multiline
/>
</View>
<TouchableOpacity style={styles.copyButton} onPress={handleCopy}>
<Text style={styles.copyButtonIcon}>{copied ? '' : '📋'}</Text>
<Text style={styles.copyButtonText}>{copied ? 'Copied!' : 'Copy Link'}</Text>
</TouchableOpacity>
<Text style={styles.hint}>
Anyone who signs up with this link will be counted as your referral
</Text>
</View>
{/* Share Options */}
<View style={styles.section}>
<Text style={styles.sectionLabel}>Share via</Text>
<View style={styles.shareGrid}>
<TouchableOpacity
style={styles.shareButton}
onPress={handleNativeShare}
>
<Text style={styles.shareButtonIcon}>📤</Text>
<Text style={styles.shareButtonText}>Share</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.shareButton}
onPress={() => handleSharePlatform('whatsapp')}
>
<Text style={styles.shareButtonIcon}>💬</Text>
<Text style={styles.shareButtonText}>WhatsApp</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.shareButton}
onPress={() => handleSharePlatform('telegram')}
>
<Text style={styles.shareButtonIcon}>✈️</Text>
<Text style={styles.shareButtonText}>Telegram</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.shareButton}
onPress={() => handleSharePlatform('twitter')}
>
<Text style={styles.shareButtonIcon}>🐦</Text>
<Text style={styles.shareButtonText}>Twitter</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.shareButton}
onPress={() => handleSharePlatform('email')}
>
<Text style={styles.shareButtonIcon}>📧</Text>
<Text style={styles.shareButtonText}>Email</Text>
</TouchableOpacity>
</View>
</View>
{/* Advanced: Pre-register */}
<View style={[styles.section, styles.advancedSection]}>
<Text style={styles.sectionLabel}>Pre-Register a Friend (Advanced)</Text>
<Text style={styles.hint}>
If you know your friend's wallet address, you can pre-register them on-chain.
</Text>
<TextInput
style={styles.addressInput}
placeholder="Friend's wallet address"
placeholderTextColor="#999"
value={inviteeAddress}
onChangeText={setInviteeAddress}
/>
<TouchableOpacity
style={styles.initiateButton}
onPress={handleInitiateReferral}
disabled={!inviteeAddress}
>
<Text style={styles.initiateButtonText}>Initiate Referral</Text>
</TouchableOpacity>
</View>
{/* Rewards Info */}
<View style={styles.rewardsSection}>
<Text style={styles.rewardsSectionTitle}>Referral Rewards</Text>
<View style={styles.rewardRow}>
<Text style={styles.rewardText}> 1-10 referrals: 10 points each (up to 100)</Text>
</View>
<View style={styles.rewardRow}>
<Text style={styles.rewardText}> 11-50 referrals: 5 points each (up to 300)</Text>
</View>
<View style={styles.rewardRow}>
<Text style={styles.rewardText}> 51-100 referrals: 4 points each (up to 500)</Text>
</View>
<View style={styles.rewardRow}>
<Text style={styles.rewardText}> Maximum: 500 trust score points</Text>
</View>
</View>
</ScrollView>
{/* Footer */}
<TouchableOpacity style={styles.doneButton} onPress={onClose}>
<Text style={styles.doneButtonText}>Done</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: '90%',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
borderBottomWidth: 1,
borderBottomColor: '#F0F0F0',
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#333',
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F5F5F5',
justifyContent: 'center',
alignItems: 'center',
},
closeButtonText: {
fontSize: 18,
color: '#666',
},
modalBody: {
padding: 20,
},
modalDescription: {
fontSize: 14,
color: '#666',
marginBottom: 20,
lineHeight: 20,
},
section: {
marginBottom: 24,
},
sectionLabel: {
fontSize: 14,
fontWeight: '600',
color: '#333',
marginBottom: 12,
},
linkContainer: {
backgroundColor: '#F5F5F5',
borderRadius: 12,
padding: 12,
marginBottom: 12,
},
linkInput: {
fontSize: 12,
color: '#333',
fontFamily: 'monospace',
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: KurdistanColors.kesk,
paddingVertical: 12,
borderRadius: 12,
marginBottom: 8,
gap: 8,
},
copyButtonIcon: {
fontSize: 16,
},
copyButtonText: {
fontSize: 14,
fontWeight: '600',
color: '#FFFFFF',
},
hint: {
fontSize: 12,
color: '#999',
lineHeight: 16,
},
shareGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
shareButton: {
flex: 1,
minWidth: '30%',
alignItems: 'center',
padding: 12,
backgroundColor: '#F5F5F5',
borderRadius: 12,
gap: 4,
},
shareButtonIcon: {
fontSize: 24,
},
shareButtonText: {
fontSize: 12,
fontWeight: '500',
color: '#666',
},
advancedSection: {
backgroundColor: '#E0F2FE',
borderRadius: 12,
padding: 16,
marginBottom: 24,
},
addressInput: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
padding: 12,
fontSize: 12,
fontFamily: 'monospace',
color: '#333',
marginTop: 8,
marginBottom: 12,
borderWidth: 1,
borderColor: '#D1D5DB',
},
initiateButton: {
backgroundColor: '#3B82F6',
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
},
initiateButtonText: {
fontSize: 14,
fontWeight: '600',
color: '#FFFFFF',
},
rewardsSection: {
backgroundColor: 'rgba(0, 143, 67, 0.1)',
borderRadius: 12,
padding: 16,
marginBottom: 20,
},
rewardsSectionTitle: {
fontSize: 14,
fontWeight: '600',
color: KurdistanColors.kesk,
marginBottom: 12,
},
rewardRow: {
marginBottom: 6,
},
rewardText: {
fontSize: 12,
color: '#666',
},
doneButton: {
backgroundColor: '#F5F5F5',
paddingVertical: 16,
marginHorizontal: 20,
marginBottom: 20,
borderRadius: 12,
alignItems: 'center',
},
doneButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
});
@@ -0,0 +1,282 @@
import React, { useState } from 'react';
import {
View,
Text,
Modal,
StyleSheet,
TouchableOpacity,
TextInput,
ActivityIndicator,
Alert,
} from 'react-native';
import { KurdistanColors } from '../../theme/colors';
import { usePezkuwi } from '../../contexts/PezkuwiContext';
interface AddTokenModalProps {
visible: boolean;
onClose: () => void;
onTokenAdded?: () => void;
}
export const AddTokenModal: React.FC<AddTokenModalProps> = ({
visible,
onClose,
onTokenAdded,
}) => {
const { api, isApiReady } = usePezkuwi();
const [assetId, setAssetId] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [tokenMetadata, setTokenMetadata] = useState<{
symbol: string;
decimals: number;
name?: string;
} | null>(null);
const handleFetchMetadata = async () => {
if (!api || !isApiReady) {
Alert.alert('Error', 'API not ready');
return;
}
if (!assetId || isNaN(Number(assetId))) {
Alert.alert('Error', 'Please enter a valid asset ID');
return;
}
setIsLoading(true);
try {
const assetIdNum = Number(assetId);
// Fetch asset metadata
const metadataOption = await api.query.assets.metadata(assetIdNum);
if (metadataOption.isEmpty) {
Alert.alert('Error', 'Asset not found');
setTokenMetadata(null);
} else {
const metadata = metadataOption.toJSON() as any;
setTokenMetadata({
symbol: metadata.symbol || 'UNKNOWN',
decimals: metadata.decimals || 12,
name: metadata.name || 'Unknown Token',
});
}
} catch (error: any) {
console.error('Failed to fetch token metadata:', error);
Alert.alert('Error', 'Failed to fetch token metadata');
setTokenMetadata(null);
} finally {
setIsLoading(false);
}
};
const handleAddToken = () => {
if (!tokenMetadata) {
Alert.alert('Error', 'Please fetch token metadata first');
return;
}
// Store the custom token in AsyncStorage or app state
// For now, just show success and call the callback
Alert.alert(
'Success',
`Token ${tokenMetadata.symbol} (ID: ${assetId}) added to your wallet!`,
[
{
text: 'OK',
onPress: () => {
handleClose();
if (onTokenAdded) onTokenAdded();
},
},
]
);
};
const handleClose = () => {
setAssetId('');
setTokenMetadata(null);
onClose();
};
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={handleClose}
>
<View style={styles.modalOverlay}>
<View style={styles.modalCard}>
<Text style={styles.modalHeader}>Add Custom Token</Text>
<Text style={styles.instructions}>
Enter the asset ID to add a custom token to your wallet
</Text>
<View style={styles.inputContainer}>
<TextInput
style={styles.inputField}
placeholder="Asset ID (e.g., 1000)"
keyboardType="numeric"
value={assetId}
onChangeText={setAssetId}
editable={!isLoading}
/>
<TouchableOpacity
style={styles.fetchButton}
onPress={handleFetchMetadata}
disabled={isLoading || !assetId}
>
{isLoading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.fetchButtonText}>Fetch</Text>
)}
</TouchableOpacity>
</View>
{tokenMetadata && (
<View style={styles.metadataContainer}>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Symbol:</Text>
<Text style={styles.metadataValue}>{tokenMetadata.symbol}</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Name:</Text>
<Text style={styles.metadataValue}>{tokenMetadata.name}</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Decimals:</Text>
<Text style={styles.metadataValue}>{tokenMetadata.decimals}</Text>
</View>
</View>
)}
<View style={styles.modalActions}>
<TouchableOpacity style={styles.btnCancel} onPress={handleClose}>
<Text style={styles.btnCancelText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.btnConfirm,
!tokenMetadata && styles.btnConfirmDisabled,
]}
onPress={handleAddToken}
disabled={!tokenMetadata}
>
<Text style={styles.btnConfirmText}>Add Token</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalCard: {
backgroundColor: 'white',
borderRadius: 20,
padding: 24,
width: '100%',
maxWidth: 400,
},
modalHeader: {
fontSize: 22,
fontWeight: 'bold',
color: '#333',
marginBottom: 12,
textAlign: 'center',
},
instructions: {
fontSize: 14,
color: '#666',
marginBottom: 20,
textAlign: 'center',
},
inputContainer: {
flexDirection: 'row',
gap: 12,
marginBottom: 20,
},
inputField: {
flex: 1,
backgroundColor: '#F5F5F5',
padding: 16,
borderRadius: 12,
fontSize: 16,
},
fetchButton: {
backgroundColor: KurdistanColors.kesk,
paddingHorizontal: 24,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
minWidth: 80,
},
fetchButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
metadataContainer: {
backgroundColor: '#F9F9F9',
borderRadius: 12,
padding: 16,
marginBottom: 20,
},
metadataRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
},
metadataLabel: {
fontSize: 14,
color: '#666',
fontWeight: '500',
},
metadataValue: {
fontSize: 14,
color: '#333',
fontWeight: '600',
},
modalActions: {
flexDirection: 'row',
gap: 12,
},
btnCancel: {
flex: 1,
padding: 16,
borderRadius: 12,
backgroundColor: '#EEE',
alignItems: 'center',
},
btnCancelText: {
fontSize: 16,
color: '#333',
fontWeight: '600',
},
btnConfirm: {
flex: 1,
padding: 16,
borderRadius: 12,
backgroundColor: KurdistanColors.kesk,
alignItems: 'center',
},
btnConfirmDisabled: {
backgroundColor: '#CCC',
},
btnConfirmText: {
fontSize: 16,
color: '#FFFFFF',
fontWeight: '600',
},
});