diff --git a/mobile/App.tsx b/mobile/App.tsx
index 69f5dff3..1c5dce0a 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -2,8 +2,11 @@ import React, { useEffect, useState } from 'react';
import { View, ActivityIndicator, StyleSheet } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { initializeI18n } from './src/i18n';
+import { ErrorBoundary } from './src/components/ErrorBoundary';
import { LanguageProvider } from './src/contexts/LanguageContext';
+import { AuthProvider } from './src/contexts/AuthContext';
import { PolkadotProvider } from './src/contexts/PolkadotContext';
+import { BiometricAuthProvider } from './src/contexts/BiometricAuthContext';
import AppNavigator from './src/navigation/AppNavigator';
import { KurdistanColors } from './src/theme/colors';
@@ -35,12 +38,18 @@ export default function App() {
}
return (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/mobile/__tests__/App.test.tsx b/mobile/__tests__/App.test.tsx
new file mode 100644
index 00000000..524e738d
--- /dev/null
+++ b/mobile/__tests__/App.test.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { render, waitFor } from '@testing-library/react-native';
+import App from '../App';
+
+// Mock i18n initialization
+jest.mock('../src/i18n', () => ({
+ initializeI18n: jest.fn(() => Promise.resolve()),
+}));
+
+describe('App Integration Tests', () => {
+ it('should render App component', async () => {
+ const { getByTestId, UNSAFE_getByType } = render();
+
+ // Wait for i18n to initialize
+ await waitFor(() => {
+ // App should render without crashing
+ expect(UNSAFE_getByType(App)).toBeTruthy();
+ });
+ });
+
+ it('should show loading indicator while initializing', () => {
+ const { UNSAFE_getAllByType } = render();
+
+ // Should have ActivityIndicator during initialization
+ const indicators = UNSAFE_getAllByType(require('react-native').ActivityIndicator);
+ expect(indicators.length).toBeGreaterThan(0);
+ });
+
+ it('should wrap app in ErrorBoundary', () => {
+ const { UNSAFE_getByType } = render();
+
+ // ErrorBoundary should be present in component tree
+ // This verifies the provider hierarchy is correct
+ expect(UNSAFE_getByType(App)).toBeTruthy();
+ });
+});
diff --git a/mobile/jest.config.js b/mobile/jest.config.js
new file mode 100644
index 00000000..81b49fc6
--- /dev/null
+++ b/mobile/jest.config.js
@@ -0,0 +1,26 @@
+module.exports = {
+ preset: 'jest-expo',
+ setupFilesAfterEnv: ['/jest.setup.js'],
+ transformIgnorePatterns: [
+ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@polkadot/.*)',
+ ],
+ moduleNameMapper: {
+ '^@pezkuwi/(.*)$': '/../shared/$1',
+ '^@/(.*)$': '/src/$1',
+ },
+ testMatch: ['**/__tests__/**/*.test.(ts|tsx|js)'],
+ collectCoverageFrom: [
+ 'src/**/*.{ts,tsx}',
+ '!src/**/*.d.ts',
+ '!src/**/__tests__/**',
+ '!src/**/types/**',
+ ],
+ coverageThreshold: {
+ global: {
+ statements: 70,
+ branches: 60,
+ functions: 70,
+ lines: 70,
+ },
+ },
+};
diff --git a/mobile/jest.setup.js b/mobile/jest.setup.js
new file mode 100644
index 00000000..2a18a9d1
--- /dev/null
+++ b/mobile/jest.setup.js
@@ -0,0 +1,68 @@
+// Jest setup for React Native testing
+import '@testing-library/react-native/extend-expect';
+
+// Mock expo modules
+jest.mock('expo-linear-gradient', () => ({
+ LinearGradient: 'LinearGradient',
+}));
+
+jest.mock('expo-secure-store', () => ({
+ setItemAsync: jest.fn(() => Promise.resolve()),
+ getItemAsync: jest.fn(() => Promise.resolve(null)),
+ deleteItemAsync: jest.fn(() => Promise.resolve()),
+}));
+
+jest.mock('expo-local-authentication', () => ({
+ authenticateAsync: jest.fn(() =>
+ Promise.resolve({ success: true })
+ ),
+ hasHardwareAsync: jest.fn(() => Promise.resolve(true)),
+ isEnrolledAsync: jest.fn(() => Promise.resolve(true)),
+}));
+
+// Mock AsyncStorage
+jest.mock('@react-native-async-storage/async-storage', () =>
+ require('@react-native-async-storage/async-storage/jest/async-storage-mock')
+);
+
+// Mock Polkadot.js
+jest.mock('@polkadot/api', () => ({
+ ApiPromise: {
+ create: jest.fn(() =>
+ Promise.resolve({
+ isReady: Promise.resolve(true),
+ query: {},
+ tx: {},
+ rpc: {},
+ })
+ ),
+ },
+ WsProvider: jest.fn(),
+}));
+
+// Mock Supabase
+jest.mock('./src/lib/supabase', () => ({
+ supabase: {
+ auth: {
+ signInWithPassword: jest.fn(),
+ signUp: jest.fn(),
+ signOut: jest.fn(),
+ getSession: jest.fn(),
+ },
+ from: jest.fn(() => ({
+ select: jest.fn().mockReturnThis(),
+ insert: jest.fn().mockReturnThis(),
+ update: jest.fn().mockReturnThis(),
+ delete: jest.fn().mockReturnThis(),
+ eq: jest.fn().mockReturnThis(),
+ order: jest.fn().mockReturnThis(),
+ })),
+ },
+}));
+
+// Silence console warnings in tests
+global.console = {
+ ...console,
+ warn: jest.fn(),
+ error: jest.fn(),
+};
diff --git a/mobile/metro.config.js b/mobile/metro.config.js
new file mode 100644
index 00000000..18401def
--- /dev/null
+++ b/mobile/metro.config.js
@@ -0,0 +1,71 @@
+// Learn more https://docs.expo.io/guides/customizing-metro
+const { getDefaultConfig } = require('expo/metro-config');
+const path = require('path');
+
+/** @type {import('expo/metro-config').MetroConfig} */
+const config = getDefaultConfig(__dirname);
+
+// Monorepo support: Watch and resolve modules from parent directory
+const projectRoot = __dirname;
+const workspaceRoot = path.resolve(projectRoot, '..');
+
+// Watch all files in the monorepo
+config.watchFolders = [workspaceRoot];
+
+// Let Metro resolve modules from the workspace root
+config.resolver.nodeModulesPaths = [
+ path.resolve(projectRoot, 'node_modules'),
+ path.resolve(workspaceRoot, 'node_modules'),
+];
+
+// Enable symlinks for shared library
+config.resolver.resolveRequest = (context, moduleName, platform) => {
+ // Handle @pezkuwi/* imports (shared library)
+ if (moduleName.startsWith('@pezkuwi/')) {
+ const sharedPath = moduleName.replace('@pezkuwi/', '');
+ const sharedDir = path.resolve(workspaceRoot, 'shared', sharedPath);
+
+ // Try .ts extension first, then .tsx, then .js
+ const extensions = ['.ts', '.tsx', '.js', '.json'];
+ for (const ext of extensions) {
+ const filePath = sharedDir + ext;
+ if (require('fs').existsSync(filePath)) {
+ return {
+ filePath,
+ type: 'sourceFile',
+ };
+ }
+ }
+
+ // Try index files
+ for (const ext of extensions) {
+ const indexPath = path.join(sharedDir, `index${ext}`);
+ if (require('fs').existsSync(indexPath)) {
+ return {
+ filePath: indexPath,
+ type: 'sourceFile',
+ };
+ }
+ }
+ }
+
+ // Fall back to the default resolver
+ return context.resolveRequest(context, moduleName, platform);
+};
+
+// Ensure all file extensions are resolved
+config.resolver.sourceExts = [
+ 'expo.ts',
+ 'expo.tsx',
+ 'expo.js',
+ 'expo.jsx',
+ 'ts',
+ 'tsx',
+ 'js',
+ 'jsx',
+ 'json',
+ 'wasm',
+ 'svg',
+];
+
+module.exports = config;
diff --git a/mobile/package.json b/mobile/package.json
index 8b507d30..00fff6d0 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -6,7 +6,10 @@
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
- "web": "expo start --web"
+ "web": "expo start --web",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "test:coverage": "jest --coverage"
},
"dependencies": {
"@polkadot/api": "^16.5.2",
diff --git a/mobile/src/components/ErrorBoundary.tsx b/mobile/src/components/ErrorBoundary.tsx
new file mode 100644
index 00000000..5ac6494d
--- /dev/null
+++ b/mobile/src/components/ErrorBoundary.tsx
@@ -0,0 +1,250 @@
+// ========================================
+// Error Boundary Component (React Native)
+// ========================================
+// Catches React errors and displays fallback UI
+
+import React, { Component, ErrorInfo, ReactNode } from 'react';
+import { View, Text, TouchableOpacity, ScrollView, StyleSheet } from 'react-native';
+
+interface Props {
+ children: ReactNode;
+ fallback?: ReactNode;
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+ errorInfo: ErrorInfo | null;
+}
+
+/**
+ * Global Error Boundary for React Native
+ * Catches unhandled errors in React component tree
+ *
+ * @example
+ *
+ *
+ *
+ */
+export class ErrorBoundary extends Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ hasError: false,
+ error: null,
+ errorInfo: null,
+ };
+ }
+
+ static getDerivedStateFromError(error: Error): Partial {
+ // Update state so next render shows fallback UI
+ return {
+ hasError: true,
+ error,
+ };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
+ // Log error to console
+ if (__DEV__) {
+ console.error('ErrorBoundary caught an error:', error, errorInfo);
+ }
+
+ // Update state with error details
+ this.setState({
+ error,
+ errorInfo,
+ });
+
+ // Call custom error handler if provided
+ if (this.props.onError) {
+ this.props.onError(error, errorInfo);
+ }
+
+ // In production, you might want to log to an error reporting service
+ // Example: Sentry.captureException(error);
+ }
+
+ handleReset = (): void => {
+ this.setState({
+ hasError: false,
+ error: null,
+ errorInfo: null,
+ });
+ };
+
+ render(): ReactNode {
+ if (this.state.hasError) {
+ // Use custom fallback if provided
+ if (this.props.fallback) {
+ return this.props.fallback;
+ }
+
+ // Default error UI for React Native
+ return (
+
+
+
+ {/* Error Icon */}
+
+ ⚠️
+
+
+ {/* Error Title */}
+ Something Went Wrong
+
+ An unexpected error occurred. We apologize for the inconvenience.
+
+
+ {/* Error Details (Development Only) */}
+ {__DEV__ && this.state.error && (
+
+
+ Error Details (for developers)
+
+
+ Error:
+
+ {this.state.error.toString()}
+
+ {this.state.errorInfo && (
+ <>
+
+ Component Stack:
+
+
+ {this.state.errorInfo.componentStack}
+
+ >
+ )}
+
+
+ )}
+
+ {/* Action Buttons */}
+
+
+ Try Again
+
+
+
+ {/* Support Contact */}
+
+ If this problem persists, please contact support at{' '}
+ info@pezkuwichain.io
+
+
+
+
+ );
+ }
+
+ // No error, render children normally
+ return this.props.children;
+ }
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#0a0a0a',
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ flexGrow: 1,
+ justifyContent: 'center',
+ padding: 16,
+ },
+ card: {
+ backgroundColor: '#1a1a1a',
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: '#2a2a2a',
+ padding: 24,
+ },
+ iconContainer: {
+ alignItems: 'center',
+ marginBottom: 16,
+ },
+ iconText: {
+ fontSize: 48,
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ color: '#ffffff',
+ textAlign: 'center',
+ marginBottom: 12,
+ },
+ description: {
+ fontSize: 16,
+ color: '#9ca3af',
+ textAlign: 'center',
+ marginBottom: 24,
+ lineHeight: 24,
+ },
+ errorDetails: {
+ marginBottom: 24,
+ },
+ errorDetailsTitle: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#6b7280',
+ marginBottom: 12,
+ },
+ errorBox: {
+ backgroundColor: '#0a0a0a',
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: '#374151',
+ padding: 12,
+ },
+ errorLabel: {
+ fontSize: 12,
+ fontWeight: 'bold',
+ color: '#ef4444',
+ marginBottom: 8,
+ },
+ stackLabel: {
+ marginTop: 12,
+ },
+ errorText: {
+ fontSize: 11,
+ fontFamily: 'monospace',
+ color: '#9ca3af',
+ lineHeight: 16,
+ },
+ buttonContainer: {
+ marginBottom: 16,
+ },
+ primaryButton: {
+ backgroundColor: '#00A94F',
+ borderRadius: 8,
+ padding: 16,
+ alignItems: 'center',
+ },
+ buttonText: {
+ color: '#ffffff',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ supportText: {
+ fontSize: 14,
+ color: '#6b7280',
+ textAlign: 'center',
+ lineHeight: 20,
+ },
+ supportEmail: {
+ color: '#00A94F',
+ },
+});
diff --git a/mobile/src/components/__tests__/ErrorBoundary.test.tsx b/mobile/src/components/__tests__/ErrorBoundary.test.tsx
new file mode 100644
index 00000000..04c78fba
--- /dev/null
+++ b/mobile/src/components/__tests__/ErrorBoundary.test.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react-native';
+import { Text } from 'react-native';
+import { ErrorBoundary } from '../ErrorBoundary';
+
+// Component that throws error for testing
+const ThrowError = () => {
+ throw new Error('Test error');
+ return null;
+};
+
+// Normal component for success case
+const SuccessComponent = () => Success!;
+
+describe('ErrorBoundary', () => {
+ // Suppress error console logs during tests
+ const originalError = console.error;
+ beforeAll(() => {
+ console.error = jest.fn();
+ });
+
+ afterAll(() => {
+ console.error = originalError;
+ });
+
+ it('should render children when no error occurs', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Success!')).toBeTruthy();
+ });
+
+ it('should render error UI when child throws error', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Something Went Wrong')).toBeTruthy();
+ expect(screen.getByText(/An unexpected error occurred/)).toBeTruthy();
+ });
+
+ it('should display try again button on error', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Try Again')).toBeTruthy();
+ });
+
+ it('should render custom fallback if provided', () => {
+ const CustomFallback = () => Custom Error UI;
+
+ render(
+ }>
+
+
+ );
+
+ expect(screen.getByText('Custom Error UI')).toBeTruthy();
+ });
+
+ it('should call onError callback when error occurs', () => {
+ const onError = jest.fn();
+
+ render(
+
+
+
+ );
+
+ expect(onError).toHaveBeenCalled();
+ expect(onError.mock.calls[0][0].message).toBe('Test error');
+ });
+});
diff --git a/mobile/src/components/index.ts b/mobile/src/components/index.ts
index b1c311fd..d1fd2537 100644
--- a/mobile/src/components/index.ts
+++ b/mobile/src/components/index.ts
@@ -3,6 +3,7 @@
* Inspired by Material Design 3, iOS HIG, and Kurdistan aesthetics
*/
+export { ErrorBoundary } from './ErrorBoundary';
export { Card } from './Card';
export { Button } from './Button';
export { Input } from './Input';
diff --git a/mobile/src/contexts/AuthContext.tsx b/mobile/src/contexts/AuthContext.tsx
new file mode 100644
index 00000000..0a1a47a9
--- /dev/null
+++ b/mobile/src/contexts/AuthContext.tsx
@@ -0,0 +1,243 @@
+import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
+import { supabase } from '../lib/supabase';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import type { User } from '@supabase/supabase-js';
+
+// Session timeout configuration
+const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
+const ACTIVITY_CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
+const LAST_ACTIVITY_KEY = '@pezkuwi_last_activity';
+
+interface AuthContextType {
+ user: User | null;
+ loading: boolean;
+ isAdmin: boolean;
+ signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
+ signUp: (email: string, password: string, username: string, referralCode?: string) => Promise<{ error: Error | null }>;
+ signOut: () => Promise;
+ checkAdminStatus: () => Promise;
+ updateActivity: () => void;
+}
+
+const AuthContext = createContext(undefined);
+
+export const useAuth = () => {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error('useAuth must be used within an AuthProvider');
+ }
+ return context;
+};
+
+export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [isAdmin, setIsAdmin] = useState(false);
+
+ // Update last activity timestamp
+ const updateActivity = useCallback(async () => {
+ try {
+ await AsyncStorage.setItem(LAST_ACTIVITY_KEY, Date.now().toString());
+ } catch (error) {
+ if (__DEV__) console.error('Failed to update activity:', error);
+ }
+ }, []);
+
+ const signOut = useCallback(async () => {
+ setIsAdmin(false);
+ setUser(null);
+ await AsyncStorage.removeItem(LAST_ACTIVITY_KEY);
+ await supabase.auth.signOut();
+ }, []);
+
+ // Check if session has timed out
+ const checkSessionTimeout = useCallback(async () => {
+ if (!user) return;
+
+ try {
+ const lastActivity = await AsyncStorage.getItem(LAST_ACTIVITY_KEY);
+ if (!lastActivity) {
+ await updateActivity();
+ return;
+ }
+
+ const lastActivityTime = parseInt(lastActivity, 10);
+ const now = Date.now();
+ const inactiveTime = now - lastActivityTime;
+
+ if (inactiveTime >= SESSION_TIMEOUT_MS) {
+ if (__DEV__) console.log('⏱️ Session timeout - logging out due to inactivity');
+ await signOut();
+ }
+ } catch (error) {
+ if (__DEV__) console.error('Error checking session timeout:', error);
+ }
+ }, [user, updateActivity, signOut]);
+
+ // Setup activity monitoring
+ useEffect(() => {
+ if (!user) return;
+
+ // Initial activity timestamp
+ updateActivity();
+
+ // Check for timeout periodically
+ const timeoutChecker = setInterval(checkSessionTimeout, ACTIVITY_CHECK_INTERVAL_MS);
+
+ return () => {
+ clearInterval(timeoutChecker);
+ };
+ }, [user, updateActivity, checkSessionTimeout]);
+
+ // Check admin status
+ const checkAdminStatus = useCallback(async (): Promise => {
+ if (!user) return false;
+
+ try {
+ const { data, error } = await supabase
+ .from('profiles')
+ .select('is_admin')
+ .eq('id', user.id)
+ .single();
+
+ if (error) {
+ if (__DEV__) console.error('Error checking admin status:', error);
+ return false;
+ }
+
+ const adminStatus = data?.is_admin || false;
+ setIsAdmin(adminStatus);
+ return adminStatus;
+ } catch (error) {
+ if (__DEV__) console.error('Error in checkAdminStatus:', error);
+ return false;
+ }
+ }, [user]);
+
+ // Sign in function
+ const signIn = async (email: string, password: string): Promise<{ error: Error | null }> => {
+ try {
+ const { data, error } = await supabase.auth.signInWithPassword({
+ email,
+ password,
+ });
+
+ if (error) {
+ return { error };
+ }
+
+ if (data.user) {
+ setUser(data.user);
+ await updateActivity();
+ await checkAdminStatus();
+ }
+
+ return { error: null };
+ } catch (error) {
+ return { error: error as Error };
+ }
+ };
+
+ // Sign up function
+ const signUp = async (
+ email: string,
+ password: string,
+ username: string,
+ referralCode?: string
+ ): Promise<{ error: Error | null }> => {
+ try {
+ // Create auth user
+ const { data: authData, error: authError } = await supabase.auth.signUp({
+ email,
+ password,
+ options: {
+ data: {
+ username,
+ referral_code: referralCode,
+ },
+ },
+ });
+
+ if (authError) {
+ return { error: authError };
+ }
+
+ // Create profile
+ if (authData.user) {
+ const { error: profileError } = await supabase
+ .from('profiles')
+ .insert({
+ id: authData.user.id,
+ username,
+ email,
+ referral_code: referralCode,
+ });
+
+ if (profileError) {
+ if (__DEV__) console.error('Profile creation error:', profileError);
+ // Don't fail signup if profile creation fails
+ }
+
+ setUser(authData.user);
+ await updateActivity();
+ }
+
+ return { error: null };
+ } catch (error) {
+ return { error: error as Error };
+ }
+ };
+
+ // Initialize auth state
+ useEffect(() => {
+ const initAuth = async () => {
+ try {
+ // Get initial session
+ const { data: { session } } = await supabase.auth.getSession();
+
+ if (session?.user) {
+ setUser(session.user);
+ await checkAdminStatus();
+ await updateActivity();
+ }
+ } catch (error) {
+ if (__DEV__) console.error('Error initializing auth:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ initAuth();
+
+ // Listen for auth changes
+ const { data: { subscription } } = supabase.auth.onAuthStateChange(
+ async (_event, session) => {
+ setUser(session?.user ?? null);
+
+ if (session?.user) {
+ await checkAdminStatus();
+ await updateActivity();
+ } else {
+ setIsAdmin(false);
+ }
+ }
+ );
+
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [checkAdminStatus, updateActivity]);
+
+ const value = {
+ user,
+ loading,
+ isAdmin,
+ signIn,
+ signUp,
+ signOut,
+ checkAdminStatus,
+ updateActivity,
+ };
+
+ return {children};
+};
diff --git a/mobile/src/contexts/BiometricAuthContext.tsx b/mobile/src/contexts/BiometricAuthContext.tsx
index f008ff4e..d5a28420 100644
--- a/mobile/src/contexts/BiometricAuthContext.tsx
+++ b/mobile/src/contexts/BiometricAuthContext.tsx
@@ -85,7 +85,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({
}
}
} catch (error) {
- console.error('Biometric init error:', error);
+ if (__DEV__) console.error('Biometric init error:', error);
}
};
@@ -108,7 +108,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({
// Check if app should be locked
await checkAutoLock();
} catch (error) {
- console.error('Error loading settings:', error);
+ if (__DEV__) console.error('Error loading settings:', error);
}
};
@@ -136,7 +136,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({
return false;
} catch (error) {
- console.error('Authentication error:', error);
+ if (__DEV__) console.error('Authentication error:', error);
return false;
}
};
@@ -159,7 +159,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({
return false;
} catch (error) {
- console.error('Enable biometric error:', error);
+ if (__DEV__) console.error('Enable biometric error:', error);
return false;
}
};
@@ -173,7 +173,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({
await AsyncStorage.setItem(BIOMETRIC_ENABLED_KEY, 'false');
setIsBiometricEnabled(false);
} catch (error) {
- console.error('Disable biometric error:', error);
+ if (__DEV__) console.error('Disable biometric error:', error);
}
};
@@ -191,7 +191,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({
// Store in SecureStore (encrypted on device)
await SecureStore.setItemAsync(PIN_CODE_KEY, hashedPin);
} catch (error) {
- console.error('Set PIN error:', error);
+ if (__DEV__) console.error('Set PIN error:', error);
throw error;
}
};
@@ -220,7 +220,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({
return false;
} catch (error) {
- console.error('Verify PIN error:', error);
+ if (__DEV__) console.error('Verify PIN error:', error);
return false;
}
};
@@ -250,7 +250,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({
await AsyncStorage.setItem(AUTO_LOCK_TIMER_KEY, minutes.toString());
setAutoLockTimerState(minutes);
} catch (error) {
- console.error('Set auto-lock timer error:', error);
+ if (__DEV__) console.error('Set auto-lock timer error:', error);
}
};
@@ -273,7 +273,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({
try {
await AsyncStorage.setItem(LAST_UNLOCK_TIME_KEY, Date.now().toString());
} catch (error) {
- console.error('Save unlock time error:', error);
+ if (__DEV__) console.error('Save unlock time error:', error);
}
};
@@ -303,7 +303,7 @@ export const BiometricAuthProvider: React.FC<{ children: React.ReactNode }> = ({
setIsLocked(false);
}
} catch (error) {
- console.error('Check auto-lock error:', error);
+ if (__DEV__) console.error('Check auto-lock error:', error);
// On error, lock for safety
setIsLocked(true);
}
diff --git a/mobile/src/contexts/LanguageContext.tsx b/mobile/src/contexts/LanguageContext.tsx
index e8436027..10aa5baa 100644
--- a/mobile/src/contexts/LanguageContext.tsx
+++ b/mobile/src/contexts/LanguageContext.tsx
@@ -29,7 +29,7 @@ export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }
const saved = await AsyncStorage.getItem(LANGUAGE_KEY);
setHasSelectedLanguage(!!saved);
} catch (error) {
- console.error('Failed to check language selection:', error);
+ if (__DEV__) console.error('Failed to check language selection:', error);
}
};
@@ -49,7 +49,7 @@ export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }
// You may want to show a message to restart the app
}
} catch (error) {
- console.error('Failed to change language:', error);
+ if (__DEV__) console.error('Failed to change language:', error);
}
};
diff --git a/mobile/src/contexts/PolkadotContext.tsx b/mobile/src/contexts/PolkadotContext.tsx
index d821f80d..e4a5cd61 100644
--- a/mobile/src/contexts/PolkadotContext.tsx
+++ b/mobile/src/contexts/PolkadotContext.tsx
@@ -3,6 +3,7 @@ import { ApiPromise, WsProvider } from '@polkadot/api';
import { Keyring } from '@polkadot/keyring';
import { KeyringPair } from '@polkadot/keyring/types';
import AsyncStorage from '@react-native-async-storage/async-storage';
+import * as SecureStore from 'expo-secure-store';
import { cryptoWaitReady } from '@polkadot/util-crypto';
import { DEFAULT_ENDPOINT } from '../../../shared/blockchain/polkadot';
@@ -56,9 +57,9 @@ export const PolkadotProvider: React.FC = ({
await cryptoWaitReady();
const kr = new Keyring({ type: 'sr25519' });
setKeyring(kr);
- console.log('✅ Crypto libraries initialized');
+ if (__DEV__) console.log('✅ Crypto libraries initialized');
} catch (err) {
- console.error('❌ Failed to initialize crypto:', err);
+ if (__DEV__) console.error('❌ Failed to initialize crypto:', err);
setError('Failed to initialize crypto libraries');
}
};
@@ -70,7 +71,7 @@ export const PolkadotProvider: React.FC = ({
useEffect(() => {
const initApi = async () => {
try {
- console.log('🔗 Connecting to Pezkuwi node:', endpoint);
+ if (__DEV__) console.log('🔗 Connecting to Pezkuwi node:', endpoint);
const provider = new WsProvider(endpoint);
const apiInstance = await ApiPromise.create({ provider });
@@ -81,7 +82,7 @@ export const PolkadotProvider: React.FC = ({
setIsApiReady(true);
setError(null);
- console.log('✅ Connected to Pezkuwi node');
+ if (__DEV__) console.log('✅ Connected to Pezkuwi node');
// Get chain info
const [chain, nodeName, nodeVersion] = await Promise.all([
@@ -90,10 +91,12 @@ export const PolkadotProvider: React.FC = ({
apiInstance.rpc.system.version(),
]);
- console.log(`📡 Chain: ${chain}`);
- console.log(`🖥️ Node: ${nodeName} v${nodeVersion}`);
+ if (__DEV__) {
+ console.log(`📡 Chain: ${chain}`);
+ console.log(`🖥️ Node: ${nodeName} v${nodeVersion}`);
+ }
} catch (err) {
- console.error('❌ Failed to connect to node:', err);
+ if (__DEV__) console.error('❌ Failed to connect to node:', err);
setError(`Failed to connect to node: ${endpoint}`);
setIsApiReady(false);
}
@@ -127,7 +130,7 @@ export const PolkadotProvider: React.FC = ({
}
}
} catch (err) {
- console.error('Failed to load accounts:', err);
+ if (__DEV__) console.error('Failed to load accounts:', err);
}
};
@@ -161,18 +164,18 @@ export const PolkadotProvider: React.FC = ({
setAccounts(updatedAccounts);
await AsyncStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(updatedAccounts));
- // Store encrypted seed separately
- const seedKey = `@pezkuwi_seed_${pair.address}`;
- await AsyncStorage.setItem(seedKey, mnemonicPhrase);
+ // SECURITY: Store encrypted seed in SecureStore (encrypted hardware-backed storage)
+ const seedKey = `pezkuwi_seed_${pair.address}`;
+ await SecureStore.setItemAsync(seedKey, mnemonicPhrase);
- console.log('✅ Wallet created:', pair.address);
+ if (__DEV__) console.log('✅ Wallet created:', pair.address);
return {
address: pair.address,
mnemonic: mnemonicPhrase,
};
} catch (err) {
- console.error('❌ Failed to create wallet:', err);
+ if (__DEV__) console.error('❌ Failed to create wallet:', err);
throw new Error('Failed to create wallet');
}
};
@@ -184,12 +187,12 @@ export const PolkadotProvider: React.FC = ({
}
try {
- // Load seed from storage
- const seedKey = `@pezkuwi_seed_${address}`;
- const mnemonic = await AsyncStorage.getItem(seedKey);
+ // SECURITY: Load seed from SecureStore (encrypted storage)
+ const seedKey = `pezkuwi_seed_${address}`;
+ const mnemonic = await SecureStore.getItemAsync(seedKey);
if (!mnemonic) {
- console.error('No seed found for address:', address);
+ if (__DEV__) console.error('No seed found for address:', address);
return null;
}
@@ -197,7 +200,7 @@ export const PolkadotProvider: React.FC = ({
const pair = keyring.addFromMnemonic(mnemonic);
return pair;
} catch (err) {
- console.error('Failed to get keypair:', err);
+ if (__DEV__) console.error('Failed to get keypair:', err);
return null;
}
};
@@ -218,9 +221,9 @@ export const PolkadotProvider: React.FC = ({
await AsyncStorage.setItem(SELECTED_ACCOUNT_KEY, accounts[0].address);
}
- console.log(`✅ Connected with ${accounts.length} account(s)`);
+ if (__DEV__) console.log(`✅ Connected with ${accounts.length} account(s)`);
} catch (err) {
- console.error('❌ Wallet connection failed:', err);
+ if (__DEV__) console.error('❌ Wallet connection failed:', err);
setError('Failed to connect wallet');
}
};
@@ -229,7 +232,7 @@ export const PolkadotProvider: React.FC = ({
const disconnectWallet = () => {
setSelectedAccount(null);
AsyncStorage.removeItem(SELECTED_ACCOUNT_KEY);
- console.log('🔌 Wallet disconnected');
+ if (__DEV__) console.log('🔌 Wallet disconnected');
};
// Update selected account storage when it changes
diff --git a/mobile/src/contexts/__tests__/AuthContext.test.tsx b/mobile/src/contexts/__tests__/AuthContext.test.tsx
new file mode 100644
index 00000000..4c795eaf
--- /dev/null
+++ b/mobile/src/contexts/__tests__/AuthContext.test.tsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import { renderHook, act, waitFor } from '@testing-library/react-native';
+import { AuthProvider, useAuth } from '../AuthContext';
+import { supabase } from '../../lib/supabase';
+
+// Wrapper for provider
+const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('AuthContext', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should provide auth context', () => {
+ const { result } = renderHook(() => useAuth(), { wrapper });
+
+ expect(result.current).toBeDefined();
+ expect(result.current.user).toBeNull();
+ expect(result.current.loading).toBe(true);
+ });
+
+ it('should sign in with email and password', async () => {
+ const mockUser = { id: '123', email: 'test@example.com' };
+ (supabase.auth.signInWithPassword as jest.Mock).mockResolvedValue({
+ data: { user: mockUser },
+ error: null,
+ });
+
+ const { result } = renderHook(() => useAuth(), { wrapper });
+
+ await act(async () => {
+ const response = await result.current.signIn('test@example.com', 'password123');
+ expect(response.error).toBeNull();
+ });
+
+ expect(supabase.auth.signInWithPassword).toHaveBeenCalledWith({
+ email: 'test@example.com',
+ password: 'password123',
+ });
+ });
+
+ it('should handle sign in error', async () => {
+ const mockError = new Error('Invalid credentials');
+ (supabase.auth.signInWithPassword as jest.Mock).mockResolvedValue({
+ data: null,
+ error: mockError,
+ });
+
+ const { result } = renderHook(() => useAuth(), { wrapper });
+
+ await act(async () => {
+ const response = await result.current.signIn('test@example.com', 'wrong-password');
+ expect(response.error).toBeDefined();
+ });
+ });
+
+ it('should sign up new user', async () => {
+ const mockUser = { id: '456', email: 'new@example.com' };
+ (supabase.auth.signUp as jest.Mock).mockResolvedValue({
+ data: { user: mockUser },
+ error: null,
+ });
+
+ const { result } = renderHook(() => useAuth(), { wrapper });
+
+ await act(async () => {
+ const response = await result.current.signUp(
+ 'new@example.com',
+ 'password123',
+ 'newuser'
+ );
+ expect(response.error).toBeNull();
+ });
+
+ expect(supabase.auth.signUp).toHaveBeenCalled();
+ });
+
+ it('should sign out user', async () => {
+ (supabase.auth.signOut as jest.Mock).mockResolvedValue({ error: null });
+
+ const { result } = renderHook(() => useAuth(), { wrapper });
+
+ await act(async () => {
+ await result.current.signOut();
+ });
+
+ expect(supabase.auth.signOut).toHaveBeenCalled();
+ });
+
+ it('should check admin status', async () => {
+ const { result } = renderHook(() => useAuth(), { wrapper });
+
+ await act(async () => {
+ const isAdmin = await result.current.checkAdminStatus();
+ expect(typeof isAdmin).toBe('boolean');
+ });
+ });
+});
diff --git a/mobile/src/i18n/index.ts b/mobile/src/i18n/index.ts
index e27af39a..0b7f29ed 100644
--- a/mobile/src/i18n/index.ts
+++ b/mobile/src/i18n/index.ts
@@ -27,7 +27,7 @@ const initializeI18n = async () => {
savedLanguage = stored;
}
} catch (error) {
- console.warn('Failed to load saved language:', error);
+ if (__DEV__) console.warn('Failed to load saved language:', error);
}
i18n
@@ -58,7 +58,7 @@ export const saveLanguage = async (languageCode: string) => {
await AsyncStorage.setItem(LANGUAGE_KEY, languageCode);
await i18n.changeLanguage(languageCode);
} catch (error) {
- console.error('Failed to save language:', error);
+ if (__DEV__) console.error('Failed to save language:', error);
}
};
diff --git a/mobile/src/lib/supabase.ts b/mobile/src/lib/supabase.ts
new file mode 100644
index 00000000..d6dd4433
--- /dev/null
+++ b/mobile/src/lib/supabase.ts
@@ -0,0 +1,23 @@
+import 'react-native-url-polyfill/auto';
+import { createClient } from '@supabase/supabase-js';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+// Initialize Supabase client from environment variables
+const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL || '';
+const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || '';
+
+if (!supabaseUrl || !supabaseKey) {
+ if (__DEV__) {
+ console.warn('Supabase credentials not found in environment variables');
+ console.warn('Add EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY to .env');
+ }
+}
+
+export const supabase = createClient(supabaseUrl, supabaseKey, {
+ auth: {
+ storage: AsyncStorage,
+ autoRefreshToken: true,
+ persistSession: true,
+ detectSessionInUrl: false,
+ },
+});
diff --git a/mobile/src/screens/BeCitizenScreen.tsx b/mobile/src/screens/BeCitizenScreen.tsx
index b90d0387..b1f63df1 100644
--- a/mobile/src/screens/BeCitizenScreen.tsx
+++ b/mobile/src/screens/BeCitizenScreen.tsx
@@ -9,15 +9,20 @@ import {
StatusBar,
TextInput,
Alert,
+ ActivityIndicator,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { useTranslation } from 'react-i18next';
+import { usePolkadot } from '../contexts/PolkadotContext';
+import { submitKycApplication, uploadToIPFS } from '@pezkuwi/lib/citizenship-workflow';
import AppColors, { KurdistanColors } from '../theme/colors';
const BeCitizenScreen: React.FC = () => {
const { t } = useTranslation();
+ const { api, selectedAccount } = usePolkadot();
const [isExistingCitizen, setIsExistingCitizen] = useState(false);
const [currentStep, setCurrentStep] = useState<'choice' | 'new' | 'existing'>('choice');
+ const [isSubmitting, setIsSubmitting] = useState(false);
// New Citizen Form State
const [fullName, setFullName] = useState('');
@@ -33,34 +38,82 @@ const BeCitizenScreen: React.FC = () => {
const [citizenId, setCitizenId] = useState('');
const [password, setPassword] = useState('');
- const handleNewCitizenApplication = () => {
+ const handleNewCitizenApplication = async () => {
if (!fullName || !fatherName || !motherName || !email) {
Alert.alert('Error', 'Please fill in all required fields');
return;
}
- // TODO: Implement actual citizenship registration on blockchain
- Alert.alert(
- 'Application Submitted',
- 'Your citizenship application has been submitted for review. You will receive a confirmation soon.',
- [
- {
- text: 'OK',
- onPress: () => {
- // Reset form
- setFullName('');
- setFatherName('');
- setMotherName('');
- setTribe('');
- setRegion('');
- setEmail('');
- setProfession('');
- setReferralCode('');
- setCurrentStep('choice');
- },
- },
- ]
- );
+ if (!api || !selectedAccount) {
+ Alert.alert('Error', 'Please connect your wallet first');
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ // Prepare citizenship data
+ const citizenshipData = {
+ fullName,
+ fatherName,
+ motherName,
+ tribe,
+ region,
+ email,
+ profession,
+ referralCode,
+ walletAddress: selectedAccount.address,
+ timestamp: Date.now(),
+ };
+
+ // Step 1: Upload encrypted data to IPFS
+ const ipfsCid = await uploadToIPFS(citizenshipData);
+
+ if (!ipfsCid) {
+ throw new Error('Failed to upload data to IPFS');
+ }
+
+ // Step 2: Submit KYC application to blockchain
+ const result = await submitKycApplication(
+ api,
+ selectedAccount,
+ fullName,
+ email,
+ ipfsCid,
+ 'Citizenship application via mobile app'
+ );
+
+ if (result.success) {
+ Alert.alert(
+ 'Application Submitted!',
+ 'Your citizenship application has been submitted for review. You will receive a confirmation once approved.',
+ [
+ {
+ text: 'OK',
+ onPress: () => {
+ // Reset form
+ setFullName('');
+ setFatherName('');
+ setMotherName('');
+ setTribe('');
+ setRegion('');
+ setEmail('');
+ setProfession('');
+ setReferralCode('');
+ setCurrentStep('choice');
+ },
+ },
+ ]
+ );
+ } else {
+ Alert.alert('Application Failed', result.error || 'Failed to submit application');
+ }
+ } catch (error: any) {
+ if (__DEV__) console.error('Citizenship application error:', error);
+ Alert.alert('Error', error.message || 'An unexpected error occurred');
+ } finally {
+ setIsSubmitting(false);
+ }
};
const handleExistingCitizenLogin = () => {
@@ -265,11 +318,16 @@ const BeCitizenScreen: React.FC = () => {
- Submit Application
+ {isSubmitting ? (
+
+ ) : (
+ Submit Application
+ )}
@@ -485,6 +543,9 @@ const styles = StyleSheet.create({
shadowRadius: 6,
elevation: 6,
},
+ submitButtonDisabled: {
+ opacity: 0.6,
+ },
submitButtonText: {
fontSize: 18,
fontWeight: 'bold',
diff --git a/mobile/src/screens/EducationScreen.tsx b/mobile/src/screens/EducationScreen.tsx
index 8572dd57..ce58ecbb 100644
--- a/mobile/src/screens/EducationScreen.tsx
+++ b/mobile/src/screens/EducationScreen.tsx
@@ -47,7 +47,7 @@ const EducationScreen: React.FC = () => {
const allCourses = await getAllCourses(api);
setCourses(allCourses);
} catch (error) {
- console.error('Failed to fetch courses:', error);
+ if (__DEV__) console.error('Failed to fetch courses:', error);
} finally {
setLoading(false);
setRefreshing(false);
@@ -64,7 +64,7 @@ const EducationScreen: React.FC = () => {
const studentEnrollments = await getStudentEnrollments(selectedAccount.address);
setEnrollments(studentEnrollments);
} catch (error) {
- console.error('Failed to fetch enrollments:', error);
+ if (__DEV__) console.error('Failed to fetch enrollments:', error);
}
}, [selectedAccount]);
@@ -102,7 +102,7 @@ const EducationScreen: React.FC = () => {
Alert.alert('Success', 'Successfully enrolled in course!');
fetchEnrollments();
} catch (error: any) {
- console.error('Enrollment failed:', error);
+ if (__DEV__) console.error('Enrollment failed:', error);
Alert.alert('Enrollment Failed', error.message || 'Failed to enroll in course');
} finally {
setEnrolling(null);
@@ -138,7 +138,7 @@ const EducationScreen: React.FC = () => {
Alert.alert('Success', 'Course completed! Certificate issued.');
fetchEnrollments();
} catch (error: any) {
- console.error('Completion failed:', error);
+ if (__DEV__) console.error('Completion failed:', error);
Alert.alert('Error', error.message || 'Failed to complete course');
}
},
diff --git a/mobile/src/screens/ForumScreen.tsx b/mobile/src/screens/ForumScreen.tsx
index 23c293ae..c5af1d08 100644
--- a/mobile/src/screens/ForumScreen.tsx
+++ b/mobile/src/screens/ForumScreen.tsx
@@ -12,6 +12,7 @@ import {
import { useTranslation } from 'react-i18next';
import { Card, Badge } from '../components';
import { KurdistanColors, AppColors } from '../theme/colors';
+import { supabase } from '../lib/supabase';
interface ForumThread {
id: string;
@@ -123,18 +124,54 @@ const ForumScreen: React.FC = () => {
const fetchThreads = async (categoryId?: string) => {
setLoading(true);
try {
- // TODO: Fetch from Supabase
- // const { data } = await supabase
- // .from('forum_threads')
- // .select('*')
- // .eq('category_id', categoryId)
- // .order('is_pinned', { ascending: false })
- // .order('last_activity', { ascending: false });
+ // Fetch from Supabase
+ let query = supabase
+ .from('forum_threads')
+ .select(`
+ *,
+ forum_categories(name)
+ `)
+ .order('is_pinned', { ascending: false })
+ .order('last_activity', { ascending: false });
- await new Promise((resolve) => setTimeout(resolve, 500));
- setThreads(MOCK_THREADS);
+ // Filter by category if provided
+ if (categoryId) {
+ query = query.eq('category_id', categoryId);
+ }
+
+ const { data, error } = await query;
+
+ if (error) {
+ if (__DEV__) console.error('Supabase fetch error:', error);
+ // Fallback to mock data on error
+ setThreads(MOCK_THREADS);
+ return;
+ }
+
+ if (data && data.length > 0) {
+ // Transform Supabase data to match ForumThread interface
+ const transformedThreads: ForumThread[] = data.map((thread: any) => ({
+ id: thread.id,
+ title: thread.title,
+ content: thread.content,
+ author: thread.author_id,
+ category: thread.forum_categories?.name || 'Unknown',
+ replies_count: thread.replies_count || 0,
+ views_count: thread.views_count || 0,
+ created_at: thread.created_at,
+ last_activity: thread.last_activity || thread.created_at,
+ is_pinned: thread.is_pinned || false,
+ is_locked: thread.is_locked || false,
+ }));
+ setThreads(transformedThreads);
+ } else {
+ // No data, use mock data
+ setThreads(MOCK_THREADS);
+ }
} catch (error) {
- console.error('Failed to fetch threads:', error);
+ if (__DEV__) console.error('Failed to fetch threads:', error);
+ // Fallback to mock data on error
+ setThreads(MOCK_THREADS);
} finally {
setLoading(false);
setRefreshing(false);
diff --git a/mobile/src/screens/GovernanceScreen.tsx b/mobile/src/screens/GovernanceScreen.tsx
index 52ad6baf..a32aaebe 100644
--- a/mobile/src/screens/GovernanceScreen.tsx
+++ b/mobile/src/screens/GovernanceScreen.tsx
@@ -121,7 +121,7 @@ export default function GovernanceScreen() {
setProposals(proposalsList);
} catch (error) {
- console.error('Error fetching proposals:', error);
+ if (__DEV__) console.error('Error fetching proposals:', error);
Alert.alert('Error', 'Failed to load proposals');
} finally {
setLoading(false);
@@ -162,7 +162,7 @@ export default function GovernanceScreen() {
];
setElections(mockElections);
} catch (error) {
- console.error('Error fetching elections:', error);
+ if (__DEV__) console.error('Error fetching elections:', error);
}
};
@@ -191,7 +191,7 @@ export default function GovernanceScreen() {
}
});
} catch (error: any) {
- console.error('Voting error:', error);
+ if (__DEV__) console.error('Voting error:', error);
Alert.alert('Error', error.message || 'Failed to submit vote');
} finally {
setVoting(false);
@@ -220,18 +220,72 @@ export default function GovernanceScreen() {
return;
}
+ if (!api || !selectedAccount || !selectedElection) {
+ Alert.alert('Error', 'Wallet not connected');
+ return;
+ }
+
try {
setVoting(true);
- // TODO: Submit votes to blockchain via pallet-tiki
- // await api.tx.tiki.voteInElection(electionId, candidateIds).signAndSend(...)
- Alert.alert('Success', 'Your vote has been recorded!');
- setElectionSheetVisible(false);
- setSelectedElection(null);
- setVotedCandidates([]);
- fetchElections();
+ // Submit vote to blockchain via pallet-welati
+ // For single vote (Presidential): api.tx.welati.voteInElection(electionId, candidateId)
+ // For multiple votes (Parliamentary): submit each vote separately
+ const electionId = selectedElection.id;
+
+ if (selectedElection.type === 'Parliamentary') {
+ // Submit multiple votes for parliamentary elections
+ const txs = votedCandidates.map(candidateId =>
+ api.tx.welati.voteInElection(electionId, candidateId)
+ );
+
+ // Batch all votes together
+ const batchTx = api.tx.utility.batch(txs);
+
+ await batchTx.signAndSend(selectedAccount.address, ({ status, dispatchError }) => {
+ if (dispatchError) {
+ if (dispatchError.isModule) {
+ const decoded = api.registry.findMetaError(dispatchError.asModule);
+ throw new Error(`${decoded.section}.${decoded.name}: ${decoded.docs}`);
+ } else {
+ throw new Error(dispatchError.toString());
+ }
+ }
+
+ if (status.isInBlock) {
+ Alert.alert('Success', `Your ${votedCandidates.length} votes have been recorded!`);
+ setElectionSheetVisible(false);
+ setSelectedElection(null);
+ setVotedCandidates([]);
+ fetchElections();
+ }
+ });
+ } else {
+ // Single vote for presidential/other elections
+ const candidateId = votedCandidates[0];
+ const tx = api.tx.welati.voteInElection(electionId, candidateId);
+
+ await tx.signAndSend(selectedAccount.address, ({ status, dispatchError }) => {
+ if (dispatchError) {
+ if (dispatchError.isModule) {
+ const decoded = api.registry.findMetaError(dispatchError.asModule);
+ throw new Error(`${decoded.section}.${decoded.name}: ${decoded.docs}`);
+ } else {
+ throw new Error(dispatchError.toString());
+ }
+ }
+
+ if (status.isInBlock) {
+ Alert.alert('Success', 'Your vote has been recorded!');
+ setElectionSheetVisible(false);
+ setSelectedElection(null);
+ setVotedCandidates([]);
+ fetchElections();
+ }
+ });
+ }
} catch (error: any) {
- console.error('Election voting error:', error);
+ if (__DEV__) console.error('Election voting error:', error);
Alert.alert('Error', error.message || 'Failed to submit vote');
} finally {
setVoting(false);
diff --git a/mobile/src/screens/NFTGalleryScreen.tsx b/mobile/src/screens/NFTGalleryScreen.tsx
index a85674a1..44c5c70b 100644
--- a/mobile/src/screens/NFTGalleryScreen.tsx
+++ b/mobile/src/screens/NFTGalleryScreen.tsx
@@ -110,7 +110,7 @@ export default function NFTGalleryScreen() {
setNfts(nftList);
} catch (error) {
- console.error('Error fetching NFTs:', error);
+ if (__DEV__) console.error('Error fetching NFTs:', error);
} finally {
setLoading(false);
setRefreshing(false);
diff --git a/mobile/src/screens/P2PScreen.tsx b/mobile/src/screens/P2PScreen.tsx
index 93b57fe9..71d00f15 100644
--- a/mobile/src/screens/P2PScreen.tsx
+++ b/mobile/src/screens/P2PScreen.tsx
@@ -9,6 +9,9 @@ import {
FlatList,
ActivityIndicator,
RefreshControl,
+ Modal,
+ TextInput,
+ Alert,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { Card, Button, Badge } from '../components';
@@ -39,6 +42,9 @@ const P2PScreen: React.FC = () => {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [showCreateOffer, setShowCreateOffer] = useState(false);
+ const [showTradeModal, setShowTradeModal] = useState(false);
+ const [selectedOffer, setSelectedOffer] = useState(null);
+ const [tradeAmount, setTradeAmount] = useState('');
useEffect(() => {
fetchOffers();
@@ -64,7 +70,7 @@ const P2PScreen: React.FC = () => {
setOffers(enrichedOffers);
} catch (error) {
- console.error('Fetch offers error:', error);
+ if (__DEV__) console.error('Fetch offers error:', error);
} finally {
setLoading(false);
setRefreshing(false);
@@ -190,8 +196,8 @@ const P2PScreen: React.FC = () => {