diff --git a/mobile/src/components/Badge.tsx b/mobile/src/components/Badge.tsx new file mode 100644 index 00000000..76603659 --- /dev/null +++ b/mobile/src/components/Badge.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { View, Text, StyleSheet, ViewStyle } from 'react-native'; +import { AppColors, KurdistanColors } from '../theme/colors'; + +interface BadgeProps { + label: string; + variant?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info'; + size?: 'small' | 'medium' | 'large'; + style?: ViewStyle; + icon?: React.ReactNode; +} + +/** + * Badge Component + * For tiki roles, status indicators, labels + */ +export const Badge: React.FC = ({ + label, + variant = 'primary', + size = 'medium', + style, + icon, +}) => { + return ( + + {icon} + + {label} + + + ); +}; + +const styles = StyleSheet.create({ + badge: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 20, + paddingHorizontal: 12, + paddingVertical: 6, + gap: 4, + }, + // Variants + primary: { + backgroundColor: `${KurdistanColors.kesk}15`, + }, + secondary: { + backgroundColor: `${KurdistanColors.zer}15`, + }, + success: { + backgroundColor: '#10B98115', + }, + warning: { + backgroundColor: `${KurdistanColors.zer}20`, + }, + danger: { + backgroundColor: `${KurdistanColors.sor}15`, + }, + info: { + backgroundColor: '#3B82F615', + }, + // Sizes + smallSize: { + paddingHorizontal: 8, + paddingVertical: 4, + }, + mediumSize: { + paddingHorizontal: 12, + paddingVertical: 6, + }, + largeSize: { + paddingHorizontal: 16, + paddingVertical: 8, + }, + // Text styles + text: { + fontWeight: '600', + }, + primaryText: { + color: KurdistanColors.kesk, + }, + secondaryText: { + color: '#855D00', + }, + successText: { + color: '#10B981', + }, + warningText: { + color: '#D97706', + }, + dangerText: { + color: KurdistanColors.sor, + }, + infoText: { + color: '#3B82F6', + }, + smallText: { + fontSize: 11, + }, + mediumText: { + fontSize: 13, + }, + largeText: { + fontSize: 15, + }, +}); diff --git a/mobile/src/components/BottomSheet.tsx b/mobile/src/components/BottomSheet.tsx new file mode 100644 index 00000000..c8506a1a --- /dev/null +++ b/mobile/src/components/BottomSheet.tsx @@ -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 = ({ + 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; + + useEffect(() => { + if (visible) { + openSheet(); + } + }, [visible]); + + const openSheet = () => { + Animated.spring(translateY, { + toValue: 0, + useNativeDriver: true, + damping: 20, + }).start(); + }; + + const closeSheet = () => { + Animated.timing(translateY, { + toValue: height, + duration: 250, + useNativeDriver: true, + }).start(() => { + onClose(); + }); + }; + + return ( + + + + + {showHandle && ( + + + + )} + {title && ( + + {title} + + )} + {children} + + + + ); +}; + +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, + }, +}); diff --git a/mobile/src/components/Button.tsx b/mobile/src/components/Button.tsx new file mode 100644 index 00000000..a960370e --- /dev/null +++ b/mobile/src/components/Button.tsx @@ -0,0 +1,185 @@ +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; +} + +/** + * Modern Button Component + * Uses Kurdistan colors for primary branding + */ +export const Button: React.FC = ({ + title, + onPress, + variant = 'primary', + size = 'medium', + loading = false, + disabled = false, + fullWidth = false, + style, + textStyle, + icon, +}) => { + 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 ( + [ + ...buttonStyle, + pressed && !isDisabled && styles.pressed, + ]} + > + {loading ? ( + + ) : ( + <> + {icon} + {title} + + )} + + ); +}; + +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, + }, +}); diff --git a/mobile/src/components/Card.tsx b/mobile/src/components/Card.tsx new file mode 100644 index 00000000..6bafb6c0 --- /dev/null +++ b/mobile/src/components/Card.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { View, StyleSheet, ViewStyle, Pressable } from 'react-native'; +import { AppColors } from '../theme/colors'; + +interface CardProps { + children: React.ReactNode; + style?: ViewStyle; + onPress?: () => void; + variant?: 'elevated' | 'outlined' | 'filled'; +} + +/** + * Modern Card Component + * Inspired by Material Design 3 and Kurdistan aesthetics + */ +export const Card: React.FC = ({ + children, + style, + onPress, + variant = 'elevated' +}) => { + const cardStyle = [ + styles.card, + variant === 'elevated' && styles.elevated, + variant === 'outlined' && styles.outlined, + variant === 'filled' && styles.filled, + style, + ]; + + if (onPress) { + return ( + [ + ...cardStyle, + pressed && styles.pressed, + ]} + > + {children} + + ); + } + + return {children}; +}; + +const styles = StyleSheet.create({ + card: { + borderRadius: 16, + padding: 16, + backgroundColor: AppColors.surface, + }, + 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 }], + }, +}); diff --git a/mobile/src/components/Input.tsx b/mobile/src/components/Input.tsx new file mode 100644 index 00000000..bd51d606 --- /dev/null +++ b/mobile/src/components/Input.tsx @@ -0,0 +1,153 @@ +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 = ({ + label, + error, + helperText, + leftIcon, + rightIcon, + onRightIconPress, + style, + ...props +}) => { + const [isFocused, setIsFocused] = useState(false); + const hasValue = props.value && props.value.length > 0; + + return ( + + {label && ( + + {label} + + )} + + {leftIcon && {leftIcon}} + { + setIsFocused(true); + props.onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + props.onBlur?.(e); + }} + placeholderTextColor={AppColors.textSecondary} + /> + {rightIcon && ( + + {rightIcon} + + )} + + {(error || helperText) && ( + + {error || helperText} + + )} + + ); +}; + +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, + }, +}); diff --git a/mobile/src/components/LoadingSkeleton.tsx b/mobile/src/components/LoadingSkeleton.tsx new file mode 100644 index 00000000..dd62d930 --- /dev/null +++ b/mobile/src/components/LoadingSkeleton.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useRef } from 'react'; +import { View, Animated, StyleSheet, ViewStyle } from 'react-native'; +import { AppColors } from '../theme/colors'; + +interface SkeletonProps { + width?: number | string; + height?: number; + borderRadius?: number; + style?: ViewStyle; +} + +/** + * Loading Skeleton Component + * Shimmer animation for loading states + */ +export const Skeleton: React.FC = ({ + width = '100%', + height = 20, + borderRadius = 8, + style, +}) => { + const animatedValue = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.loop( + Animated.sequence([ + Animated.timing(animatedValue, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(animatedValue, { + toValue: 0, + duration: 1000, + useNativeDriver: true, + }), + ]) + ).start(); + }, []); + + const opacity = animatedValue.interpolate({ + inputRange: [0, 1], + outputRange: [0.3, 0.7], + }); + + return ( + + ); +}; + +/** + * Card Skeleton for loading states + */ +export const CardSkeleton: React.FC = () => ( + + + + + + + + + +); + +/** + * List Item Skeleton + */ +export const ListItemSkeleton: React.FC = () => ( + + + + + + + +); + +const styles = StyleSheet.create({ + skeleton: { + backgroundColor: AppColors.border, + }, + cardSkeleton: { + backgroundColor: AppColors.surface, + borderRadius: 16, + padding: 16, + marginBottom: 16, + }, + row: { + flexDirection: 'row', + gap: 12, + }, + listItem: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + gap: 12, + backgroundColor: AppColors.surface, + borderRadius: 12, + marginBottom: 8, + }, + listItemContent: { + flex: 1, + }, +}); diff --git a/mobile/src/components/index.ts b/mobile/src/components/index.ts new file mode 100644 index 00000000..a21f6fe9 --- /dev/null +++ b/mobile/src/components/index.ts @@ -0,0 +1,11 @@ +/** + * Modern Component Library for PezkuwiChain Mobile + * Inspired by Material Design 3, iOS HIG, and Kurdistan aesthetics + */ + +export { Card } from './Card'; +export { Button } from './Button'; +export { Input } from './Input'; +export { BottomSheet } from './BottomSheet'; +export { Skeleton, CardSkeleton, ListItemSkeleton } from './LoadingSkeleton'; +export { Badge } from './Badge'; diff --git a/mobile/src/screens/GovernanceScreen.tsx b/mobile/src/screens/GovernanceScreen.tsx new file mode 100644 index 00000000..8f515b09 --- /dev/null +++ b/mobile/src/screens/GovernanceScreen.tsx @@ -0,0 +1,490 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + RefreshControl, + Alert, + Pressable, +} from 'react-native'; +import { usePolkadot } from '../contexts/PolkadotContext'; +import { AppColors, KurdistanColors } from '../theme/colors'; +import { + Card, + Button, + BottomSheet, + Badge, + CardSkeleton, +} from '../components'; + +interface Proposal { + index: number; + proposer: string; + description: string; + value: string; + beneficiary: string; + bond: string; + ayes: number; + nays: number; + status: 'active' | 'approved' | 'rejected'; + endBlock: number; +} + +/** + * Governance Screen + * View proposals, vote, participate in governance + * Inspired by Polkadot governance and modern DAO interfaces + */ +export default function GovernanceScreen() { + const { api, selectedAccount, isApiReady } = usePolkadot(); + const [proposals, setProposals] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [selectedProposal, setSelectedProposal] = useState(null); + const [voteSheetVisible, setVoteSheetVisible] = useState(false); + const [voting, setVoting] = useState(false); + + useEffect(() => { + if (isApiReady && selectedAccount) { + fetchProposals(); + } + }, [isApiReady, selectedAccount]); + + const fetchProposals = async () => { + try { + setLoading(true); + + if (!api) return; + + // Fetch democracy proposals + const proposalEntries = await api.query.democracy?.publicProps(); + + if (!proposalEntries || proposalEntries.isEmpty) { + setProposals([]); + return; + } + + const proposalsList: Proposal[] = []; + + // Parse proposals + const publicProps = proposalEntries.toJSON() as any[]; + + for (const [index, proposal, proposer] of publicProps) { + // Get proposal hash and details + const proposalHash = proposal; + + // For demo, create sample proposals + // In production, decode actual proposal data + proposalsList.push({ + index: index as number, + proposer: proposer as string, + description: `Proposal #${index}: Infrastructure Development`, + value: '10000', + beneficiary: '5GrwvaEF...', + bond: '1000', + ayes: Math.floor(Math.random() * 1000), + nays: Math.floor(Math.random() * 200), + status: 'active', + endBlock: 1000000, + }); + } + + setProposals(proposalsList); + } catch (error) { + console.error('Error fetching proposals:', error); + Alert.alert('Error', 'Failed to load proposals'); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + const handleVote = async (approve: boolean) => { + if (!selectedProposal) return; + + try { + setVoting(true); + + if (!api || !selectedAccount) return; + + // Vote on proposal (referendum) + const tx = approve + ? api.tx.democracy.vote(selectedProposal.index, { Standard: { vote: { aye: true, conviction: 'None' }, balance: '1000000000000' } }) + : api.tx.democracy.vote(selectedProposal.index, { Standard: { vote: { aye: false, conviction: 'None' }, balance: '1000000000000' } }); + + await tx.signAndSend(selectedAccount.address, ({ status }) => { + if (status.isInBlock) { + Alert.alert( + 'Success', + `Vote ${approve ? 'FOR' : 'AGAINST'} recorded successfully!` + ); + setVoteSheetVisible(false); + setSelectedProposal(null); + fetchProposals(); + } + }); + } catch (error: any) { + console.error('Voting error:', error); + Alert.alert('Error', error.message || 'Failed to submit vote'); + } finally { + setVoting(false); + } + }; + + if (loading && proposals.length === 0) { + return ( + + + + + + ); + } + + return ( + + { + setRefreshing(true); + fetchProposals(); + }} + /> + } + > + {/* Header */} + + Governance + + Participate in digital democracy + + + + {/* Stats */} + + + {proposals.length} + Active Proposals + + + 1,234 + Total Voters + + + + {/* Proposals List */} + Active Proposals + {proposals.length === 0 ? ( + + No active proposals + + Check back later for new governance proposals + + + ) : ( + proposals.map((proposal) => ( + { + setSelectedProposal(proposal); + setVoteSheetVisible(true); + }} + /> + )) + )} + + {/* Info Card */} + + 🗳️ How Voting Works + + Each HEZ token equals one vote. Your vote helps shape the future of Digital Kurdistan. Proposals need majority approval to pass. + + + + + {/* Vote Bottom Sheet */} + setVoteSheetVisible(false)} + title="Vote on Proposal" + height={500} + > + {selectedProposal && ( + + + {selectedProposal.description} + + + + + + + + {/* Vote Progress */} + + + + + + {selectedProposal.ayes} For + {selectedProposal.nays} Against + + + + {/* Vote Buttons */} + +