✨: Initial Commit
parent
1cc64f3731
commit
b7ff79f08c
|
@ -0,0 +1,140 @@
|
|||
# Requirements & Environment Setup
|
||||
|
||||
## Hardware Prerequisites
|
||||
|
||||
**Minimum Requirements:**
|
||||
|
||||
- **macOS**: macOS 10.15+ (for iOS development)
|
||||
- **Windows**: Windows 10+ with WSL2
|
||||
- **Linux**: Ubuntu 18.04+ or equivalent
|
||||
- **RAM**: 8GB minimum, 16GB recommended
|
||||
- **Storage**: 50GB free space minimum
|
||||
|
||||
## JDK Setup
|
||||
|
||||
```bash
|
||||
# Install JDK 11 (recommended for RN 0.70+)
|
||||
# macOS (using Homebrew)
|
||||
brew install openjdk@11
|
||||
|
||||
# Windows (using Chocolatey)
|
||||
choco install openjdk11
|
||||
|
||||
# Linux (Ubuntu)
|
||||
sudo apt update
|
||||
sudo apt install openjdk-11-jdk
|
||||
```
|
||||
|
||||
**Configure JAVA_HOME:**
|
||||
|
||||
```bash
|
||||
# macOS/Linux - Add to ~/.zshrc or ~/.bash_profile
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 11)
|
||||
export PATH=$JAVA_HOME/bin:$PATH
|
||||
|
||||
# Windows - Set environment variables
|
||||
JAVA_HOME=C:\Program Files\OpenJDK\openjdk-11.0.x
|
||||
PATH=%JAVA_HOME%\bin;%PATH%
|
||||
```
|
||||
|
||||
## Node.js & Package Manager
|
||||
|
||||
```bash
|
||||
# Install Node.js LTS (18.x recommended)
|
||||
# Using nvm (recommended)
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||
nvm install 18
|
||||
nvm use 18
|
||||
|
||||
# Install Yarn (recommended over npm)
|
||||
npm install -g yarn
|
||||
|
||||
# Verify installations
|
||||
node --version # Should show v18.x.x
|
||||
yarn --version # Should show 1.22.x+
|
||||
```
|
||||
|
||||
## Android Studio & SDK Setup
|
||||
|
||||
1. **Download Android Studio** from https://developer.android.com/studio
|
||||
2. **Install Android SDK:**
|
||||
|
||||
- SDK Platforms: Android 13 (API 33), Android 12 (API 31)
|
||||
- SDK Tools: Android SDK Build-Tools 33.0.0, Android Emulator, Android SDK Platform-Tools
|
||||
|
||||
3. **Configure Environment Variables:**
|
||||
|
||||
```bash
|
||||
# macOS/Linux - Add to ~/.zshrc or ~/.bash_profile
|
||||
export ANDROID_HOME=$HOME/Library/Android/sdk # macOS
|
||||
export ANDROID_HOME=$HOME/Android/Sdk # Linux
|
||||
export PATH=$PATH:$ANDROID_HOME/emulator
|
||||
export PATH=$PATH:$ANDROID_HOME/platform-tools
|
||||
|
||||
# Windows
|
||||
ANDROID_HOME=C:\Users\%USERNAME%\AppData\Local\Android\Sdk
|
||||
PATH=%ANDROID_HOME%\emulator;%ANDROID_HOME%\platform-tools;%PATH%
|
||||
```
|
||||
|
||||
4. **Create AVD (Android Virtual Device):**
|
||||
- Open Android Studio → AVD Manager
|
||||
- Create Virtual Device → Pixel 4 → API 33 → Finish
|
||||
|
||||
## Xcode & iOS Setup (macOS only)
|
||||
|
||||
```bash
|
||||
# Install Xcode from App Store
|
||||
# Install Command Line Tools
|
||||
xcode-select --install
|
||||
|
||||
# Install CocoaPods
|
||||
sudo gem install cocoapods
|
||||
|
||||
# Verify installation
|
||||
pod --version
|
||||
```
|
||||
|
||||
## VSCode Setup
|
||||
|
||||
**Required Extensions:**
|
||||
|
||||
```json
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-vscode.vscode-typescript-next",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-vscode.vscode-eslint",
|
||||
"formulahendry.auto-rename-tag",
|
||||
"ms-vscode-remote.remote-containers",
|
||||
"ms-vscode.vscode-json"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**VSCode Settings (.vscode/settings.json):**
|
||||
|
||||
```json
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "relative"
|
||||
}
|
||||
```
|
||||
|
||||
## Global Dependencies
|
||||
|
||||
```bash
|
||||
# Install global tools
|
||||
npm install -g @react-native-community/cli
|
||||
npm install -g react-devtools
|
||||
|
||||
# macOS only
|
||||
brew install watchman
|
||||
|
||||
# Verify React Native CLI
|
||||
npx react-native --version
|
||||
```
|
|
@ -0,0 +1,147 @@
|
|||
# Project Initialization
|
||||
|
||||
## Create New Project
|
||||
|
||||
```bash
|
||||
# Create new React Native project with TypeScript
|
||||
npx react-native init SaayamApp --template react-native-template-typescript
|
||||
|
||||
cd SaayamApp
|
||||
|
||||
# Initialize git repository
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial commit"
|
||||
```
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
**tsconfig.json:**
|
||||
```json
|
||||
{
|
||||
"extends": "@react-native/typescript-config/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@components/*": ["./src/components/*"],
|
||||
"@screens/*": ["./src/screens/*"],
|
||||
"@utils/*": ["./src/utils/*"],
|
||||
"@config/*": ["./src/config/*"],
|
||||
"@redux/*": ["./src/redux/*"],
|
||||
"@assets/*": ["./src/assets/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "index.js"],
|
||||
"exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Reusable UI components
|
||||
│ ├── common/ # Generic components
|
||||
│ ├── forms/ # Form-specific components
|
||||
│ └── index.ts # Component exports
|
||||
├── screens/ # Screen components
|
||||
│ ├── Auth/
|
||||
│ ├── Home/
|
||||
│ └── index.ts
|
||||
├── navigation/ # Navigation configuration
|
||||
├── redux/ # State management
|
||||
│ ├── store/
|
||||
│ ├── slices/
|
||||
│ └── types/
|
||||
├── services/ # API services
|
||||
├── utils/ # Utility functions
|
||||
├── config/ # App configuration
|
||||
├── assets/ # Images, fonts, etc.
|
||||
│ ├── images/
|
||||
│ ├── fonts/
|
||||
│ └── icons/
|
||||
├── types/ # TypeScript type definitions
|
||||
└── constants/ # App constants
|
||||
```
|
||||
|
||||
## Code Quality Setup
|
||||
|
||||
**Install dependencies:**
|
||||
```bash
|
||||
yarn add -D eslint prettier husky lint-staged @typescript-eslint/parser @typescript-eslint/eslint-plugin
|
||||
```
|
||||
|
||||
**.eslintrc.js:**
|
||||
```javascript
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'@react-native-community',
|
||||
'@typescript-eslint/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
rules: {
|
||||
'react-native/no-inline-styles': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**.prettierrc.js:**
|
||||
```javascript
|
||||
module.exports = {
|
||||
arrowParens: 'avoid',
|
||||
bracketSameLine: true,
|
||||
bracketSpacing: false,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
tabWidth: 2,
|
||||
semi: true,
|
||||
};
|
||||
```
|
||||
|
||||
**package.json scripts:**
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json}\"",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
```bash
|
||||
yarn add react-native-config
|
||||
```
|
||||
|
||||
**Create environment files:**
|
||||
```bash
|
||||
# .env.development
|
||||
API_BASE_URL=https://dev-api.saayam.com
|
||||
APP_ENV=development
|
||||
|
||||
# .env.production
|
||||
API_BASE_URL=https://api.saayam.com
|
||||
APP_ENV=production
|
||||
```
|
|
@ -0,0 +1,123 @@
|
|||
# Development Standards & Guidelines
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- **Files**: PascalCase for components, camelCase for utilities
|
||||
- **Components**: PascalCase (e.g., `UserProfile.tsx`)
|
||||
- **Variables**: camelCase (e.g., `userName`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (e.g., `API_BASE_URL`)
|
||||
- **Types/Interfaces**: PascalCase with descriptive names
|
||||
|
||||
## Component Architecture
|
||||
|
||||
**Base Component Structure:**
|
||||
|
||||
```typescript
|
||||
// src/components/common/Button/Button.tsx
|
||||
import React from "react";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
} from "react-native";
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: "primary" | "secondary" | "outline";
|
||||
disabled?: boolean;
|
||||
style?: ViewStyle;
|
||||
textStyle?: TextStyle;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
variant = "primary",
|
||||
disabled = false,
|
||||
style,
|
||||
textStyle,
|
||||
}) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles[variant],
|
||||
disabled && styles.disabled,
|
||||
style,
|
||||
]}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.text, styles[`${variant}Text`], textStyle]}>
|
||||
{title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: "#007AFF",
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: "#6C757D",
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 1,
|
||||
borderColor: "#007AFF",
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
primaryText: {
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
secondaryText: {
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
outlineText: {
|
||||
color: "#007AFF",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### TypeScript Best Practices
|
||||
|
||||
1. **Always use strict mode**
|
||||
2. **Define interfaces for all props**
|
||||
3. **Use proper type annotations**
|
||||
4. **Avoid `any` type**
|
||||
5. **Use union types for variants**
|
||||
|
||||
### React Native Specific
|
||||
|
||||
1. **Use StyleSheet.create() for styles**
|
||||
2. **Avoid inline styles when possible**
|
||||
3. **Use proper component lifecycle methods**
|
||||
4. **Implement proper error boundaries**
|
||||
5. **Use React.memo for performance optimization**
|
||||
|
||||
### File Organization
|
||||
|
||||
1. **One component per file**
|
||||
2. **Index files for clean imports**
|
||||
3. **Separate styles in same file**
|
||||
4. **Group related components in folders**
|
|
@ -0,0 +1,520 @@
|
|||
# Required Components
|
||||
|
||||
## TextInput Component
|
||||
|
||||
```typescript
|
||||
// src/components/common/TextInput/TextInput.tsx
|
||||
import React, {useState} from 'react';
|
||||
import {
|
||||
TextInput as RNTextInput,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInputProps,
|
||||
} from 'react-native';
|
||||
|
||||
interface CustomTextInputProps extends TextInputProps {
|
||||
label?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const TextInput: React.FC<CustomTextInputProps> = ({
|
||||
label,
|
||||
error,
|
||||
required,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && (
|
||||
<Text style={styles.label}>
|
||||
{label}
|
||||
{required && <Text style={styles.required}> *</Text>}
|
||||
</Text>
|
||||
)}
|
||||
<RNTextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isFocused && styles.focused,
|
||||
error && styles.error,
|
||||
style,
|
||||
]}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
},
|
||||
required: {
|
||||
color: '#FF3B30',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E5E7',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
focused: {
|
||||
borderColor: '#007AFF',
|
||||
},
|
||||
error: {
|
||||
borderColor: '#FF3B30',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: '#FF3B30',
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Header Component (CHeader)
|
||||
|
||||
```typescript
|
||||
// src/components/common/CHeader/CHeader.tsx
|
||||
import React from 'react';
|
||||
import {View, Text, TouchableOpacity, StyleSheet, StatusBar} from 'react-native';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
interface CHeaderProps {
|
||||
title: string;
|
||||
showBackButton?: boolean;
|
||||
onBackPress?: () => void;
|
||||
rightComponent?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export const CHeader: React.FC<CHeaderProps> = ({
|
||||
title,
|
||||
showBackButton = true,
|
||||
onBackPress,
|
||||
rightComponent,
|
||||
backgroundColor = '#FFFFFF',
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, {paddingTop: insets.top, backgroundColor}]}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor={backgroundColor} />
|
||||
<View style={styles.header}>
|
||||
{showBackButton && (
|
||||
<TouchableOpacity onPress={onBackPress} style={styles.backButton}>
|
||||
<Text style={styles.backText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<View style={styles.rightContainer}>{rightComponent}</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E5E7',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
backText: {
|
||||
fontSize: 24,
|
||||
color: '#007AFF',
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
color: '#000',
|
||||
},
|
||||
rightContainer: {
|
||||
minWidth: 40,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## AlertModal Component
|
||||
|
||||
```typescript
|
||||
// src/components/common/AlertModal/AlertModal.tsx
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
|
||||
interface AlertModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
type?: 'info' | 'warning' | 'error' | 'success';
|
||||
}
|
||||
|
||||
export const AlertModal: React.FC<AlertModalProps> = ({
|
||||
visible,
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmText = 'OK',
|
||||
cancelText = 'Cancel',
|
||||
type = 'info',
|
||||
}) => {
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade">
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.title, styles[`${type}Title`]]}>{title}</Text>
|
||||
<Text style={styles.message}>{message}</Text>
|
||||
<View style={styles.buttonContainer}>
|
||||
{onCancel && (
|
||||
<TouchableOpacity style={styles.cancelButton} onPress={onCancel}>
|
||||
<Text style={styles.cancelText}>{cancelText}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{onConfirm && (
|
||||
<TouchableOpacity
|
||||
style={[styles.confirmButton, styles[`${type}Button`]]}
|
||||
onPress={onConfirm}>
|
||||
<Text style={styles.confirmText}>{confirmText}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
width: Dimensions.get('window').width - 40,
|
||||
maxWidth: 400,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
infoTitle: {
|
||||
color: '#007AFF',
|
||||
},
|
||||
warningTitle: {
|
||||
color: '#FF9500',
|
||||
},
|
||||
errorTitle: {
|
||||
color: '#FF3B30',
|
||||
},
|
||||
successTitle: {
|
||||
color: '#34C759',
|
||||
},
|
||||
message: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
lineHeight: 22,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
marginRight: 8,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E5E7',
|
||||
},
|
||||
confirmButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
marginLeft: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
infoButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
warningButton: {
|
||||
backgroundColor: '#FF9500',
|
||||
},
|
||||
errorButton: {
|
||||
backgroundColor: '#FF3B30',
|
||||
},
|
||||
successButton: {
|
||||
backgroundColor: '#34C759',
|
||||
},
|
||||
cancelText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
confirmText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Dropdown Component
|
||||
|
||||
```typescript
|
||||
// src/components/common/Dropdown/Dropdown.tsx
|
||||
import React, {useState} from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
|
||||
interface DropdownItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
items: DropdownItem[];
|
||||
selectedValue?: string;
|
||||
onSelect: (item: DropdownItem) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const Dropdown: React.FC<DropdownProps> = ({
|
||||
items,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
placeholder = 'Select an option',
|
||||
label,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const selectedItem = items.find(item => item.value === selectedValue);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
<TouchableOpacity
|
||||
style={styles.selector}
|
||||
onPress={() => setIsVisible(true)}>
|
||||
<Text style={[styles.selectorText, !selectedItem && styles.placeholder]}>
|
||||
{selectedItem ? selectedItem.label : placeholder}
|
||||
</Text>
|
||||
<Text style={styles.arrow}>▼</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal visible={isVisible} transparent animationType="fade">
|
||||
<TouchableOpacity
|
||||
style={styles.overlay}
|
||||
onPress={() => setIsVisible(false)}>
|
||||
<View style={styles.dropdown}>
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={item => item.value}
|
||||
renderItem={({item}) => (
|
||||
<TouchableOpacity
|
||||
style={styles.item}
|
||||
onPress={() => {
|
||||
onSelect(item);
|
||||
setIsVisible(false);
|
||||
}}>
|
||||
<Text style={styles.itemText}>{item.label}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
},
|
||||
selector: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E5E7',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
selectorText: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
},
|
||||
placeholder: {
|
||||
color: '#999',
|
||||
},
|
||||
arrow: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
dropdown: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 8,
|
||||
maxHeight: 200,
|
||||
width: '80%',
|
||||
},
|
||||
item: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
itemText: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## RadioButton Component
|
||||
|
||||
```typescript
|
||||
// src/components/common/RadioButton/RadioButton.tsx
|
||||
import React from 'react';
|
||||
import {View, Text, TouchableOpacity, StyleSheet} from 'react-native';
|
||||
|
||||
interface RadioOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface RadioButtonProps {
|
||||
options: RadioOption[];
|
||||
selectedValue?: string;
|
||||
onSelect: (value: string) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const RadioButton: React.FC<RadioButtonProps> = ({
|
||||
options,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
label,
|
||||
}) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
{options.map(option => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={styles.option}
|
||||
onPress={() => onSelect(option.value)}>
|
||||
<View style={styles.radio}>
|
||||
{selectedValue === option.value && <View style={styles.selected} />}
|
||||
</View>
|
||||
<Text style={styles.optionText}>{option.label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
},
|
||||
option: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
radio: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
borderColor: '#007AFF',
|
||||
marginRight: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
selected: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
},
|
||||
});
|
||||
```
|
|
@ -0,0 +1,232 @@
|
|||
# VSCode Snippets
|
||||
|
||||
## Setup VSCode Snippets
|
||||
|
||||
Create `.vscode/snippets.code-snippets` in your project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"React Native Functional Component": {
|
||||
"prefix": "rnfc",
|
||||
"body": [
|
||||
"import React from 'react';",
|
||||
"import {View, Text, StyleSheet} from 'react-native';",
|
||||
"",
|
||||
"interface ${1:ComponentName}Props {",
|
||||
" $2",
|
||||
"}",
|
||||
"",
|
||||
"export const ${1:ComponentName}: React.FC<${1:ComponentName}Props> = ({$3}) => {",
|
||||
" return (",
|
||||
" <View style={styles.container}>",
|
||||
" <Text>$4</Text>",
|
||||
" </View>",
|
||||
" );",
|
||||
"};",
|
||||
"",
|
||||
"const styles = StyleSheet.create({",
|
||||
" container: {",
|
||||
" flex: 1,",
|
||||
" },",
|
||||
"});"
|
||||
],
|
||||
"description": "Create a React Native functional component"
|
||||
},
|
||||
|
||||
"React Native Screen Component": {
|
||||
"prefix": "rnscreen",
|
||||
"body": [
|
||||
"import React from 'react';",
|
||||
"import {View, Text, StyleSheet, SafeAreaView} from 'react-native';",
|
||||
"import {CHeader} from '@components/common';",
|
||||
"",
|
||||
"interface ${1:ScreenName}Props {",
|
||||
" navigation: any;",
|
||||
" route: any;",
|
||||
"}",
|
||||
"",
|
||||
"export const ${1:ScreenName}: React.FC<${1:ScreenName}Props> = ({navigation, route}) => {",
|
||||
" return (",
|
||||
" <SafeAreaView style={styles.container}>",
|
||||
" <CHeader",
|
||||
" title=\"${2:Screen Title}\"",
|
||||
" onBackPress={() => navigation.goBack()}",
|
||||
" />",
|
||||
" <View style={styles.content}>",
|
||||
" <Text>$3</Text>",
|
||||
" </View>",
|
||||
" </SafeAreaView>",
|
||||
" );",
|
||||
"};",
|
||||
"",
|
||||
"const styles = StyleSheet.create({",
|
||||
" container: {",
|
||||
" flex: 1,",
|
||||
" backgroundColor: '#FFFFFF',",
|
||||
" },",
|
||||
" content: {",
|
||||
" flex: 1,",
|
||||
" padding: 16,",
|
||||
" },",
|
||||
"});"
|
||||
],
|
||||
"description": "Create a React Native screen component"
|
||||
},
|
||||
|
||||
"Redux Slice": {
|
||||
"prefix": "reduxslice",
|
||||
"body": [
|
||||
"import {createSlice, PayloadAction} from '@reduxjs/toolkit';",
|
||||
"",
|
||||
"interface ${1:SliceName}State {",
|
||||
" $2",
|
||||
"}",
|
||||
"",
|
||||
"const initialState: ${1:SliceName}State = {",
|
||||
" $3",
|
||||
"};",
|
||||
"",
|
||||
"const ${4:sliceName}Slice = createSlice({",
|
||||
" name: '${4:sliceName}',",
|
||||
" initialState,",
|
||||
" reducers: {",
|
||||
" ${5:actionName}: (state, action: PayloadAction<$6>) => {",
|
||||
" $7",
|
||||
" },",
|
||||
" },",
|
||||
"});",
|
||||
"",
|
||||
"export const {${5:actionName}} = ${4:sliceName}Slice.actions;",
|
||||
"export default ${4:sliceName}Slice.reducer;"
|
||||
],
|
||||
"description": "Create a Redux Toolkit slice"
|
||||
},
|
||||
|
||||
"API Service": {
|
||||
"prefix": "apiservice",
|
||||
"body": [
|
||||
"import {createApi, fetchBaseQuery} from '@reduxjs/toolkit/query/react';",
|
||||
"import Config from 'react-native-config';",
|
||||
"",
|
||||
"export const ${1:serviceName}Api = createApi({",
|
||||
" reducerPath: '${1:serviceName}Api',",
|
||||
" baseQuery: fetchBaseQuery({",
|
||||
" baseUrl: `\\${Config.API_BASE_URL}/${2:endpoint}`,",
|
||||
" prepareHeaders: (headers, {getState}) => {",
|
||||
" const token = (getState() as any).auth.token;",
|
||||
" if (token) {",
|
||||
" headers.set('authorization', `Bearer \\${token}`);",
|
||||
" }",
|
||||
" return headers;",
|
||||
" },",
|
||||
" }),",
|
||||
" tagTypes: ['${3:TagType}'],",
|
||||
" endpoints: builder => ({",
|
||||
" ${4:endpointName}: builder.query({",
|
||||
" query: () => '/${5:path}',",
|
||||
" providesTags: ['${3:TagType}'],",
|
||||
" }),",
|
||||
" }),",
|
||||
"});",
|
||||
"",
|
||||
"export const {use${6:EndpointName}Query} = ${1:serviceName}Api;"
|
||||
],
|
||||
"description": "Create an RTK Query API service"
|
||||
},
|
||||
|
||||
"React Hook": {
|
||||
"prefix": "rnhook",
|
||||
"body": [
|
||||
"import {useState, useEffect} from 'react';",
|
||||
"",
|
||||
"interface Use${1:HookName}Return {",
|
||||
" $2",
|
||||
"}",
|
||||
"",
|
||||
"export const use${1:HookName} = ($3): Use${1:HookName}Return => {",
|
||||
" const [${4:state}, set${5:State}] = useState($6);",
|
||||
"",
|
||||
" useEffect(() => {",
|
||||
" $7",
|
||||
" }, []);",
|
||||
"",
|
||||
" return {",
|
||||
" ${4:state},",
|
||||
" set${5:State},",
|
||||
" $8",
|
||||
" };",
|
||||
"};"
|
||||
],
|
||||
"description": "Create a custom React hook"
|
||||
},
|
||||
|
||||
"Test Component": {
|
||||
"prefix": "rntest",
|
||||
"body": [
|
||||
"import React from 'react';",
|
||||
"import {render, fireEvent} from '@testing-library/react-native';",
|
||||
"import {${1:ComponentName}} from '../${1:ComponentName}';",
|
||||
"",
|
||||
"describe('${1:ComponentName}', () => {",
|
||||
" it('renders correctly', () => {",
|
||||
" const {getByText} = render(<${1:ComponentName} $2 />);",
|
||||
" expect(getByText('$3')).toBeTruthy();",
|
||||
" });",
|
||||
"",
|
||||
" it('$4', () => {",
|
||||
" const mock${5:Function} = jest.fn();",
|
||||
" const {getByText} = render(",
|
||||
" <${1:ComponentName} ${6:prop}={mock${5:Function}} $7 />",
|
||||
" );",
|
||||
" ",
|
||||
" fireEvent.press(getByText('$8'));",
|
||||
" expect(mock${5:Function}).toHaveBeenCalledTimes(1);",
|
||||
" });",
|
||||
"});"
|
||||
],
|
||||
"description": "Create a test file for a component"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
After creating the snippets file, you can use these shortcuts in VSCode:
|
||||
|
||||
- `rnfc` - Creates a functional component
|
||||
- `rnscreen` - Creates a screen component with navigation
|
||||
- `reduxslice` - Creates a Redux Toolkit slice
|
||||
- `apiservice` - Creates an RTK Query API service
|
||||
- `rnhook` - Creates a custom React hook
|
||||
- `rntest` - Creates a test file template
|
||||
|
||||
## Additional Snippets
|
||||
|
||||
You can extend the snippets file with more specific patterns used in your project:
|
||||
|
||||
```json
|
||||
{
|
||||
"Styled Component": {
|
||||
"prefix": "rnstyle",
|
||||
"body": [
|
||||
"const styles = StyleSheet.create({",
|
||||
" ${1:styleName}: {",
|
||||
" $2",
|
||||
" },",
|
||||
"});"
|
||||
],
|
||||
"description": "Create StyleSheet styles"
|
||||
},
|
||||
|
||||
"Navigation Type": {
|
||||
"prefix": "navtype",
|
||||
"body": [
|
||||
"type ${1:StackName}ParamList = {",
|
||||
" ${2:ScreenName}: {${3:param}: ${4:type}};",
|
||||
" ${5:AnotherScreen}: undefined;",
|
||||
"};"
|
||||
],
|
||||
"description": "Create navigation param list type"
|
||||
}
|
||||
}
|
||||
```
|
|
@ -0,0 +1,281 @@
|
|||
# Asset Management
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
src/assets/
|
||||
├── images/
|
||||
│ ├── logo.png
|
||||
│ ├── logo@2x.png
|
||||
│ └── logo@3x.png
|
||||
├── icons/
|
||||
│ └── index.ts
|
||||
└── fonts/
|
||||
├── Montserrat-Regular.ttf
|
||||
└── Montserrat-Bold.ttf
|
||||
```
|
||||
|
||||
## Image Management
|
||||
|
||||
**Create image index file:**
|
||||
```typescript
|
||||
// src/assets/images/index.ts
|
||||
export const Images = {
|
||||
logo: require('./logo.png'),
|
||||
splash: require('./splash.png'),
|
||||
placeholder: require('./placeholder.png'),
|
||||
// Add more images here
|
||||
};
|
||||
```
|
||||
|
||||
**Usage in components:**
|
||||
```typescript
|
||||
import {Images} from '@assets/images';
|
||||
import {Image} from 'react-native';
|
||||
|
||||
const MyComponent = () => (
|
||||
<Image source={Images.logo} style={{width: 100, height: 100}} />
|
||||
);
|
||||
```
|
||||
|
||||
## Icon Management
|
||||
|
||||
**Install react-native-vector-icons:**
|
||||
```bash
|
||||
yarn add react-native-vector-icons
|
||||
yarn add -D @types/react-native-vector-icons
|
||||
|
||||
# iOS setup
|
||||
cd ios && pod install
|
||||
```
|
||||
|
||||
**Create icon component:**
|
||||
```typescript
|
||||
// src/components/common/Icon/Icon.tsx
|
||||
import React from 'react';
|
||||
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||
import Ionicons from 'react-native-vector-icons/Ionicons';
|
||||
import FontAwesome from 'react-native-vector-icons/FontAwesome';
|
||||
|
||||
type IconFamily = 'MaterialIcons' | 'Ionicons' | 'FontAwesome';
|
||||
|
||||
interface IconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
family?: IconFamily;
|
||||
}
|
||||
|
||||
const IconComponents = {
|
||||
MaterialIcons,
|
||||
Ionicons,
|
||||
FontAwesome,
|
||||
};
|
||||
|
||||
export const Icon: React.FC<IconProps> = ({
|
||||
name,
|
||||
size = 24,
|
||||
color = '#000',
|
||||
family = 'MaterialIcons',
|
||||
}) => {
|
||||
const IconComponent = IconComponents[family];
|
||||
return <IconComponent name={name} size={size} color={color} />;
|
||||
};
|
||||
```
|
||||
|
||||
## SVG Icons
|
||||
|
||||
**Install react-native-svg:**
|
||||
```bash
|
||||
yarn add react-native-svg
|
||||
yarn add -D @types/react-native-svg
|
||||
|
||||
# iOS setup
|
||||
cd ios && pod install
|
||||
```
|
||||
|
||||
**Create SVG icon component:**
|
||||
```typescript
|
||||
// src/components/common/SvgIcon/SvgIcon.tsx
|
||||
import React from 'react';
|
||||
import Svg, {Path, Circle, Rect} from 'react-native-svg';
|
||||
|
||||
interface SvgIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const iconPaths = {
|
||||
home: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z',
|
||||
user: 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z',
|
||||
// Add more icon paths
|
||||
};
|
||||
|
||||
export const SvgIcon: React.FC<SvgIconProps> = ({
|
||||
name,
|
||||
size = 24,
|
||||
color = '#000',
|
||||
}) => {
|
||||
const path = iconPaths[name as keyof typeof iconPaths];
|
||||
|
||||
if (!path) {
|
||||
console.warn(`Icon "${name}" not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d={path} />
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Image Optimization
|
||||
|
||||
**Install react-native-fast-image:**
|
||||
```bash
|
||||
yarn add react-native-fast-image
|
||||
|
||||
# iOS setup
|
||||
cd ios && pod install
|
||||
```
|
||||
|
||||
**Optimized image component:**
|
||||
```typescript
|
||||
// src/components/common/OptimizedImage/OptimizedImage.tsx
|
||||
import React from 'react';
|
||||
import FastImage, {FastImageProps} from 'react-native-fast-image';
|
||||
import {StyleSheet, View, ActivityIndicator} from 'react-native';
|
||||
|
||||
interface OptimizedImageProps extends FastImageProps {
|
||||
showLoader?: boolean;
|
||||
loaderColor?: string;
|
||||
}
|
||||
|
||||
export const OptimizedImage: React.FC<OptimizedImageProps> = ({
|
||||
showLoader = true,
|
||||
loaderColor = '#007AFF',
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<FastImage
|
||||
{...props}
|
||||
style={[StyleSheet.absoluteFillObject, style]}
|
||||
onLoadStart={() => setLoading(true)}
|
||||
onLoadEnd={() => setLoading(false)}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
{loading && showLoader && (
|
||||
<View style={styles.loader}>
|
||||
<ActivityIndicator color={loaderColor} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loader: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Asset Preloading
|
||||
|
||||
```typescript
|
||||
// src/utils/assetPreloader.ts
|
||||
import {Image} from 'react-native';
|
||||
import {Images} from '@assets/images';
|
||||
|
||||
export const preloadImages = async (): Promise<void> => {
|
||||
const imagePromises = Object.values(Images).map(image => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
Image.prefetch(Image.resolveAssetSource(image).uri)
|
||||
.then(() => resolve())
|
||||
.catch(reject);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(imagePromises);
|
||||
console.log('All images preloaded successfully');
|
||||
} catch (error) {
|
||||
console.error('Error preloading images:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Image Caching Strategy
|
||||
|
||||
```typescript
|
||||
// src/utils/imageCache.ts
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
|
||||
const CACHE_DIR = `${RNFS.CachesDirectoryPath}/images`;
|
||||
const CACHE_KEY_PREFIX = 'image_cache_';
|
||||
|
||||
export class ImageCache {
|
||||
static async getCachedImagePath(url: string): Promise<string | null> {
|
||||
try {
|
||||
const cacheKey = `${CACHE_KEY_PREFIX}${this.hashUrl(url)}`;
|
||||
const cachedPath = await AsyncStorage.getItem(cacheKey);
|
||||
|
||||
if (cachedPath && await RNFS.exists(cachedPath)) {
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error getting cached image:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async cacheImage(url: string): Promise<string> {
|
||||
try {
|
||||
const fileName = this.hashUrl(url);
|
||||
const filePath = `${CACHE_DIR}/${fileName}`;
|
||||
const cacheKey = `${CACHE_KEY_PREFIX}${fileName}`;
|
||||
|
||||
// Ensure cache directory exists
|
||||
await RNFS.mkdir(CACHE_DIR);
|
||||
|
||||
// Download and cache the image
|
||||
await RNFS.downloadFile({
|
||||
fromUrl: url,
|
||||
toFile: filePath,
|
||||
}).promise;
|
||||
|
||||
// Store the path in AsyncStorage
|
||||
await AsyncStorage.setItem(cacheKey, filePath);
|
||||
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
console.error('Error caching image:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static hashUrl(url: string): string {
|
||||
// Simple hash function for URL
|
||||
let hash = 0;
|
||||
for (let i = 0; i < url.length; i++) {
|
||||
const char = url.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash).toString();
|
||||
}
|
||||
}
|
||||
```
|
|
@ -0,0 +1,258 @@
|
|||
# Design System Setup
|
||||
|
||||
## Theme Configuration
|
||||
|
||||
**Install design dependencies:**
|
||||
```bash
|
||||
yarn add react-native-vector-icons react-native-svg
|
||||
yarn add -D @types/react-native-vector-icons
|
||||
```
|
||||
|
||||
**Theme Configuration:**
|
||||
```typescript
|
||||
// src/config/theme.ts
|
||||
export const Colors = {
|
||||
primary: '#007AFF',
|
||||
secondary: '#5856D6',
|
||||
success: '#34C759',
|
||||
warning: '#FF9500',
|
||||
error: '#FF3B30',
|
||||
background: '#F2F2F7',
|
||||
surface: '#FFFFFF',
|
||||
text: {
|
||||
primary: '#000000',
|
||||
secondary: '#6D6D80',
|
||||
disabled: '#C7C7CC',
|
||||
},
|
||||
border: '#E5E5E7',
|
||||
};
|
||||
|
||||
export const Typography = {
|
||||
h1: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700' as const,
|
||||
lineHeight: 38,
|
||||
},
|
||||
h2: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 30,
|
||||
},
|
||||
body: {
|
||||
fontSize: 16,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 22,
|
||||
},
|
||||
caption: {
|
||||
fontSize: 12,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 16,
|
||||
},
|
||||
};
|
||||
|
||||
export const Spacing = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
};
|
||||
```
|
||||
|
||||
## Design Tokens
|
||||
|
||||
Create a comprehensive design token system:
|
||||
|
||||
```typescript
|
||||
// src/config/designTokens.ts
|
||||
export const DesignTokens = {
|
||||
colors: {
|
||||
// Primary palette
|
||||
primary: {
|
||||
50: '#E3F2FD',
|
||||
100: '#BBDEFB',
|
||||
500: '#2196F3',
|
||||
700: '#1976D2',
|
||||
900: '#0D47A1',
|
||||
},
|
||||
// Semantic colors
|
||||
semantic: {
|
||||
success: '#4CAF50',
|
||||
warning: '#FF9800',
|
||||
error: '#F44336',
|
||||
info: '#2196F3',
|
||||
},
|
||||
// Neutral colors
|
||||
neutral: {
|
||||
white: '#FFFFFF',
|
||||
gray50: '#FAFAFA',
|
||||
gray100: '#F5F5F5',
|
||||
gray200: '#EEEEEE',
|
||||
gray300: '#E0E0E0',
|
||||
gray400: '#BDBDBD',
|
||||
gray500: '#9E9E9E',
|
||||
gray600: '#757575',
|
||||
gray700: '#616161',
|
||||
gray800: '#424242',
|
||||
gray900: '#212121',
|
||||
black: '#000000',
|
||||
},
|
||||
},
|
||||
|
||||
typography: {
|
||||
fontFamily: {
|
||||
regular: 'Montserrat-Regular',
|
||||
medium: 'Montserrat-Medium',
|
||||
semiBold: 'Montserrat-SemiBold',
|
||||
bold: 'Montserrat-Bold',
|
||||
},
|
||||
fontSize: {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
base: 16,
|
||||
lg: 18,
|
||||
xl: 20,
|
||||
'2xl': 24,
|
||||
'3xl': 30,
|
||||
'4xl': 36,
|
||||
},
|
||||
lineHeight: {
|
||||
tight: 1.25,
|
||||
normal: 1.5,
|
||||
relaxed: 1.75,
|
||||
},
|
||||
},
|
||||
|
||||
spacing: {
|
||||
0: 0,
|
||||
1: 4,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
5: 20,
|
||||
6: 24,
|
||||
8: 32,
|
||||
10: 40,
|
||||
12: 48,
|
||||
16: 64,
|
||||
20: 80,
|
||||
},
|
||||
|
||||
borderRadius: {
|
||||
none: 0,
|
||||
sm: 4,
|
||||
base: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 24,
|
||||
full: 9999,
|
||||
},
|
||||
|
||||
shadows: {
|
||||
sm: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {width: 0, height: 1},
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
base: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {width: 0, height: 2},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
lg: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {width: 0, height: 4},
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Theme Provider
|
||||
|
||||
```typescript
|
||||
// src/providers/ThemeProvider.tsx
|
||||
import React, {createContext, useContext, ReactNode} from 'react';
|
||||
import {DesignTokens} from '@config/designTokens';
|
||||
|
||||
interface ThemeContextType {
|
||||
colors: typeof DesignTokens.colors;
|
||||
typography: typeof DesignTokens.typography;
|
||||
spacing: typeof DesignTokens.spacing;
|
||||
borderRadius: typeof DesignTokens.borderRadius;
|
||||
shadows: typeof DesignTokens.shadows;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({children}) => {
|
||||
const theme = {
|
||||
colors: DesignTokens.colors,
|
||||
typography: DesignTokens.typography,
|
||||
spacing: DesignTokens.spacing,
|
||||
borderRadius: DesignTokens.borderRadius,
|
||||
shadows: DesignTokens.shadows,
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={theme}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = (): ThemeContextType => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
```typescript
|
||||
// src/utils/responsive.ts
|
||||
import {Dimensions, PixelRatio} from 'react-native';
|
||||
|
||||
const {width: SCREEN_WIDTH, height: SCREEN_HEIGHT} = Dimensions.get('window');
|
||||
|
||||
// Based on iPhone 6/7/8 dimensions
|
||||
const BASE_WIDTH = 375;
|
||||
const BASE_HEIGHT = 667;
|
||||
|
||||
export const wp = (percentage: number): number => {
|
||||
const value = (percentage * SCREEN_WIDTH) / 100;
|
||||
return Math.round(PixelRatio.roundToNearestPixel(value));
|
||||
};
|
||||
|
||||
export const hp = (percentage: number): number => {
|
||||
const value = (percentage * SCREEN_HEIGHT) / 100;
|
||||
return Math.round(PixelRatio.roundToNearestPixel(value));
|
||||
};
|
||||
|
||||
export const normalize = (size: number): number => {
|
||||
const scale = SCREEN_WIDTH / BASE_WIDTH;
|
||||
const newSize = size * scale;
|
||||
return Math.round(PixelRatio.roundToNearestPixel(newSize));
|
||||
};
|
||||
|
||||
export const isTablet = (): boolean => {
|
||||
return SCREEN_WIDTH >= 768;
|
||||
};
|
||||
|
||||
export const getDeviceType = (): 'phone' | 'tablet' => {
|
||||
return isTablet() ? 'tablet' : 'phone';
|
||||
};
|
||||
```
|
|
@ -0,0 +1,295 @@
|
|||
# Icons & Fonts
|
||||
|
||||
## App Icon & Splash Screen
|
||||
|
||||
### Generate App Icons
|
||||
|
||||
**Using IconKitchen or manually:**
|
||||
|
||||
1. **Android**: Place icons in `android/app/src/main/res/mipmap-*`
|
||||
2. **iOS**: Use Xcode Asset Catalog in `ios/SaayamApp/Images.xcassets`
|
||||
|
||||
**Android Icon Sizes:**
|
||||
- `mipmap-mdpi`: 48x48px
|
||||
- `mipmap-hdpi`: 72x72px
|
||||
- `mipmap-xhdpi`: 96x96px
|
||||
- `mipmap-xxhdpi`: 144x144px
|
||||
- `mipmap-xxxhdpi`: 192x192px
|
||||
|
||||
**iOS Icon Sizes:**
|
||||
- iPhone: 60x60pt (120x120px, 180x180px)
|
||||
- iPad: 76x76pt (152x152px, 228x228px)
|
||||
- App Store: 1024x1024px
|
||||
|
||||
### Splash Screen Setup
|
||||
|
||||
**Install react-native-splash-screen:**
|
||||
```bash
|
||||
yarn add react-native-splash-screen
|
||||
```
|
||||
|
||||
**Android Configuration:**
|
||||
|
||||
1. Create `android/app/src/main/res/drawable/launch_screen.xml`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/primary_color" />
|
||||
<item
|
||||
android:width="200dp"
|
||||
android:height="200dp"
|
||||
android:drawable="@mipmap/launch_icon"
|
||||
android:gravity="center" />
|
||||
</layer-list>
|
||||
```
|
||||
|
||||
2. Update `android/app/src/main/res/values/styles.xml`:
|
||||
```xml
|
||||
<resources>
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||
</style>
|
||||
|
||||
<style name="SplashTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@drawable/launch_screen</item>
|
||||
</style>
|
||||
</resources>
|
||||
```
|
||||
|
||||
**iOS Configuration:**
|
||||
|
||||
1. Create LaunchScreen.storyboard in Xcode
|
||||
2. Add your logo and configure constraints
|
||||
|
||||
## Custom Fonts
|
||||
|
||||
### Font Installation
|
||||
|
||||
**Add fonts to project:**
|
||||
|
||||
1. Create `src/assets/fonts/` directory
|
||||
2. Add font files (.ttf, .otf)
|
||||
3. Configure for each platform
|
||||
|
||||
**Android Configuration:**
|
||||
|
||||
1. Copy fonts to `android/app/src/main/assets/fonts/`
|
||||
2. Use font family name in styles
|
||||
|
||||
**iOS Configuration:**
|
||||
|
||||
1. Add fonts to Xcode project
|
||||
2. Update `ios/SaayamApp/Info.plist`:
|
||||
```xml
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>Montserrat-Regular.ttf</string>
|
||||
<string>Montserrat-Bold.ttf</string>
|
||||
<string>Montserrat-SemiBold.ttf</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Font Helper
|
||||
|
||||
```typescript
|
||||
// src/utils/fontHelper.ts
|
||||
import {Platform} from 'react-native';
|
||||
|
||||
export const FontFamily = {
|
||||
regular: Platform.select({
|
||||
ios: 'Montserrat-Regular',
|
||||
android: 'Montserrat-Regular',
|
||||
}),
|
||||
medium: Platform.select({
|
||||
ios: 'Montserrat-Medium',
|
||||
android: 'Montserrat-Medium',
|
||||
}),
|
||||
semiBold: Platform.select({
|
||||
ios: 'Montserrat-SemiBold',
|
||||
android: 'Montserrat-SemiBold',
|
||||
}),
|
||||
bold: Platform.select({
|
||||
ios: 'Montserrat-Bold',
|
||||
android: 'Montserrat-Bold',
|
||||
}),
|
||||
};
|
||||
|
||||
export const getFontFamily = (weight: 'regular' | 'medium' | 'semiBold' | 'bold' = 'regular'): string => {
|
||||
return FontFamily[weight] || FontFamily.regular;
|
||||
};
|
||||
```
|
||||
|
||||
## Icon Font Generation
|
||||
|
||||
### Using IcoMoon
|
||||
|
||||
1. **Visit IcoMoon.io**
|
||||
2. **Import SVG icons**
|
||||
3. **Generate font**
|
||||
4. **Download and integrate**
|
||||
|
||||
**IcoMoon Integration:**
|
||||
```typescript
|
||||
// src/config/iconFont.ts
|
||||
import {createIconSetFromIcoMoon} from 'react-native-vector-icons/lib/create-icon-set-from-icomoon';
|
||||
import icoMoonConfig from '../assets/fonts/selection.json';
|
||||
|
||||
export const CustomIcon = createIconSetFromIcoMoon(
|
||||
icoMoonConfig,
|
||||
'CustomIcons',
|
||||
'CustomIcons.ttf'
|
||||
);
|
||||
```
|
||||
|
||||
### Custom Icon Component
|
||||
|
||||
```typescript
|
||||
// src/components/common/CustomIcon/CustomIcon.tsx
|
||||
import React from 'react';
|
||||
import {createIconSetFromIcoMoon} from 'react-native-vector-icons/lib/create-icon-set-from-icomoon';
|
||||
import icoMoonConfig from '@assets/fonts/selection.json';
|
||||
|
||||
const IconFont = createIconSetFromIcoMoon(
|
||||
icoMoonConfig,
|
||||
'SaayamIcons',
|
||||
'SaayamIcons.ttf'
|
||||
);
|
||||
|
||||
interface CustomIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
style?: any;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export const CustomIcon: React.FC<CustomIconProps> = ({
|
||||
name,
|
||||
size = 24,
|
||||
color = '#000',
|
||||
style,
|
||||
onPress,
|
||||
}) => {
|
||||
return (
|
||||
<IconFont
|
||||
name={name}
|
||||
size={size}
|
||||
color={color}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Icon Library Setup
|
||||
|
||||
```typescript
|
||||
// src/config/icons.ts
|
||||
export const IconLibrary = {
|
||||
// Navigation icons
|
||||
home: 'home',
|
||||
profile: 'user',
|
||||
settings: 'settings',
|
||||
back: 'arrow-left',
|
||||
|
||||
// Action icons
|
||||
add: 'plus',
|
||||
edit: 'edit',
|
||||
delete: 'trash',
|
||||
save: 'check',
|
||||
cancel: 'x',
|
||||
|
||||
// Status icons
|
||||
success: 'check-circle',
|
||||
error: 'x-circle',
|
||||
warning: 'alert-triangle',
|
||||
info: 'info',
|
||||
|
||||
// Social icons
|
||||
facebook: 'facebook',
|
||||
google: 'google',
|
||||
apple: 'apple',
|
||||
|
||||
// Feature icons
|
||||
camera: 'camera',
|
||||
gallery: 'image',
|
||||
location: 'map-pin',
|
||||
notification: 'bell',
|
||||
search: 'search',
|
||||
filter: 'filter',
|
||||
share: 'share',
|
||||
favorite: 'heart',
|
||||
star: 'star',
|
||||
};
|
||||
|
||||
export type IconName = keyof typeof IconLibrary;
|
||||
```
|
||||
|
||||
## Dynamic Icon Loading
|
||||
|
||||
```typescript
|
||||
// src/utils/iconLoader.ts
|
||||
import {IconLibrary, IconName} from '@config/icons';
|
||||
|
||||
export class IconLoader {
|
||||
private static loadedIcons: Set<string> = new Set();
|
||||
|
||||
static async preloadIcons(iconNames: IconName[]): Promise<void> {
|
||||
const promises = iconNames.map(async (iconName) => {
|
||||
if (!this.loadedIcons.has(iconName)) {
|
||||
// Preload icon font glyphs
|
||||
await this.loadIcon(iconName);
|
||||
this.loadedIcons.add(iconName);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
private static async loadIcon(iconName: IconName): Promise<void> {
|
||||
// Implementation depends on your icon font setup
|
||||
return new Promise((resolve) => {
|
||||
// Simulate icon loading
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
}
|
||||
|
||||
static isIconLoaded(iconName: IconName): boolean {
|
||||
return this.loadedIcons.has(iconName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Font Size Scaling
|
||||
|
||||
```typescript
|
||||
// src/utils/fontScaling.ts
|
||||
import {Dimensions, PixelRatio} from 'react-native';
|
||||
|
||||
const {width: SCREEN_WIDTH} = Dimensions.get('window');
|
||||
const BASE_WIDTH = 375; // iPhone 6/7/8 width
|
||||
|
||||
export const fontSizer = (size: number): number => {
|
||||
const scale = SCREEN_WIDTH / BASE_WIDTH;
|
||||
const newSize = size * scale;
|
||||
|
||||
// Ensure minimum readable size
|
||||
const minSize = size * 0.8;
|
||||
const maxSize = size * 1.2;
|
||||
|
||||
return Math.max(minSize, Math.min(maxSize, Math.round(PixelRatio.roundToNearestPixel(newSize))));
|
||||
};
|
||||
|
||||
export const FontSizes = {
|
||||
xs: fontSizer(10),
|
||||
sm: fontSizer(12),
|
||||
base: fontSizer(14),
|
||||
lg: fontSizer(16),
|
||||
xl: fontSizer(18),
|
||||
'2xl': fontSizer(20),
|
||||
'3xl': fontSizer(24),
|
||||
'4xl': fontSizer(28),
|
||||
'5xl': fontSizer(32),
|
||||
};
|
||||
```
|
|
@ -0,0 +1,536 @@
|
|||
# API Integration
|
||||
|
||||
## RTK Query Setup
|
||||
|
||||
```typescript
|
||||
// src/services/api.ts
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||
import Config from 'react-native-config';
|
||||
import { RootState } from '@redux/store';
|
||||
|
||||
export const api = createApi({
|
||||
reducerPath: 'api',
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: Config.API_BASE_URL,
|
||||
prepareHeaders: (headers, { getState }) => {
|
||||
const token = (getState() as RootState).auth.token;
|
||||
if (token) {
|
||||
headers.set('authorization', `Bearer ${token}`);
|
||||
}
|
||||
headers.set('Content-Type', 'application/json');
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
tagTypes: ['User', 'NGO', 'Donation', 'Request'],
|
||||
endpoints: () => ({}),
|
||||
});
|
||||
```
|
||||
|
||||
## Auth API
|
||||
|
||||
```typescript
|
||||
// src/services/authAPI.ts
|
||||
import { api } from './api';
|
||||
|
||||
interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
interface RegisterRequest {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export const authAPI = api.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
login: builder.mutation<LoginResponse, LoginRequest>({
|
||||
query: (credentials) => ({
|
||||
url: '/auth/login',
|
||||
method: 'POST',
|
||||
body: credentials,
|
||||
}),
|
||||
}),
|
||||
register: builder.mutation<LoginResponse, RegisterRequest>({
|
||||
query: (userData) => ({
|
||||
url: '/auth/register',
|
||||
method: 'POST',
|
||||
body: userData,
|
||||
}),
|
||||
}),
|
||||
refreshToken: builder.mutation<{ token: string }, { refreshToken: string }>(
|
||||
{
|
||||
query: ({ refreshToken }) => ({
|
||||
url: '/auth/refresh',
|
||||
method: 'POST',
|
||||
body: { refreshToken },
|
||||
}),
|
||||
},
|
||||
),
|
||||
logout: builder.mutation<void, void>({
|
||||
query: () => ({
|
||||
url: '/auth/logout',
|
||||
method: 'POST',
|
||||
}),
|
||||
}),
|
||||
forgotPassword: builder.mutation<void, { email: string }>({
|
||||
query: ({ email }) => ({
|
||||
url: '/auth/forgot-password',
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
}),
|
||||
}),
|
||||
verifyOTP: builder.mutation<void, { email: string; otp: string }>({
|
||||
query: ({ email, otp }) => ({
|
||||
url: '/auth/verify-otp',
|
||||
method: 'POST',
|
||||
body: { email, otp },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useLoginMutation,
|
||||
useRegisterMutation,
|
||||
useRefreshTokenMutation,
|
||||
useLogoutMutation,
|
||||
useForgotPasswordMutation,
|
||||
useVerifyOTPMutation,
|
||||
} = authAPI;
|
||||
```
|
||||
|
||||
## User API
|
||||
|
||||
```typescript
|
||||
// src/services/userAPI.ts
|
||||
import { api } from './api';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
avatar?: string;
|
||||
isNGO: boolean;
|
||||
ngoDetails?: NGODetails;
|
||||
}
|
||||
|
||||
interface NGODetails {
|
||||
ngoName: string;
|
||||
registrationNumber: string;
|
||||
address: string;
|
||||
description: string;
|
||||
causes: string[];
|
||||
}
|
||||
|
||||
interface UpdateProfileRequest {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export const userAPI = api.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getProfile: builder.query<User, void>({
|
||||
query: () => '/user/profile',
|
||||
providesTags: ['User'],
|
||||
}),
|
||||
updateProfile: builder.mutation<User, UpdateProfileRequest>({
|
||||
query: (updates) => ({
|
||||
url: '/user/profile',
|
||||
method: 'PUT',
|
||||
body: updates,
|
||||
}),
|
||||
invalidatesTags: ['User'],
|
||||
}),
|
||||
uploadAvatar: builder.mutation<{ avatarUrl: string }, FormData>({
|
||||
query: (formData) => ({
|
||||
url: '/user/avatar',
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}),
|
||||
invalidatesTags: ['User'],
|
||||
}),
|
||||
deleteAccount: builder.mutation<void, void>({
|
||||
query: () => ({
|
||||
url: '/user/account',
|
||||
method: 'DELETE',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetProfileQuery,
|
||||
useUpdateProfileMutation,
|
||||
useUploadAvatarMutation,
|
||||
useDeleteAccountMutation,
|
||||
} = userAPI;
|
||||
```
|
||||
|
||||
## NGO API
|
||||
|
||||
```typescript
|
||||
// src/services/ngoAPI.ts
|
||||
import { api } from './api';
|
||||
|
||||
interface NGO {
|
||||
id: string;
|
||||
ngoName: string;
|
||||
description: string;
|
||||
registrationNumber: string;
|
||||
address: string;
|
||||
causes: string[];
|
||||
coverImage?: string;
|
||||
isVerified: boolean;
|
||||
totalDonations: number;
|
||||
activeRequests: number;
|
||||
}
|
||||
|
||||
interface CreateNGORequest {
|
||||
ngoName: string;
|
||||
description: string;
|
||||
registrationNumber: string;
|
||||
address: string;
|
||||
causes: string[];
|
||||
documents: string[];
|
||||
}
|
||||
|
||||
export const ngoAPI = api.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getNGOList: builder.query<
|
||||
NGO[],
|
||||
{ page?: number; limit?: number; search?: string }
|
||||
>({
|
||||
query: ({ page = 1, limit = 10, search }) => ({
|
||||
url: '/ngo',
|
||||
params: { page, limit, search },
|
||||
}),
|
||||
providesTags: ['NGO'],
|
||||
}),
|
||||
getNGOById: builder.query<NGO, string>({
|
||||
query: (id) => `/ngo/${id}`,
|
||||
providesTags: ['NGO'],
|
||||
}),
|
||||
createNGO: builder.mutation<NGO, CreateNGORequest>({
|
||||
query: (ngoData) => ({
|
||||
url: '/ngo',
|
||||
method: 'POST',
|
||||
body: ngoData,
|
||||
}),
|
||||
invalidatesTags: ['NGO', 'User'],
|
||||
}),
|
||||
updateNGO: builder.mutation<
|
||||
NGO,
|
||||
{ id: string; updates: Partial<CreateNGORequest> }
|
||||
>({
|
||||
query: ({ id, updates }) => ({
|
||||
url: `/ngo/${id}`,
|
||||
method: 'PUT',
|
||||
body: updates,
|
||||
}),
|
||||
invalidatesTags: ['NGO'],
|
||||
}),
|
||||
getFavoriteNGOs: builder.query<NGO[], void>({
|
||||
query: () => '/ngo/favorites',
|
||||
providesTags: ['NGO'],
|
||||
}),
|
||||
toggleFavoriteNGO: builder.mutation<void, string>({
|
||||
query: (ngoId) => ({
|
||||
url: `/ngo/${ngoId}/favorite`,
|
||||
method: 'POST',
|
||||
}),
|
||||
invalidatesTags: ['NGO'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetNGOListQuery,
|
||||
useGetNGOByIdQuery,
|
||||
useCreateNGOMutation,
|
||||
useUpdateNGOMutation,
|
||||
useGetFavoriteNGOsQuery,
|
||||
useToggleFavoriteNGOMutation,
|
||||
} = ngoAPI;
|
||||
```
|
||||
|
||||
## Request API
|
||||
|
||||
```typescript
|
||||
// src/services/requestAPI.ts
|
||||
import { api } from './api';
|
||||
|
||||
interface FundraisingRequest {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
targetAmount: number;
|
||||
currentAmount: number;
|
||||
images: string[];
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
};
|
||||
status: 'active' | 'completed' | 'cancelled';
|
||||
createdBy: string;
|
||||
ngoId?: string;
|
||||
isUrgent: boolean;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
interface CreateRequestData {
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
targetAmount: number;
|
||||
images: string[];
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
};
|
||||
isUrgent: boolean;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export const requestAPI = api.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getRequests: builder.query<
|
||||
FundraisingRequest[],
|
||||
{
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: string;
|
||||
location?: string;
|
||||
urgent?: boolean;
|
||||
}
|
||||
>({
|
||||
query: (params) => ({
|
||||
url: '/requests',
|
||||
params,
|
||||
}),
|
||||
providesTags: ['Request'],
|
||||
}),
|
||||
getRequestById: builder.query<FundraisingRequest, string>({
|
||||
query: (id) => `/requests/${id}`,
|
||||
providesTags: ['Request'],
|
||||
}),
|
||||
createRequest: builder.mutation<FundraisingRequest, CreateRequestData>({
|
||||
query: (requestData) => ({
|
||||
url: '/requests',
|
||||
method: 'POST',
|
||||
body: requestData,
|
||||
}),
|
||||
invalidatesTags: ['Request'],
|
||||
}),
|
||||
updateRequest: builder.mutation<
|
||||
FundraisingRequest,
|
||||
{
|
||||
id: string;
|
||||
updates: Partial<CreateRequestData>;
|
||||
}
|
||||
>({
|
||||
query: ({ id, updates }) => ({
|
||||
url: `/requests/${id}`,
|
||||
method: 'PUT',
|
||||
body: updates,
|
||||
}),
|
||||
invalidatesTags: ['Request'],
|
||||
}),
|
||||
deleteRequest: builder.mutation<void, string>({
|
||||
query: (id) => ({
|
||||
url: `/requests/${id}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: ['Request'],
|
||||
}),
|
||||
getMyRequests: builder.query<FundraisingRequest[], void>({
|
||||
query: () => '/requests/my-requests',
|
||||
providesTags: ['Request'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetRequestsQuery,
|
||||
useGetRequestByIdQuery,
|
||||
useCreateRequestMutation,
|
||||
useUpdateRequestMutation,
|
||||
useDeleteRequestMutation,
|
||||
useGetMyRequestsQuery,
|
||||
} = requestAPI;
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
// src/utils/errorHandler.ts
|
||||
import { SerializedError } from '@reduxjs/toolkit';
|
||||
import { FetchBaseQueryError } from '@reduxjs/toolkit/query';
|
||||
|
||||
export interface APIError {
|
||||
message: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export const handleAPIError = (
|
||||
error: FetchBaseQueryError | SerializedError,
|
||||
): APIError => {
|
||||
if ('status' in error) {
|
||||
// FetchBaseQueryError
|
||||
const status = error.status;
|
||||
const data = error.data as any;
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
return {
|
||||
message: data?.message || 'Bad request',
|
||||
status: 400,
|
||||
code: 'BAD_REQUEST',
|
||||
};
|
||||
case 401:
|
||||
return {
|
||||
message: 'Authentication required',
|
||||
status: 401,
|
||||
code: 'UNAUTHORIZED',
|
||||
};
|
||||
case 403:
|
||||
return {
|
||||
message: 'Access denied',
|
||||
status: 403,
|
||||
code: 'FORBIDDEN',
|
||||
};
|
||||
case 404:
|
||||
return {
|
||||
message: 'Resource not found',
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
};
|
||||
case 500:
|
||||
return {
|
||||
message: 'Server error. Please try again later.',
|
||||
status: 500,
|
||||
code: 'SERVER_ERROR',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
message: data?.message || 'An unexpected error occurred',
|
||||
status: typeof status === 'number' ? status : undefined,
|
||||
code: 'UNKNOWN_ERROR',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// SerializedError
|
||||
return {
|
||||
message: error.message || 'Network error',
|
||||
code: 'NETWORK_ERROR',
|
||||
};
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## API Hooks
|
||||
|
||||
```typescript
|
||||
// src/hooks/useAPI.ts
|
||||
import { useState, useCallback } from 'react';
|
||||
import { handleAPIError, APIError } from '@utils/errorHandler';
|
||||
|
||||
interface UseAPIState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: APIError | null;
|
||||
}
|
||||
|
||||
export const useAPI = <T>() => {
|
||||
const [state, setState] = useState<UseAPIState<T>>({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const execute = useCallback(async (apiCall: () => Promise<T>) => {
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const data = await apiCall();
|
||||
setState({ data, loading: false, error: null });
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
const apiError = handleAPIError(error);
|
||||
setState((prev) => ({ ...prev, loading: false, error: apiError }));
|
||||
throw apiError;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({ data: null, loading: false, error: null });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
execute,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Store Integration
|
||||
|
||||
```typescript
|
||||
// src/redux/store/index.ts (updated)
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { persistStore, persistReducer } from 'redux-persist';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { combineReducers } from '@reduxjs/toolkit';
|
||||
import { api } from '@services/api';
|
||||
import authSlice from '../slices/authSlice';
|
||||
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage: AsyncStorage,
|
||||
whitelist: ['auth'],
|
||||
};
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
auth: authSlice,
|
||||
[api.reducerPath]: api.reducer,
|
||||
});
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, rootReducer);
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: persistedReducer,
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
|
||||
},
|
||||
}).concat(api.middleware),
|
||||
});
|
||||
|
||||
export const persistor = persistStore(store);
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
```
|
|
@ -0,0 +1,364 @@
|
|||
# Navigation Setup
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
yarn add @react-navigation/native @react-navigation/stack @react-navigation/bottom-tabs
|
||||
yarn add react-native-screens react-native-safe-area-context react-native-gesture-handler
|
||||
```
|
||||
|
||||
## iOS Setup
|
||||
|
||||
```bash
|
||||
cd ios && pod install
|
||||
```
|
||||
|
||||
## Navigation Types
|
||||
|
||||
```typescript
|
||||
// src/navigation/types.ts
|
||||
export type RootStackParamList = {
|
||||
Auth: undefined;
|
||||
Main: undefined;
|
||||
Splash: undefined;
|
||||
};
|
||||
|
||||
export type AuthStackParamList = {
|
||||
Login: undefined;
|
||||
Register: undefined;
|
||||
ForgotPassword: undefined;
|
||||
OTPVerification: {email: string};
|
||||
};
|
||||
|
||||
export type MainTabParamList = {
|
||||
Home: undefined;
|
||||
Profile: undefined;
|
||||
Settings: undefined;
|
||||
Notifications: undefined;
|
||||
};
|
||||
|
||||
export type HomeStackParamList = {
|
||||
HomeScreen: undefined;
|
||||
Details: {id: string};
|
||||
Search: undefined;
|
||||
};
|
||||
```
|
||||
|
||||
## Root Navigator
|
||||
|
||||
```typescript
|
||||
// src/navigation/RootNavigator.tsx
|
||||
import React from 'react';
|
||||
import {NavigationContainer} from '@react-navigation/native';
|
||||
import {createStackNavigator} from '@react-navigation/stack';
|
||||
import {useAppSelector} from '@redux/hooks';
|
||||
import {selectIsAuthenticated} from '@redux/selectors/authSelectors';
|
||||
|
||||
import AuthNavigator from './AuthNavigator';
|
||||
import MainNavigator from './MainNavigator';
|
||||
import SplashScreen from '@screens/SplashScreen';
|
||||
import {RootStackParamList} from './types';
|
||||
|
||||
const Stack = createStackNavigator<RootStackParamList>();
|
||||
|
||||
export const RootNavigator: React.FC = () => {
|
||||
const isAuthenticated = useAppSelector(selectIsAuthenticated);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Simulate app initialization
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator screenOptions={{headerShown: false}}>
|
||||
{isLoading ? (
|
||||
<Stack.Screen name="Splash" component={SplashScreen} />
|
||||
) : isAuthenticated ? (
|
||||
<Stack.Screen name="Main" component={MainNavigator} />
|
||||
) : (
|
||||
<Stack.Screen name="Auth" component={AuthNavigator} />
|
||||
)}
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Auth Navigator
|
||||
|
||||
```typescript
|
||||
// src/navigation/AuthNavigator.tsx
|
||||
import React from 'react';
|
||||
import {createStackNavigator} from '@react-navigation/stack';
|
||||
import {AuthStackParamList} from './types';
|
||||
|
||||
import LoginScreen from '@screens/Auth/LoginScreen';
|
||||
import RegisterScreen from '@screens/Auth/RegisterScreen';
|
||||
import ForgotPasswordScreen from '@screens/Auth/ForgotPasswordScreen';
|
||||
import OTPVerificationScreen from '@screens/Auth/OTPVerificationScreen';
|
||||
|
||||
const Stack = createStackNavigator<AuthStackParamList>();
|
||||
|
||||
const AuthNavigator: React.FC = () => {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
initialRouteName="Login"
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
cardStyleInterpolator: ({current, layouts}) => {
|
||||
return {
|
||||
cardStyle: {
|
||||
transform: [
|
||||
{
|
||||
translateX: current.progress.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [layouts.screen.width, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
}}>
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
<Stack.Screen name="Register" component={RegisterScreen} />
|
||||
<Stack.Screen name="ForgotPassword" component={ForgotPasswordScreen} />
|
||||
<Stack.Screen name="OTPVerification" component={OTPVerificationScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthNavigator;
|
||||
```
|
||||
|
||||
## Main Navigator (Bottom Tabs)
|
||||
|
||||
```typescript
|
||||
// src/navigation/MainNavigator.tsx
|
||||
import React from 'react';
|
||||
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
|
||||
import {MainTabParamList} from './types';
|
||||
import {CustomIcon} from '@components/common/CustomIcon';
|
||||
import {useTheme} from '@providers/ThemeProvider';
|
||||
|
||||
import HomeNavigator from './HomeNavigator';
|
||||
import ProfileScreen from '@screens/Profile/ProfileScreen';
|
||||
import SettingsScreen from '@screens/Settings/SettingsScreen';
|
||||
import NotificationsScreen from '@screens/Notifications/NotificationsScreen';
|
||||
|
||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||
|
||||
const MainNavigator: React.FC = () => {
|
||||
const {colors} = useTheme();
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={({route}) => ({
|
||||
headerShown: false,
|
||||
tabBarIcon: ({focused, color, size}) => {
|
||||
let iconName: string;
|
||||
|
||||
switch (route.name) {
|
||||
case 'Home':
|
||||
iconName = 'home';
|
||||
break;
|
||||
case 'Profile':
|
||||
iconName = 'user';
|
||||
break;
|
||||
case 'Settings':
|
||||
iconName = 'settings';
|
||||
break;
|
||||
case 'Notifications':
|
||||
iconName = 'bell';
|
||||
break;
|
||||
default:
|
||||
iconName = 'home';
|
||||
}
|
||||
|
||||
return <CustomIcon name={iconName} size={size} color={color} />;
|
||||
},
|
||||
tabBarActiveTintColor: colors.primary[500],
|
||||
tabBarInactiveTintColor: colors.neutral.gray500,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.neutral.white,
|
||||
borderTopColor: colors.neutral.gray200,
|
||||
paddingBottom: 5,
|
||||
height: 60,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
})}>
|
||||
<Tab.Screen name="Home" component={HomeNavigator} />
|
||||
<Tab.Screen name="Profile" component={ProfileScreen} />
|
||||
<Tab.Screen name="Settings" component={SettingsScreen} />
|
||||
<Tab.Screen name="Notifications" component={NotificationsScreen} />
|
||||
</Tab.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainNavigator;
|
||||
```
|
||||
|
||||
## Home Stack Navigator
|
||||
|
||||
```typescript
|
||||
// src/navigation/HomeNavigator.tsx
|
||||
import React from 'react';
|
||||
import {createStackNavigator} from '@react-navigation/stack';
|
||||
import {HomeStackParamList} from './types';
|
||||
|
||||
import HomeScreen from '@screens/Home/HomeScreen';
|
||||
import DetailsScreen from '@screens/Home/DetailsScreen';
|
||||
import SearchScreen from '@screens/Home/SearchScreen';
|
||||
|
||||
const Stack = createStackNavigator<HomeStackParamList>();
|
||||
|
||||
const HomeNavigator: React.FC = () => {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
initialRouteName="HomeScreen"
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}>
|
||||
<Stack.Screen name="HomeScreen" component={HomeScreen} />
|
||||
<Stack.Screen name="Details" component={DetailsScreen} />
|
||||
<Stack.Screen name="Search" component={SearchScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeNavigator;
|
||||
```
|
||||
|
||||
## Navigation Service
|
||||
|
||||
```typescript
|
||||
// src/navigation/NavigationService.ts
|
||||
import {createNavigationContainerRef, StackActions} from '@react-navigation/native';
|
||||
|
||||
export const navigationRef = createNavigationContainerRef();
|
||||
|
||||
export function navigate(name: string, params?: any) {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate(name as never, params as never);
|
||||
}
|
||||
}
|
||||
|
||||
export function goBack() {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
export function reset(routeName: string) {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.reset({
|
||||
index: 0,
|
||||
routes: [{name: routeName}],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function push(name: string, params?: any) {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.dispatch(StackActions.push(name, params));
|
||||
}
|
||||
}
|
||||
|
||||
export function replace(name: string, params?: any) {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.dispatch(StackActions.replace(name, params));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Hooks
|
||||
|
||||
```typescript
|
||||
// src/hooks/useNavigation.ts
|
||||
import {useNavigation as useRNNavigation} from '@react-navigation/native';
|
||||
import {StackNavigationProp} from '@react-navigation/stack';
|
||||
import {RootStackParamList} from '@navigation/types';
|
||||
|
||||
export type NavigationProp = StackNavigationProp<RootStackParamList>;
|
||||
|
||||
export const useNavigation = () => {
|
||||
return useRNNavigation<NavigationProp>();
|
||||
};
|
||||
```
|
||||
|
||||
## Deep Linking
|
||||
|
||||
```typescript
|
||||
// src/navigation/LinkingConfiguration.ts
|
||||
import {LinkingOptions} from '@react-navigation/native';
|
||||
import {RootStackParamList} from './types';
|
||||
|
||||
const linking: LinkingOptions<RootStackParamList> = {
|
||||
prefixes: ['saayam://'],
|
||||
config: {
|
||||
screens: {
|
||||
Auth: {
|
||||
screens: {
|
||||
Login: 'login',
|
||||
Register: 'register',
|
||||
ForgotPassword: 'forgot-password',
|
||||
OTPVerification: 'otp-verification',
|
||||
},
|
||||
},
|
||||
Main: {
|
||||
screens: {
|
||||
Home: {
|
||||
screens: {
|
||||
HomeScreen: 'home',
|
||||
Details: 'details/:id',
|
||||
Search: 'search',
|
||||
},
|
||||
},
|
||||
Profile: 'profile',
|
||||
Settings: 'settings',
|
||||
Notifications: 'notifications',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default linking;
|
||||
```
|
||||
|
||||
## Navigation Container Setup
|
||||
|
||||
```typescript
|
||||
// App.tsx (updated)
|
||||
import React from 'react';
|
||||
import {NavigationContainer} from '@react-navigation/native';
|
||||
import {Provider} from 'react-redux';
|
||||
import {PersistGate} from 'redux-persist/integration/react';
|
||||
import {store, persistor} from './src/redux/store';
|
||||
import {RootNavigator} from './src/navigation/RootNavigator';
|
||||
import {navigationRef} from './src/navigation/NavigationService';
|
||||
import linking from './src/navigation/LinkingConfiguration';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<NavigationContainer ref={navigationRef} linking={linking}>
|
||||
<RootNavigator />
|
||||
</NavigationContainer>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
```
|
|
@ -0,0 +1,264 @@
|
|||
# Redux Toolkit Setup
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
yarn add @reduxjs/toolkit react-redux redux-persist
|
||||
yarn add -D @types/react-redux
|
||||
```
|
||||
|
||||
## Store Configuration
|
||||
|
||||
```typescript
|
||||
// src/redux/store/index.ts
|
||||
import {configureStore} from '@reduxjs/toolkit';
|
||||
import {persistStore, persistReducer} from 'redux-persist';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {combineReducers} from '@reduxjs/toolkit';
|
||||
import authSlice from '../slices/authSlice';
|
||||
import userSlice from '../slices/userSlice';
|
||||
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage: AsyncStorage,
|
||||
whitelist: ['auth'],
|
||||
};
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
auth: authSlice,
|
||||
user: userSlice,
|
||||
});
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, rootReducer);
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: persistedReducer,
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const persistor = persistStore(store);
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
```
|
||||
|
||||
## Auth Slice
|
||||
|
||||
```typescript
|
||||
// src/redux/slices/authSlice.ts
|
||||
import {createSlice, PayloadAction} from '@reduxjs/toolkit';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
loginStart: (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
},
|
||||
loginSuccess: (state, action: PayloadAction<{user: User; token: string; refreshToken: string}>) => {
|
||||
state.loading = false;
|
||||
state.isAuthenticated = true;
|
||||
state.user = action.payload.user;
|
||||
state.token = action.payload.token;
|
||||
state.refreshToken = action.payload.refreshToken;
|
||||
state.error = null;
|
||||
},
|
||||
loginFailure: (state, action: PayloadAction<string>) => {
|
||||
state.loading = false;
|
||||
state.isAuthenticated = false;
|
||||
state.user = null;
|
||||
state.token = null;
|
||||
state.refreshToken = null;
|
||||
state.error = action.payload;
|
||||
},
|
||||
logout: (state) => {
|
||||
state.isAuthenticated = false;
|
||||
state.user = null;
|
||||
state.token = null;
|
||||
state.refreshToken = null;
|
||||
state.error = null;
|
||||
},
|
||||
updateUser: (state, action: PayloadAction<Partial<User>>) => {
|
||||
if (state.user) {
|
||||
state.user = {...state.user, ...action.payload};
|
||||
}
|
||||
},
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
loginStart,
|
||||
loginSuccess,
|
||||
loginFailure,
|
||||
logout,
|
||||
updateUser,
|
||||
clearError,
|
||||
} = authSlice.actions;
|
||||
|
||||
export default authSlice.reducer;
|
||||
```
|
||||
|
||||
## Typed Hooks
|
||||
|
||||
```typescript
|
||||
// src/redux/hooks.ts
|
||||
import {useDispatch, useSelector, TypedUseSelectorHook} from 'react-redux';
|
||||
import type {RootState, AppDispatch} from './store';
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
```
|
||||
|
||||
## Async Thunks
|
||||
|
||||
```typescript
|
||||
// src/redux/thunks/authThunks.ts
|
||||
import {createAsyncThunk} from '@reduxjs/toolkit';
|
||||
import {authAPI} from '@services/authAPI';
|
||||
|
||||
interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const loginUser = createAsyncThunk(
|
||||
'auth/loginUser',
|
||||
async (credentials: LoginCredentials, {rejectWithValue}) => {
|
||||
try {
|
||||
const response = await authAPI.login(credentials);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.response?.data?.message || 'Login failed');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const refreshToken = createAsyncThunk(
|
||||
'auth/refreshToken',
|
||||
async (_, {getState, rejectWithValue}) => {
|
||||
try {
|
||||
const state = getState() as any;
|
||||
const refreshToken = state.auth.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await authAPI.refreshToken(refreshToken);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.response?.data?.message || 'Token refresh failed');
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Provider Setup
|
||||
|
||||
```typescript
|
||||
// App.tsx
|
||||
import React from 'react';
|
||||
import {Provider} from 'react-redux';
|
||||
import {PersistGate} from 'redux-persist/integration/react';
|
||||
import {store, persistor} from './src/redux/store';
|
||||
import {AppNavigator} from './src/navigation/AppNavigator';
|
||||
import {LoadingScreen} from './src/components/common/LoadingScreen';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={<LoadingScreen />} persistor={persistor}>
|
||||
<AppNavigator />
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
## Middleware Configuration
|
||||
|
||||
```typescript
|
||||
// src/redux/middleware/authMiddleware.ts
|
||||
import {Middleware} from '@reduxjs/toolkit';
|
||||
import {logout} from '../slices/authSlice';
|
||||
|
||||
export const authMiddleware: Middleware = (store) => (next) => (action) => {
|
||||
// Handle token expiration
|
||||
if (action.type.endsWith('/rejected') && action.payload?.status === 401) {
|
||||
store.dispatch(logout());
|
||||
}
|
||||
|
||||
return next(action);
|
||||
};
|
||||
```
|
||||
|
||||
## Selectors
|
||||
|
||||
```typescript
|
||||
// src/redux/selectors/authSelectors.ts
|
||||
import {createSelector} from '@reduxjs/toolkit';
|
||||
import {RootState} from '../store';
|
||||
|
||||
export const selectAuth = (state: RootState) => state.auth;
|
||||
|
||||
export const selectIsAuthenticated = createSelector(
|
||||
[selectAuth],
|
||||
(auth) => auth.isAuthenticated
|
||||
);
|
||||
|
||||
export const selectUser = createSelector(
|
||||
[selectAuth],
|
||||
(auth) => auth.user
|
||||
);
|
||||
|
||||
export const selectAuthLoading = createSelector(
|
||||
[selectAuth],
|
||||
(auth) => auth.loading
|
||||
);
|
||||
|
||||
export const selectAuthError = createSelector(
|
||||
[selectAuth],
|
||||
(auth) => auth.error
|
||||
);
|
||||
|
||||
export const selectUserFullName = createSelector(
|
||||
[selectUser],
|
||||
(user) => user ? `${user.firstName} ${user.lastName}` : ''
|
||||
);
|
||||
```
|
|
@ -0,0 +1,335 @@
|
|||
# E2E Testing with Detox
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
yarn add -D detox
|
||||
npx detox init
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
**detox.config.js:**
|
||||
```javascript
|
||||
module.exports = {
|
||||
testRunner: 'jest',
|
||||
runnerConfig: 'e2e/config.json',
|
||||
configurations: {
|
||||
'ios.sim.debug': {
|
||||
device: {
|
||||
type: 'ios.simulator',
|
||||
device: {
|
||||
type: 'iPhone 12',
|
||||
},
|
||||
},
|
||||
app: {
|
||||
type: 'ios.app',
|
||||
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/SaayamApp.app',
|
||||
build: 'xcodebuild -workspace ios/SaayamApp.xcworkspace -scheme SaayamApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
|
||||
},
|
||||
},
|
||||
'android.emu.debug': {
|
||||
device: {
|
||||
type: 'android.emulator',
|
||||
device: {
|
||||
avdName: 'Pixel_4_API_30',
|
||||
},
|
||||
},
|
||||
app: {
|
||||
type: 'android.apk',
|
||||
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
|
||||
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
|
||||
reversePorts: [8081],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**e2e/config.json:**
|
||||
```json
|
||||
{
|
||||
"maxWorkers": 1,
|
||||
"testTimeout": 120000,
|
||||
"retries": 2,
|
||||
"bail": false,
|
||||
"verbose": true,
|
||||
"setupFilesAfterEnv": ["./init.js"]
|
||||
}
|
||||
```
|
||||
|
||||
## Test Setup
|
||||
|
||||
```javascript
|
||||
// e2e/init.js
|
||||
const detox = require('detox');
|
||||
const config = require('../detox.config.js');
|
||||
const adapter = require('detox/runners/jest/adapter');
|
||||
|
||||
jest.setTimeout(300000);
|
||||
jasmine.getEnv().addReporter(adapter);
|
||||
|
||||
beforeAll(async () => {
|
||||
await detox.init(config);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await adapter.beforeEach();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await adapter.afterAll();
|
||||
await detox.cleanup();
|
||||
});
|
||||
```
|
||||
|
||||
## Login Flow Test
|
||||
|
||||
```javascript
|
||||
// e2e/loginFlow.e2e.js
|
||||
describe('Login Flow', () => {
|
||||
beforeEach(async () => {
|
||||
await device.reloadReactNative();
|
||||
});
|
||||
|
||||
it('should show login screen on app launch', async () => {
|
||||
await expect(element(by.id('loginScreen'))).toBeVisible();
|
||||
await expect(element(by.id('emailInput'))).toBeVisible();
|
||||
await expect(element(by.id('passwordInput'))).toBeVisible();
|
||||
await expect(element(by.id('loginButton'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show validation errors for empty fields', async () => {
|
||||
await element(by.id('loginButton')).tap();
|
||||
|
||||
await expect(element(by.text('Email is required'))).toBeVisible();
|
||||
await expect(element(by.text('Password is required'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should login successfully with valid credentials', async () => {
|
||||
await element(by.id('emailInput')).typeText('test@example.com');
|
||||
await element(by.id('passwordInput')).typeText('password123');
|
||||
await element(by.id('loginButton')).tap();
|
||||
|
||||
// Wait for navigation to home screen
|
||||
await waitFor(element(by.id('homeScreen')))
|
||||
.toBeVisible()
|
||||
.withTimeout(10000);
|
||||
});
|
||||
|
||||
it('should show error for invalid credentials', async () => {
|
||||
await element(by.id('emailInput')).typeText('invalid@example.com');
|
||||
await element(by.id('passwordInput')).typeText('wrongpassword');
|
||||
await element(by.id('loginButton')).tap();
|
||||
|
||||
await expect(element(by.text('Invalid credentials'))).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Navigation Test
|
||||
|
||||
```javascript
|
||||
// e2e/navigation.e2e.js
|
||||
describe('Navigation', () => {
|
||||
beforeEach(async () => {
|
||||
await device.reloadReactNative();
|
||||
// Login first
|
||||
await element(by.id('emailInput')).typeText('test@example.com');
|
||||
await element(by.id('passwordInput')).typeText('password123');
|
||||
await element(by.id('loginButton')).tap();
|
||||
await waitFor(element(by.id('homeScreen'))).toBeVisible().withTimeout(10000);
|
||||
});
|
||||
|
||||
it('should navigate between tabs', async () => {
|
||||
// Test Home tab
|
||||
await expect(element(by.id('homeScreen'))).toBeVisible();
|
||||
|
||||
// Navigate to Profile tab
|
||||
await element(by.id('profileTab')).tap();
|
||||
await expect(element(by.id('profileScreen'))).toBeVisible();
|
||||
|
||||
// Navigate to Settings tab
|
||||
await element(by.id('settingsTab')).tap();
|
||||
await expect(element(by.id('settingsScreen'))).toBeVisible();
|
||||
|
||||
// Navigate back to Home tab
|
||||
await element(by.id('homeTab')).tap();
|
||||
await expect(element(by.id('homeScreen'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should navigate to detail screen and back', async () => {
|
||||
await element(by.id('firstRequestItem')).tap();
|
||||
await expect(element(by.id('requestDetailScreen'))).toBeVisible();
|
||||
|
||||
await element(by.id('backButton')).tap();
|
||||
await expect(element(by.id('homeScreen'))).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Form Testing
|
||||
|
||||
```javascript
|
||||
// e2e/createRequest.e2e.js
|
||||
describe('Create Request Flow', () => {
|
||||
beforeEach(async () => {
|
||||
await device.reloadReactNative();
|
||||
// Login and navigate to create request
|
||||
await element(by.id('emailInput')).typeText('test@example.com');
|
||||
await element(by.id('passwordInput')).typeText('password123');
|
||||
await element(by.id('loginButton')).tap();
|
||||
await waitFor(element(by.id('homeScreen'))).toBeVisible().withTimeout(10000);
|
||||
await element(by.id('createRequestButton')).tap();
|
||||
});
|
||||
|
||||
it('should create a new request successfully', async () => {
|
||||
await element(by.id('titleInput')).typeText('Help for Medical Treatment');
|
||||
await element(by.id('descriptionInput')).typeText('Need urgent help for medical treatment');
|
||||
await element(by.id('targetAmountInput')).typeText('50000');
|
||||
|
||||
// Select category
|
||||
await element(by.id('categoryDropdown')).tap();
|
||||
await element(by.text('Health')).tap();
|
||||
|
||||
// Add image
|
||||
await element(by.id('addImageButton')).tap();
|
||||
await element(by.text('Camera')).tap();
|
||||
|
||||
// Submit form
|
||||
await element(by.id('submitButton')).tap();
|
||||
|
||||
await waitFor(element(by.text('Request created successfully')))
|
||||
.toBeVisible()
|
||||
.withTimeout(10000);
|
||||
});
|
||||
|
||||
it('should show validation errors for incomplete form', async () => {
|
||||
await element(by.id('submitButton')).tap();
|
||||
|
||||
await expect(element(by.text('Title is required'))).toBeVisible();
|
||||
await expect(element(by.text('Description is required'))).toBeVisible();
|
||||
await expect(element(by.text('Target amount is required'))).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Scroll and List Testing
|
||||
|
||||
```javascript
|
||||
// e2e/requestList.e2e.js
|
||||
describe('Request List', () => {
|
||||
beforeEach(async () => {
|
||||
await device.reloadReactNative();
|
||||
// Login first
|
||||
await element(by.id('emailInput')).typeText('test@example.com');
|
||||
await element(by.id('passwordInput')).typeText('password123');
|
||||
await element(by.id('loginButton')).tap();
|
||||
await waitFor(element(by.id('homeScreen'))).toBeVisible().withTimeout(10000);
|
||||
});
|
||||
|
||||
it('should load and display request list', async () => {
|
||||
await expect(element(by.id('requestList'))).toBeVisible();
|
||||
await expect(element(by.id('requestItem-0'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should scroll through request list', async () => {
|
||||
await element(by.id('requestList')).scroll(300, 'down');
|
||||
await expect(element(by.id('requestItem-5'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should pull to refresh', async () => {
|
||||
await element(by.id('requestList')).swipe('down', 'slow', 0.8);
|
||||
await waitFor(element(by.id('refreshIndicator')))
|
||||
.toBeVisible()
|
||||
.withTimeout(2000);
|
||||
});
|
||||
|
||||
it('should filter requests by category', async () => {
|
||||
await element(by.id('filterButton')).tap();
|
||||
await element(by.text('Health')).tap();
|
||||
await element(by.id('applyFilterButton')).tap();
|
||||
|
||||
await waitFor(element(by.id('requestList')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Device Interactions
|
||||
|
||||
```javascript
|
||||
// e2e/deviceInteractions.e2e.js
|
||||
describe('Device Interactions', () => {
|
||||
beforeEach(async () => {
|
||||
await device.reloadReactNative();
|
||||
});
|
||||
|
||||
it('should handle device rotation', async () => {
|
||||
await device.setOrientation('landscape');
|
||||
await expect(element(by.id('loginScreen'))).toBeVisible();
|
||||
|
||||
await device.setOrientation('portrait');
|
||||
await expect(element(by.id('loginScreen'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should handle app backgrounding and foregrounding', async () => {
|
||||
await device.sendToHome();
|
||||
await device.launchApp({newInstance: false});
|
||||
await expect(element(by.id('loginScreen'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should handle deep links', async () => {
|
||||
await device.openURL({url: 'saayam://request/123'});
|
||||
await waitFor(element(by.id('requestDetailScreen')))
|
||||
.toBeVisible()
|
||||
.withTimeout(10000);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Utilities
|
||||
|
||||
```javascript
|
||||
// e2e/utils/helpers.js
|
||||
export const loginUser = async (email = 'test@example.com', password = 'password123') => {
|
||||
await element(by.id('emailInput')).typeText(email);
|
||||
await element(by.id('passwordInput')).typeText(password);
|
||||
await element(by.id('loginButton')).tap();
|
||||
await waitFor(element(by.id('homeScreen'))).toBeVisible().withTimeout(10000);
|
||||
};
|
||||
|
||||
export const navigateToScreen = async (screenId) => {
|
||||
await element(by.id(screenId)).tap();
|
||||
await waitFor(element(by.id(`${screenId}Screen`))).toBeVisible().withTimeout(5000);
|
||||
};
|
||||
|
||||
export const fillForm = async (formData) => {
|
||||
for (const [fieldId, value] of Object.entries(formData)) {
|
||||
await element(by.id(fieldId)).typeText(value);
|
||||
}
|
||||
};
|
||||
|
||||
export const waitForElementToDisappear = async (elementId, timeout = 5000) => {
|
||||
await waitFor(element(by.id(elementId))).not.toBeVisible().withTimeout(timeout);
|
||||
};
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Build and run iOS tests
|
||||
detox build --configuration ios.sim.debug
|
||||
detox test --configuration ios.sim.debug
|
||||
|
||||
# Build and run Android tests
|
||||
detox build --configuration android.emu.debug
|
||||
detox test --configuration android.emu.debug
|
||||
|
||||
# Run specific test file
|
||||
detox test --configuration ios.sim.debug e2e/loginFlow.e2e.js
|
||||
|
||||
# Run tests with verbose output
|
||||
detox test --configuration ios.sim.debug --loglevel verbose
|
||||
```
|
|
@ -0,0 +1,437 @@
|
|||
# Unit Testing Setup
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
yarn add -D jest @testing-library/react-native @testing-library/jest-native
|
||||
```
|
||||
|
||||
## Jest Configuration
|
||||
|
||||
**jest.config.js:**
|
||||
```javascript
|
||||
module.exports = {
|
||||
preset: 'react-native',
|
||||
setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect'],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(react-native|@react-native|react-navigation)/)',
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/*.stories.{ts,tsx}',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Test Setup
|
||||
|
||||
```typescript
|
||||
// src/__tests__/setup.ts
|
||||
import 'react-native-gesture-handler/jestSetup';
|
||||
|
||||
jest.mock('react-native-reanimated', () => {
|
||||
const Reanimated = require('react-native-reanimated/mock');
|
||||
Reanimated.default.call = () => {};
|
||||
return Reanimated;
|
||||
});
|
||||
|
||||
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
|
||||
|
||||
// Mock AsyncStorage
|
||||
jest.mock('@react-native-async-storage/async-storage', () =>
|
||||
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
|
||||
);
|
||||
|
||||
// Mock react-native-config
|
||||
jest.mock('react-native-config', () => ({
|
||||
API_BASE_URL: 'https://test-api.example.com',
|
||||
APP_ENV: 'test',
|
||||
}));
|
||||
```
|
||||
|
||||
## Component Testing
|
||||
|
||||
```typescript
|
||||
// src/components/Button/__tests__/Button.test.tsx
|
||||
import React from 'react';
|
||||
import {render, fireEvent} from '@testing-library/react-native';
|
||||
import {Button} from '../Button';
|
||||
|
||||
describe('Button Component', () => {
|
||||
it('renders correctly', () => {
|
||||
const {getByText} = render(
|
||||
<Button title="Test Button" onPress={() => {}} />
|
||||
);
|
||||
expect(getByText('Test Button')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onPress when pressed', () => {
|
||||
const mockOnPress = jest.fn();
|
||||
const {getByText} = render(
|
||||
<Button title="Test Button" onPress={mockOnPress} />
|
||||
);
|
||||
|
||||
fireEvent.press(getByText('Test Button'));
|
||||
expect(mockOnPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('applies correct styles for different variants', () => {
|
||||
const {getByText, rerender} = render(
|
||||
<Button title="Primary" onPress={() => {}} variant="primary" />
|
||||
);
|
||||
|
||||
const button = getByText('Primary').parent;
|
||||
expect(button).toHaveStyle({backgroundColor: '#007AFF'});
|
||||
|
||||
rerender(
|
||||
<Button title="Secondary" onPress={() => {}} variant="secondary" />
|
||||
);
|
||||
|
||||
const secondaryButton = getByText('Secondary').parent;
|
||||
expect(secondaryButton).toHaveStyle({backgroundColor: '#6C757D'});
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
const mockOnPress = jest.fn();
|
||||
const {getByText} = render(
|
||||
<Button title="Disabled" onPress={mockOnPress} disabled />
|
||||
);
|
||||
|
||||
const button = getByText('Disabled').parent;
|
||||
expect(button).toHaveStyle({opacity: 0.5});
|
||||
|
||||
fireEvent.press(getByText('Disabled'));
|
||||
expect(mockOnPress).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Screen Testing
|
||||
|
||||
```typescript
|
||||
// src/screens/Login/__tests__/LoginScreen.test.tsx
|
||||
import React from 'react';
|
||||
import {render, fireEvent, waitFor} from '@testing-library/react-native';
|
||||
import {Provider} from 'react-redux';
|
||||
import {configureStore} from '@reduxjs/toolkit';
|
||||
import LoginScreen from '../LoginScreen';
|
||||
import authSlice from '@redux/slices/authSlice';
|
||||
|
||||
const createMockStore = (initialState = {}) => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
auth: authSlice,
|
||||
},
|
||||
preloadedState: initialState,
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProvider = (component: React.ReactElement, store = createMockStore()) => {
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
{component}
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('LoginScreen', () => {
|
||||
it('renders login form correctly', () => {
|
||||
const {getByPlaceholderText, getByText} = renderWithProvider(
|
||||
<LoginScreen navigation={{} as any} route={{} as any} />
|
||||
);
|
||||
|
||||
expect(getByPlaceholderText('Email')).toBeTruthy();
|
||||
expect(getByPlaceholderText('Password')).toBeTruthy();
|
||||
expect(getByText('Login')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows validation errors for empty fields', async () => {
|
||||
const {getByText, getByTestId} = renderWithProvider(
|
||||
<LoginScreen navigation={{} as any} route={{} as any} />
|
||||
);
|
||||
|
||||
fireEvent.press(getByText('Login'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Email is required')).toBeTruthy();
|
||||
expect(getByText('Password is required')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls login API with correct credentials', async () => {
|
||||
const mockLogin = jest.fn();
|
||||
const {getByPlaceholderText, getByText} = renderWithProvider(
|
||||
<LoginScreen navigation={{} as any} route={{} as any} />
|
||||
);
|
||||
|
||||
fireEvent.changeText(getByPlaceholderText('Email'), 'test@example.com');
|
||||
fireEvent.changeText(getByPlaceholderText('Password'), 'password123');
|
||||
fireEvent.press(getByText('Login'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Redux Testing
|
||||
|
||||
```typescript
|
||||
// src/redux/slices/__tests__/authSlice.test.ts
|
||||
import authSlice, {
|
||||
loginStart,
|
||||
loginSuccess,
|
||||
loginFailure,
|
||||
logout,
|
||||
updateUser,
|
||||
} from '../authSlice';
|
||||
|
||||
describe('authSlice', () => {
|
||||
const initialState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
it('should return the initial state', () => {
|
||||
expect(authSlice(undefined, {type: 'unknown'})).toEqual(initialState);
|
||||
});
|
||||
|
||||
it('should handle loginStart', () => {
|
||||
const actual = authSlice(initialState, loginStart());
|
||||
expect(actual.loading).toBe(true);
|
||||
expect(actual.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle loginSuccess', () => {
|
||||
const payload = {
|
||||
user: {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
},
|
||||
token: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
};
|
||||
|
||||
const actual = authSlice(initialState, loginSuccess(payload));
|
||||
expect(actual.loading).toBe(false);
|
||||
expect(actual.isAuthenticated).toBe(true);
|
||||
expect(actual.user).toEqual(payload.user);
|
||||
expect(actual.token).toBe(payload.token);
|
||||
expect(actual.refreshToken).toBe(payload.refreshToken);
|
||||
});
|
||||
|
||||
it('should handle loginFailure', () => {
|
||||
const errorMessage = 'Invalid credentials';
|
||||
const actual = authSlice(initialState, loginFailure(errorMessage));
|
||||
expect(actual.loading).toBe(false);
|
||||
expect(actual.isAuthenticated).toBe(false);
|
||||
expect(actual.error).toBe(errorMessage);
|
||||
});
|
||||
|
||||
it('should handle logout', () => {
|
||||
const loggedInState = {
|
||||
...initialState,
|
||||
isAuthenticated: true,
|
||||
user: {id: '1', email: 'test@example.com', firstName: 'John', lastName: 'Doe'},
|
||||
token: 'token',
|
||||
};
|
||||
|
||||
const actual = authSlice(loggedInState, logout());
|
||||
expect(actual).toEqual(initialState);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Testing
|
||||
|
||||
```typescript
|
||||
// src/services/__tests__/authAPI.test.ts
|
||||
import {authAPI} from '../authAPI';
|
||||
import {store} from '@redux/store';
|
||||
|
||||
describe('authAPI', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state
|
||||
store.dispatch({type: 'RESET'});
|
||||
});
|
||||
|
||||
it('should login successfully', async () => {
|
||||
const credentials = {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
user: {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
},
|
||||
token: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
};
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
})
|
||||
) as jest.Mock;
|
||||
|
||||
const result = await store.dispatch(
|
||||
authAPI.endpoints.login.initiate(credentials)
|
||||
);
|
||||
|
||||
expect(result.data).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle login error', async () => {
|
||||
const credentials = {
|
||||
email: 'test@example.com',
|
||||
password: 'wrongpassword',
|
||||
};
|
||||
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({message: 'Invalid credentials'}),
|
||||
})
|
||||
) as jest.Mock;
|
||||
|
||||
const result = await store.dispatch(
|
||||
authAPI.endpoints.login.initiate(credentials)
|
||||
);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Custom Hooks Testing
|
||||
|
||||
```typescript
|
||||
// src/hooks/__tests__/useAPI.test.ts
|
||||
import {renderHook, act} from '@testing-library/react-hooks';
|
||||
import {useAPI} from '../useAPI';
|
||||
|
||||
describe('useAPI', () => {
|
||||
it('should initialize with correct default state', () => {
|
||||
const {result} = renderHook(() => useAPI());
|
||||
|
||||
expect(result.current.data).toBe(null);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle successful API call', async () => {
|
||||
const {result} = renderHook(() => useAPI());
|
||||
const mockData = {id: 1, name: 'Test'};
|
||||
const mockApiCall = jest.fn(() => Promise.resolve(mockData));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute(mockApiCall);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle API call error', async () => {
|
||||
const {result} = renderHook(() => useAPI());
|
||||
const mockError = new Error('API Error');
|
||||
const mockApiCall = jest.fn(() => Promise.reject(mockError));
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.execute(mockApiCall);
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.current.data).toBe(null);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Utilities
|
||||
|
||||
```typescript
|
||||
// src/__tests__/utils/testUtils.tsx
|
||||
import React from 'react';
|
||||
import {render, RenderOptions} from '@testing-library/react-native';
|
||||
import {Provider} from 'react-redux';
|
||||
import {NavigationContainer} from '@react-navigation/native';
|
||||
import {configureStore} from '@reduxjs/toolkit';
|
||||
import authSlice from '@redux/slices/authSlice';
|
||||
|
||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
preloadedState?: any;
|
||||
store?: any;
|
||||
}
|
||||
|
||||
const createTestStore = (preloadedState = {}) => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
auth: authSlice,
|
||||
},
|
||||
preloadedState,
|
||||
});
|
||||
};
|
||||
|
||||
const AllTheProviders: React.FC<{children: React.ReactNode; store: any}> = ({
|
||||
children,
|
||||
store,
|
||||
}) => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<NavigationContainer>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const customRender = (
|
||||
ui: React.ReactElement,
|
||||
{
|
||||
preloadedState = {},
|
||||
store = createTestStore(preloadedState),
|
||||
...renderOptions
|
||||
}: CustomRenderOptions = {}
|
||||
) => {
|
||||
const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) => (
|
||||
<AllTheProviders store={store}>{children}</AllTheProviders>
|
||||
);
|
||||
|
||||
return render(ui, {wrapper: Wrapper, ...renderOptions});
|
||||
};
|
||||
|
||||
export * from '@testing-library/react-native';
|
||||
export {customRender as render};
|
||||
```
|
|
@ -0,0 +1,477 @@
|
|||
# Performance Optimization
|
||||
|
||||
## Hermes Setup
|
||||
|
||||
### Enable Hermes
|
||||
|
||||
**Android (android/app/build.gradle):**
|
||||
```gradle
|
||||
project.ext.react = [
|
||||
enableHermes: true
|
||||
]
|
||||
```
|
||||
|
||||
**iOS (Podfile):**
|
||||
```ruby
|
||||
use_react_native!(
|
||||
:path => config[:reactNativePath],
|
||||
:hermes_enabled => true
|
||||
)
|
||||
```
|
||||
|
||||
### Hermes Benefits
|
||||
- Faster app startup time
|
||||
- Reduced memory usage
|
||||
- Smaller bundle size
|
||||
- Better performance on lower-end devices
|
||||
|
||||
## List Optimization
|
||||
|
||||
### Optimized FlatList
|
||||
|
||||
```typescript
|
||||
// src/components/OptimizedList/OptimizedList.tsx
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {FlatList, ListRenderItem, ViewToken} from 'react-native';
|
||||
|
||||
interface OptimizedListProps<T> {
|
||||
data: T[];
|
||||
renderItem: ListRenderItem<T>;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
itemHeight?: number;
|
||||
onEndReached?: () => void;
|
||||
onViewableItemsChanged?: (info: {viewableItems: ViewToken[]}) => void;
|
||||
}
|
||||
|
||||
export const OptimizedList = <T,>({
|
||||
data,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
itemHeight = 100,
|
||||
onEndReached,
|
||||
onViewableItemsChanged,
|
||||
}: OptimizedListProps<T>) => {
|
||||
const memoizedRenderItem = useCallback(renderItem, []);
|
||||
const memoizedKeyExtractor = useCallback(keyExtractor, []);
|
||||
|
||||
const getItemLayout = useMemo(
|
||||
() =>
|
||||
itemHeight
|
||||
? (data: any, index: number) => ({
|
||||
length: itemHeight,
|
||||
offset: itemHeight * index,
|
||||
index,
|
||||
})
|
||||
: undefined,
|
||||
[itemHeight]
|
||||
);
|
||||
|
||||
const viewabilityConfig = useMemo(
|
||||
() => ({
|
||||
itemVisiblePercentThreshold: 50,
|
||||
minimumViewTime: 300,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={data}
|
||||
renderItem={memoizedRenderItem}
|
||||
keyExtractor={memoizedKeyExtractor}
|
||||
getItemLayout={getItemLayout}
|
||||
removeClippedSubviews={true}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={10}
|
||||
initialNumToRender={10}
|
||||
updateCellsBatchingPeriod={50}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
viewabilityConfig={viewabilityConfig}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Virtualized List for Large Datasets
|
||||
|
||||
```typescript
|
||||
// src/components/VirtualizedList/VirtualizedList.tsx
|
||||
import React from 'react';
|
||||
import {VirtualizedList, ListRenderItem} from 'react-native';
|
||||
|
||||
interface VirtualizedListProps<T> {
|
||||
data: T[];
|
||||
renderItem: ListRenderItem<T>;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
itemHeight: number;
|
||||
}
|
||||
|
||||
export const CustomVirtualizedList = <T,>({
|
||||
data,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
itemHeight,
|
||||
}: VirtualizedListProps<T>) => {
|
||||
const getItem = (data: T[], index: number) => data[index];
|
||||
const getItemCount = (data: T[]) => data.length;
|
||||
|
||||
return (
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
initialNumToRender={4}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
getItemCount={getItemCount}
|
||||
getItem={getItem}
|
||||
getItemLayout={(data, index) => ({
|
||||
length: itemHeight,
|
||||
offset: itemHeight * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Image Optimization
|
||||
|
||||
### Lazy Loading Images
|
||||
|
||||
```typescript
|
||||
// src/components/LazyImage/LazyImage.tsx
|
||||
import React, {useState, useRef} from 'react';
|
||||
import {View, Image, Animated, StyleSheet} from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
|
||||
interface LazyImageProps {
|
||||
source: {uri: string};
|
||||
style?: any;
|
||||
placeholder?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const LazyImage: React.FC<LazyImageProps> = ({
|
||||
source,
|
||||
style,
|
||||
placeholder,
|
||||
}) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const onLoad = () => {
|
||||
setLoaded(true);
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
{!loaded && placeholder}
|
||||
<Animated.View style={[StyleSheet.absoluteFill, {opacity}]}>
|
||||
<FastImage
|
||||
source={source}
|
||||
style={StyleSheet.absoluteFill}
|
||||
onLoad={onLoad}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Memory Management
|
||||
|
||||
### Component Memoization
|
||||
|
||||
```typescript
|
||||
// src/components/MemoizedComponent/MemoizedComponent.tsx
|
||||
import React, {memo, useMemo, useCallback} from 'react';
|
||||
import {View, Text, TouchableOpacity} from 'react-native';
|
||||
|
||||
interface ItemProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
onPress: (id: string) => void;
|
||||
}
|
||||
|
||||
const ItemComponent: React.FC<ItemProps> = memo(({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
onPress,
|
||||
}) => {
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(id);
|
||||
}, [id, onPress]);
|
||||
|
||||
const formattedDescription = useMemo(() => {
|
||||
return description.length > 100
|
||||
? `${description.substring(0, 100)}...`
|
||||
: description;
|
||||
}, [description]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress}>
|
||||
<View>
|
||||
<Text>{title}</Text>
|
||||
<Text>{formattedDescription}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
export default ItemComponent;
|
||||
```
|
||||
|
||||
### Custom Hooks Optimization
|
||||
|
||||
```typescript
|
||||
// src/hooks/useOptimizedData.ts
|
||||
import {useMemo, useCallback} from 'react';
|
||||
import {useAppSelector} from '@redux/hooks';
|
||||
|
||||
export const useOptimizedData = (filter?: string) => {
|
||||
const rawData = useAppSelector(state => state.data.items);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!filter) return rawData;
|
||||
return rawData.filter(item =>
|
||||
item.title.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
}, [rawData, filter]);
|
||||
|
||||
const processItem = useCallback((item: any) => {
|
||||
return {
|
||||
...item,
|
||||
displayTitle: item.title.toUpperCase(),
|
||||
isNew: Date.now() - item.createdAt < 86400000, // 24 hours
|
||||
};
|
||||
}, []);
|
||||
|
||||
const processedData = useMemo(() =>
|
||||
filteredData.map(processItem),
|
||||
[filteredData, processItem]
|
||||
);
|
||||
|
||||
return processedData;
|
||||
};
|
||||
```
|
||||
|
||||
## Bundle Optimization
|
||||
|
||||
### Code Splitting
|
||||
|
||||
```typescript
|
||||
// src/utils/lazyImports.ts
|
||||
import {lazy} from 'react';
|
||||
|
||||
// Lazy load heavy screens
|
||||
export const ProfileScreen = lazy(() => import('@screens/Profile/ProfileScreen'));
|
||||
export const SettingsScreen = lazy(() => import('@screens/Settings/SettingsScreen'));
|
||||
export const ReportsScreen = lazy(() => import('@screens/Reports/ReportsScreen'));
|
||||
|
||||
// Lazy load heavy components
|
||||
export const ChartComponent = lazy(() => import('@components/Chart/ChartComponent'));
|
||||
export const MapComponent = lazy(() => import('@components/Map/MapComponent'));
|
||||
```
|
||||
|
||||
### Dynamic Imports
|
||||
|
||||
```typescript
|
||||
// src/utils/dynamicImports.ts
|
||||
export const loadChartLibrary = async () => {
|
||||
const {Chart} = await import('react-native-chart-kit');
|
||||
return Chart;
|
||||
};
|
||||
|
||||
export const loadMapLibrary = async () => {
|
||||
const MapView = await import('react-native-maps');
|
||||
return MapView.default;
|
||||
};
|
||||
|
||||
// Usage in component
|
||||
const MyComponent = () => {
|
||||
const [ChartComponent, setChartComponent] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadChartLibrary().then(setChartComponent);
|
||||
}, []);
|
||||
|
||||
return ChartComponent ? <ChartComponent /> : <LoadingSpinner />;
|
||||
};
|
||||
```
|
||||
|
||||
## Animation Optimization
|
||||
|
||||
### Native Driver Usage
|
||||
|
||||
```typescript
|
||||
// src/animations/optimizedAnimations.ts
|
||||
import {Animated, Easing} from 'react-native';
|
||||
|
||||
export const createOptimizedAnimation = (
|
||||
animatedValue: Animated.Value,
|
||||
toValue: number,
|
||||
duration: number = 300
|
||||
) => {
|
||||
return Animated.timing(animatedValue, {
|
||||
toValue,
|
||||
duration,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true, // Use native driver for better performance
|
||||
});
|
||||
};
|
||||
|
||||
export const createSpringAnimation = (
|
||||
animatedValue: Animated.Value,
|
||||
toValue: number
|
||||
) => {
|
||||
return Animated.spring(animatedValue, {
|
||||
toValue,
|
||||
tension: 100,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Network Optimization
|
||||
|
||||
### Request Batching
|
||||
|
||||
```typescript
|
||||
// src/utils/requestBatcher.ts
|
||||
class RequestBatcher {
|
||||
private queue: Array<{
|
||||
url: string;
|
||||
options: RequestInit;
|
||||
resolve: (value: any) => void;
|
||||
reject: (reason: any) => void;
|
||||
}> = [];
|
||||
private batchTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
public request(url: string, options: RequestInit = {}): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.queue.push({url, options, resolve, reject});
|
||||
|
||||
if (this.batchTimeout) {
|
||||
clearTimeout(this.batchTimeout);
|
||||
}
|
||||
|
||||
this.batchTimeout = setTimeout(() => {
|
||||
this.processBatch();
|
||||
}, 50); // Batch requests within 50ms
|
||||
});
|
||||
}
|
||||
|
||||
private async processBatch() {
|
||||
const batch = [...this.queue];
|
||||
this.queue = [];
|
||||
this.batchTimeout = null;
|
||||
|
||||
// Group similar requests
|
||||
const grouped = batch.reduce((acc, req) => {
|
||||
const key = `${req.options.method || 'GET'}-${req.url.split('?')[0]}`;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(req);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof batch>);
|
||||
|
||||
// Process each group
|
||||
for (const requests of Object.values(grouped)) {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
requests.map(req => fetch(req.url, req.options))
|
||||
);
|
||||
|
||||
results.forEach((result, index) => {
|
||||
requests[index].resolve(result);
|
||||
});
|
||||
} catch (error) {
|
||||
requests.forEach(req => req.reject(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const requestBatcher = new RequestBatcher();
|
||||
```
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
```typescript
|
||||
// src/utils/performanceMonitor.ts
|
||||
class PerformanceMonitor {
|
||||
private metrics: Map<string, number> = new Map();
|
||||
|
||||
startTiming(label: string) {
|
||||
this.metrics.set(label, Date.now());
|
||||
}
|
||||
|
||||
endTiming(label: string): number {
|
||||
const startTime = this.metrics.get(label);
|
||||
if (!startTime) {
|
||||
console.warn(`No start time found for ${label}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.metrics.delete(label);
|
||||
|
||||
console.log(`${label}: ${duration}ms`);
|
||||
return duration;
|
||||
}
|
||||
|
||||
measureAsync<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
this.startTiming(label);
|
||||
return fn().finally(() => {
|
||||
this.endTiming(label);
|
||||
});
|
||||
}
|
||||
|
||||
measureSync<T>(label: string, fn: () => T): T {
|
||||
this.startTiming(label);
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
this.endTiming(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
|
||||
// Usage
|
||||
performanceMonitor.measureAsync('API_CALL', () =>
|
||||
fetch('/api/data').then(res => res.json())
|
||||
);
|
||||
```
|
||||
|
||||
### Memory Usage Tracking
|
||||
|
||||
```typescript
|
||||
// src/utils/memoryTracker.ts
|
||||
export const trackMemoryUsage = () => {
|
||||
if (__DEV__) {
|
||||
const memoryInfo = (performance as any).memory;
|
||||
if (memoryInfo) {
|
||||
console.log('Memory Usage:', {
|
||||
used: Math.round(memoryInfo.usedJSHeapSize / 1048576) + ' MB',
|
||||
total: Math.round(memoryInfo.totalJSHeapSize / 1048576) + ' MB',
|
||||
limit: Math.round(memoryInfo.jsHeapSizeLimit / 1048576) + ' MB',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track memory usage periodically
|
||||
setInterval(trackMemoryUsage, 30000); // Every 30 seconds in development
|
||||
```
|
|
@ -0,0 +1,471 @@
|
|||
# Security
|
||||
|
||||
## Secure Storage
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
yarn add react-native-keychain
|
||||
```
|
||||
|
||||
### Secure Storage Implementation
|
||||
|
||||
```typescript
|
||||
// src/utils/secureStorage.ts
|
||||
import * as Keychain from 'react-native-keychain';
|
||||
|
||||
export class SecureStorage {
|
||||
static async setItem(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
await Keychain.setInternetCredentials(key, key, value);
|
||||
} catch (error) {
|
||||
console.error('Error storing secure item:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getItem(key: string): Promise<string | null> {
|
||||
try {
|
||||
const credentials = await Keychain.getInternetCredentials(key);
|
||||
return credentials ? credentials.password : null;
|
||||
} catch (error) {
|
||||
console.error('Error retrieving secure item:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async removeItem(key: string): Promise<void> {
|
||||
try {
|
||||
await Keychain.resetInternetCredentials(key);
|
||||
} catch (error) {
|
||||
console.error('Error removing secure item:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async clear(): Promise<void> {
|
||||
try {
|
||||
await Keychain.resetGenericPassword();
|
||||
} catch (error) {
|
||||
console.error('Error clearing secure storage:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Biometric authentication
|
||||
static async setItemWithBiometrics(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
await Keychain.setInternetCredentials(key, key, value, {
|
||||
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
|
||||
authenticationType: Keychain.AUTHENTICATION_TYPE.DEVICE_PASSCODE_OR_BIOMETRICS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error storing item with biometrics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getItemWithBiometrics(key: string): Promise<string | null> {
|
||||
try {
|
||||
const credentials = await Keychain.getInternetCredentials(key, {
|
||||
authenticationType: Keychain.AUTHENTICATION_TYPE.DEVICE_PASSCODE_OR_BIOMETRICS,
|
||||
showModal: true,
|
||||
kLocalizedFallbackTitle: 'Please use your passcode',
|
||||
});
|
||||
return credentials ? credentials.password : null;
|
||||
} catch (error) {
|
||||
console.error('Error retrieving item with biometrics:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Data Encryption
|
||||
|
||||
### Encryption Utilities
|
||||
|
||||
```typescript
|
||||
// src/utils/encryption.ts
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
export class EncryptionService {
|
||||
private static readonly SECRET_KEY = 'your-secret-key-here'; // Should be from secure config
|
||||
|
||||
static encrypt(text: string): string {
|
||||
try {
|
||||
return CryptoJS.AES.encrypt(text, this.SECRET_KEY).toString();
|
||||
} catch (error) {
|
||||
console.error('Encryption error:', error);
|
||||
throw new Error('Failed to encrypt data');
|
||||
}
|
||||
}
|
||||
|
||||
static decrypt(encryptedText: string): string {
|
||||
try {
|
||||
const bytes = CryptoJS.AES.decrypt(encryptedText, this.SECRET_KEY);
|
||||
return bytes.toString(CryptoJS.enc.Utf8);
|
||||
} catch (error) {
|
||||
console.error('Decryption error:', error);
|
||||
throw new Error('Failed to decrypt data');
|
||||
}
|
||||
}
|
||||
|
||||
static hash(text: string): string {
|
||||
return CryptoJS.SHA256(text).toString();
|
||||
}
|
||||
|
||||
static generateSalt(): string {
|
||||
return CryptoJS.lib.WordArray.random(128/8).toString();
|
||||
}
|
||||
|
||||
static hashWithSalt(text: string, salt: string): string {
|
||||
return CryptoJS.SHA256(text + salt).toString();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Security
|
||||
|
||||
### Request Interceptors
|
||||
|
||||
```typescript
|
||||
// src/services/secureAPI.ts
|
||||
import axios, {AxiosRequestConfig, AxiosResponse} from 'axios';
|
||||
import {SecureStorage} from '@utils/secureStorage';
|
||||
import {EncryptionService} from '@utils/encryption';
|
||||
|
||||
const secureAPI = axios.create({
|
||||
baseURL: 'https://api.saayam.com',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Request interceptor for authentication
|
||||
secureAPI.interceptors.request.use(
|
||||
async (config: AxiosRequestConfig) => {
|
||||
// Add authentication token
|
||||
const token = await SecureStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Add request signature
|
||||
const timestamp = Date.now().toString();
|
||||
const signature = EncryptionService.hash(
|
||||
`${config.method}${config.url}${timestamp}`
|
||||
);
|
||||
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Signature': signature,
|
||||
};
|
||||
|
||||
// Encrypt sensitive data
|
||||
if (config.data && config.method === 'post') {
|
||||
config.data = {
|
||||
...config.data,
|
||||
encrypted: true,
|
||||
payload: EncryptionService.encrypt(JSON.stringify(config.data)),
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for decryption
|
||||
secureAPI.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// Decrypt response if needed
|
||||
if (response.data?.encrypted) {
|
||||
try {
|
||||
response.data = JSON.parse(
|
||||
EncryptionService.decrypt(response.data.payload)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt response:', error);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
// Handle token refresh
|
||||
if (error.response?.status === 401) {
|
||||
try {
|
||||
const refreshToken = await SecureStorage.getItem('refresh_token');
|
||||
if (refreshToken) {
|
||||
const response = await axios.post('/auth/refresh', {
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
const newToken = response.data.token;
|
||||
await SecureStorage.setItem('auth_token', newToken);
|
||||
|
||||
// Retry original request
|
||||
error.config.headers.Authorization = `Bearer ${newToken}`;
|
||||
return secureAPI.request(error.config);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Redirect to login
|
||||
await SecureStorage.clear();
|
||||
// Navigate to login screen
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default secureAPI;
|
||||
```
|
||||
|
||||
## Input Validation
|
||||
|
||||
### Validation Schemas
|
||||
|
||||
```typescript
|
||||
// src/utils/validation.ts
|
||||
import * as yup from 'yup';
|
||||
|
||||
export const validationSchemas = {
|
||||
email: yup
|
||||
.string()
|
||||
.email('Invalid email format')
|
||||
.required('Email is required')
|
||||
.matches(
|
||||
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
|
||||
'Invalid email format'
|
||||
),
|
||||
|
||||
password: yup
|
||||
.string()
|
||||
.required('Password is required')
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.matches(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||
'Password must contain uppercase, lowercase, number and special character'
|
||||
),
|
||||
|
||||
phone: yup
|
||||
.string()
|
||||
.required('Phone number is required')
|
||||
.matches(
|
||||
/^\+?[1-9]\d{1,14}$/,
|
||||
'Invalid phone number format'
|
||||
),
|
||||
|
||||
amount: yup
|
||||
.number()
|
||||
.required('Amount is required')
|
||||
.positive('Amount must be positive')
|
||||
.max(1000000, 'Amount cannot exceed 1,000,000'),
|
||||
};
|
||||
|
||||
export const sanitizeInput = (input: string): string => {
|
||||
return input
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/[<>]/g, '')
|
||||
.trim();
|
||||
};
|
||||
|
||||
export const validateAndSanitize = (
|
||||
value: string,
|
||||
schema: yup.StringSchema
|
||||
): {isValid: boolean; sanitized: string; error?: string} => {
|
||||
const sanitized = sanitizeInput(value);
|
||||
|
||||
try {
|
||||
schema.validateSync(sanitized);
|
||||
return {isValid: true, sanitized};
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
sanitized,
|
||||
error: error instanceof Error ? error.message : 'Validation failed',
|
||||
};
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Biometric Authentication
|
||||
|
||||
### Biometric Setup
|
||||
|
||||
```typescript
|
||||
// src/utils/biometricAuth.ts
|
||||
import TouchID from 'react-native-touch-id';
|
||||
import {Alert, Platform} from 'react-native';
|
||||
|
||||
export class BiometricAuth {
|
||||
static async isSupported(): Promise<boolean> {
|
||||
try {
|
||||
const biometryType = await TouchID.isSupported();
|
||||
return !!biometryType;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async getSupportedType(): Promise<string | null> {
|
||||
try {
|
||||
const biometryType = await TouchID.isSupported();
|
||||
return biometryType;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async authenticate(reason: string = 'Authenticate to continue'): Promise<boolean> {
|
||||
try {
|
||||
const optionalConfigObject = {
|
||||
title: 'Authentication Required',
|
||||
subtitle: reason,
|
||||
description: 'This app uses biometric authentication to protect your data',
|
||||
fallbackLabel: 'Use Passcode',
|
||||
cancelLabel: 'Cancel',
|
||||
disableDeviceFallback: false,
|
||||
showModal: true,
|
||||
kLocalizedFallbackTitle: 'Use Passcode',
|
||||
};
|
||||
|
||||
await TouchID.authenticate(reason, optionalConfigObject);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('Biometric authentication failed:', error);
|
||||
|
||||
if (error.name === 'LAErrorUserFallback') {
|
||||
// User chose to use passcode
|
||||
return this.authenticateWithPasscode();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async authenticateWithPasscode(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
Alert.prompt(
|
||||
'Enter Passcode',
|
||||
'Please enter your device passcode',
|
||||
[
|
||||
{text: 'Cancel', onPress: () => resolve(false)},
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: (passcode) => {
|
||||
// In a real app, you would validate the passcode
|
||||
resolve(!!passcode);
|
||||
},
|
||||
},
|
||||
],
|
||||
'secure-text'
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Certificate Pinning
|
||||
|
||||
### SSL Pinning Implementation
|
||||
|
||||
```typescript
|
||||
// src/utils/certificatePinning.ts
|
||||
import {NetworkingModule} from 'react-native';
|
||||
|
||||
export class CertificatePinning {
|
||||
private static readonly PINNED_CERTIFICATES = [
|
||||
'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // Your server's certificate hash
|
||||
];
|
||||
|
||||
static setupPinning() {
|
||||
if (Platform.OS === 'ios') {
|
||||
// iOS certificate pinning setup
|
||||
NetworkingModule.addRequestInterceptor((request: any) => {
|
||||
request.trusty = {
|
||||
hosts: [
|
||||
{
|
||||
host: 'api.saayam.com',
|
||||
certificates: this.PINNED_CERTIFICATES,
|
||||
},
|
||||
],
|
||||
};
|
||||
return request;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static validateCertificate(hostname: string, certificate: string): boolean {
|
||||
return this.PINNED_CERTIFICATES.includes(certificate);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Headers
|
||||
|
||||
### Request Security Headers
|
||||
|
||||
```typescript
|
||||
// src/utils/securityHeaders.ts
|
||||
export const getSecurityHeaders = () => ({
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
||||
'Content-Security-Policy': "default-src 'self'",
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
});
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Security Checklist
|
||||
|
||||
```typescript
|
||||
// src/utils/securityAudit.ts
|
||||
export class SecurityAudit {
|
||||
static performSecurityCheck(): {passed: boolean; issues: string[]} {
|
||||
const issues: string[] = [];
|
||||
|
||||
// Check for debug mode in production
|
||||
if (__DEV__ && process.env.NODE_ENV === 'production') {
|
||||
issues.push('Debug mode is enabled in production');
|
||||
}
|
||||
|
||||
// Check for console logs in production
|
||||
if (process.env.NODE_ENV === 'production' && console.log.toString().includes('native code')) {
|
||||
issues.push('Console logs are not disabled in production');
|
||||
}
|
||||
|
||||
// Check for secure storage usage
|
||||
if (!this.isUsingSecureStorage()) {
|
||||
issues.push('Sensitive data is not stored securely');
|
||||
}
|
||||
|
||||
// Check for HTTPS usage
|
||||
if (!this.isUsingHTTPS()) {
|
||||
issues.push('API calls are not using HTTPS');
|
||||
}
|
||||
|
||||
return {
|
||||
passed: issues.length === 0,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
private static isUsingSecureStorage(): boolean {
|
||||
// Check if secure storage is properly implemented
|
||||
return true; // Implement actual check
|
||||
}
|
||||
|
||||
private static isUsingHTTPS(): boolean {
|
||||
// Check if all API endpoints use HTTPS
|
||||
return true; // Implement actual check
|
||||
}
|
||||
}
|
||||
```
|
|
@ -0,0 +1,542 @@
|
|||
# CI/CD Pipeline
|
||||
|
||||
## GitHub Actions Setup
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
**.github/workflows/ci.yml:**
|
||||
```yaml
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Run linter
|
||||
run: yarn lint
|
||||
|
||||
- name: Run tests
|
||||
run: yarn test --coverage
|
||||
|
||||
- name: Type check
|
||||
run: yarn type-check
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
|
||||
build-android:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '11'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
|
||||
- name: Build Android APK
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assembleRelease
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: android-apk
|
||||
path: android/app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
build-ios:
|
||||
needs: test
|
||||
runs-on: macos-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Install CocoaPods
|
||||
run: |
|
||||
cd ios
|
||||
pod install
|
||||
|
||||
- name: Build iOS
|
||||
run: |
|
||||
cd ios
|
||||
xcodebuild -workspace SaayamApp.xcworkspace \
|
||||
-scheme SaayamApp \
|
||||
-configuration Release \
|
||||
-destination generic/platform=iOS \
|
||||
-archivePath $PWD/build/SaayamApp.xcarchive \
|
||||
archive
|
||||
```
|
||||
|
||||
### Advanced Workflow with Fastlane
|
||||
|
||||
**.github/workflows/deploy.yml:**
|
||||
```yaml
|
||||
name: Deploy to Stores
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
deploy-android:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '11'
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Decode Keystore
|
||||
env:
|
||||
ENCODED_STRING: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
echo $ENCODED_STRING | base64 -d > android/app/keystore.jks
|
||||
|
||||
- name: Deploy to Play Store
|
||||
env:
|
||||
SUPPLY_JSON_KEY_DATA: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
run: |
|
||||
cd android
|
||||
bundle exec fastlane deploy
|
||||
|
||||
deploy-ios:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Install CocoaPods
|
||||
run: |
|
||||
cd ios
|
||||
pod install
|
||||
|
||||
- name: Deploy to App Store
|
||||
env:
|
||||
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
||||
FASTLANE_SESSION: ${{ secrets.FASTLANE_SESSION }}
|
||||
run: |
|
||||
cd ios
|
||||
bundle exec fastlane deploy
|
||||
```
|
||||
|
||||
## Fastlane Setup
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install Fastlane
|
||||
sudo gem install fastlane
|
||||
|
||||
# Initialize Fastlane for iOS
|
||||
cd ios && fastlane init
|
||||
|
||||
# Initialize Fastlane for Android
|
||||
cd android && fastlane init
|
||||
```
|
||||
|
||||
### iOS Fastfile
|
||||
|
||||
**ios/fastlane/Fastfile:**
|
||||
```ruby
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Build and upload to TestFlight"
|
||||
lane :beta do
|
||||
setup_ci if ENV['CI']
|
||||
|
||||
match(
|
||||
type: "appstore",
|
||||
readonly: true
|
||||
)
|
||||
|
||||
increment_build_number(
|
||||
xcodeproj: "SaayamApp.xcodeproj"
|
||||
)
|
||||
|
||||
build_app(
|
||||
scheme: "SaayamApp",
|
||||
export_method: "app-store",
|
||||
export_options: {
|
||||
provisioningProfiles: {
|
||||
"com.saayam.app" => "match AppStore com.saayam.app"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
|
||||
slack(
|
||||
message: "Successfully uploaded a new build to TestFlight! 🚀",
|
||||
channel: "#releases"
|
||||
)
|
||||
end
|
||||
|
||||
desc "Deploy to App Store"
|
||||
lane :deploy do
|
||||
setup_ci if ENV['CI']
|
||||
|
||||
match(
|
||||
type: "appstore",
|
||||
readonly: true
|
||||
)
|
||||
|
||||
build_app(
|
||||
scheme: "SaayamApp",
|
||||
export_method: "app-store"
|
||||
)
|
||||
|
||||
upload_to_app_store(
|
||||
force: true,
|
||||
reject_if_possible: true,
|
||||
skip_metadata: false,
|
||||
skip_screenshots: false,
|
||||
submit_for_review: true,
|
||||
automatic_release: false,
|
||||
submission_information: {
|
||||
add_id_info_limits_tracking: true,
|
||||
add_id_info_serves_ads: false,
|
||||
add_id_info_tracks_action: true,
|
||||
add_id_info_tracks_install: true,
|
||||
add_id_info_uses_idfa: true,
|
||||
content_rights_has_rights: true,
|
||||
content_rights_contains_third_party_content: true,
|
||||
export_compliance_platform: 'ios',
|
||||
export_compliance_compliance_required: false,
|
||||
export_compliance_encryption_updated: false,
|
||||
export_compliance_app_type: nil,
|
||||
export_compliance_uses_encryption: false,
|
||||
export_compliance_is_exempt: false,
|
||||
export_compliance_contains_third_party_cryptography: false,
|
||||
export_compliance_contains_proprietary_cryptography: false,
|
||||
export_compliance_available_on_french_store: false
|
||||
}
|
||||
)
|
||||
|
||||
slack(
|
||||
message: "Successfully deployed to App Store! 🎉",
|
||||
channel: "#releases"
|
||||
)
|
||||
end
|
||||
|
||||
desc "Create screenshots"
|
||||
lane :screenshots do
|
||||
capture_screenshots
|
||||
upload_to_app_store(skip_binary_upload: true)
|
||||
end
|
||||
|
||||
error do |lane, exception|
|
||||
slack(
|
||||
message: "Error in #{lane}: #{exception.message}",
|
||||
success: false,
|
||||
channel: "#releases"
|
||||
)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Android Fastfile
|
||||
|
||||
**android/fastlane/Fastfile:**
|
||||
```ruby
|
||||
default_platform(:android)
|
||||
|
||||
platform :android do
|
||||
desc "Build and upload to Play Store Internal Testing"
|
||||
lane :beta do
|
||||
gradle(
|
||||
task: "clean bundleRelease",
|
||||
project_dir: "."
|
||||
)
|
||||
|
||||
upload_to_play_store(
|
||||
track: 'internal',
|
||||
release_status: 'draft',
|
||||
skip_upload_metadata: true,
|
||||
skip_upload_changelogs: true,
|
||||
skip_upload_images: true,
|
||||
skip_upload_screenshots: true
|
||||
)
|
||||
|
||||
slack(
|
||||
message: "Successfully uploaded a new build to Play Store Internal Testing! 🚀",
|
||||
channel: "#releases"
|
||||
)
|
||||
end
|
||||
|
||||
desc "Deploy to Play Store"
|
||||
lane :deploy do
|
||||
gradle(
|
||||
task: "clean bundleRelease",
|
||||
project_dir: "."
|
||||
)
|
||||
|
||||
upload_to_play_store(
|
||||
track: 'production',
|
||||
release_status: 'completed',
|
||||
skip_upload_metadata: false,
|
||||
skip_upload_changelogs: false,
|
||||
skip_upload_images: false,
|
||||
skip_upload_screenshots: false
|
||||
)
|
||||
|
||||
slack(
|
||||
message: "Successfully deployed to Play Store! 🎉",
|
||||
channel: "#releases"
|
||||
)
|
||||
end
|
||||
|
||||
desc "Create screenshots"
|
||||
lane :screenshots do
|
||||
capture_android_screenshots
|
||||
upload_to_play_store(skip_upload_apk: true)
|
||||
end
|
||||
|
||||
error do |lane, exception|
|
||||
slack(
|
||||
message: "Error in #{lane}: #{exception.message}",
|
||||
success: false,
|
||||
channel: "#releases"
|
||||
)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Secrets Management
|
||||
|
||||
**GitHub Secrets to configure:**
|
||||
- `KEYSTORE_BASE64`: Base64 encoded Android keystore
|
||||
- `KEYSTORE_PASSWORD`: Android keystore password
|
||||
- `KEY_ALIAS`: Android key alias
|
||||
- `KEY_PASSWORD`: Android key password
|
||||
- `GOOGLE_PLAY_SERVICE_ACCOUNT`: Google Play service account JSON
|
||||
- `MATCH_PASSWORD`: iOS certificates password
|
||||
- `FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD`: Apple app-specific password
|
||||
- `FASTLANE_SESSION`: Fastlane session for Apple ID
|
||||
- `SLACK_URL`: Slack webhook URL for notifications
|
||||
|
||||
### Match Setup (iOS)
|
||||
|
||||
```bash
|
||||
# Initialize match
|
||||
fastlane match init
|
||||
|
||||
# Generate certificates
|
||||
fastlane match development
|
||||
fastlane match appstore
|
||||
```
|
||||
|
||||
**Matchfile:**
|
||||
```ruby
|
||||
git_url("https://github.com/your-org/certificates")
|
||||
storage_mode("git")
|
||||
type("development")
|
||||
app_identifier(["com.saayam.app"])
|
||||
username("your-apple-id@example.com")
|
||||
```
|
||||
|
||||
## Branch Protection
|
||||
|
||||
### GitHub Branch Protection Rules
|
||||
|
||||
```yaml
|
||||
# .github/branch-protection.yml
|
||||
protection_rules:
|
||||
main:
|
||||
required_status_checks:
|
||||
strict: true
|
||||
contexts:
|
||||
- "test"
|
||||
- "build-android"
|
||||
- "build-ios"
|
||||
enforce_admins: true
|
||||
required_pull_request_reviews:
|
||||
required_approving_review_count: 2
|
||||
dismiss_stale_reviews: true
|
||||
require_code_owner_reviews: true
|
||||
restrictions:
|
||||
users: []
|
||||
teams: ["core-team"]
|
||||
```
|
||||
|
||||
## Automated Testing in CI
|
||||
|
||||
### E2E Testing in CI
|
||||
|
||||
**.github/workflows/e2e.yml:**
|
||||
```yaml
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
e2e-ios:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Install CocoaPods
|
||||
run: |
|
||||
cd ios
|
||||
pod install
|
||||
|
||||
- name: Build for Detox
|
||||
run: detox build --configuration ios.sim.release
|
||||
|
||||
- name: Run Detox tests
|
||||
run: detox test --configuration ios.sim.release --cleanup
|
||||
|
||||
e2e-android:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '11'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v2
|
||||
|
||||
- name: AVD cache
|
||||
uses: actions/cache@v3
|
||||
id: avd-cache
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/*
|
||||
~/.android/adb*
|
||||
key: avd-29
|
||||
|
||||
- name: Create AVD and generate snapshot for caching
|
||||
if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 29
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
disable-animations: false
|
||||
script: echo "Generated AVD snapshot for caching."
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Run Detox tests
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 29
|
||||
script: |
|
||||
detox build --configuration android.emu.release
|
||||
detox test --configuration android.emu.release --cleanup
|
||||
```
|
|
@ -0,0 +1,404 @@
|
|||
# Android Release Process
|
||||
|
||||
## Keystore Generation
|
||||
|
||||
### Create Release Keystore
|
||||
|
||||
```bash
|
||||
# Generate a new keystore
|
||||
keytool -genkeypair -v -storetype PKCS12 -keystore saayam-release-key.keystore -alias saayam-key-alias -keyalg RSA -keysize 2048 -validity 10000
|
||||
|
||||
# Verify keystore
|
||||
keytool -list -v -keystore saayam-release-key.keystore
|
||||
```
|
||||
|
||||
### Keystore Security
|
||||
|
||||
```bash
|
||||
# Store keystore securely
|
||||
mkdir -p ~/.android/keystores
|
||||
mv saayam-release-key.keystore ~/.android/keystores/
|
||||
|
||||
# Set proper permissions
|
||||
chmod 600 ~/.android/keystores/saayam-release-key.keystore
|
||||
```
|
||||
|
||||
## Gradle Configuration
|
||||
|
||||
### Configure Signing
|
||||
|
||||
**android/gradle.properties:**
|
||||
```properties
|
||||
# Keystore configuration
|
||||
SAAYAM_UPLOAD_STORE_FILE=saayam-release-key.keystore
|
||||
SAAYAM_UPLOAD_KEY_ALIAS=saayam-key-alias
|
||||
SAAYAM_UPLOAD_STORE_PASSWORD=your_keystore_password
|
||||
SAAYAM_UPLOAD_KEY_PASSWORD=your_key_password
|
||||
|
||||
# Build optimization
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
org.gradle.configureondemand=true
|
||||
org.gradle.daemon=true
|
||||
|
||||
# Android configuration
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
android.enableR8.fullMode=true
|
||||
```
|
||||
|
||||
**android/app/build.gradle:**
|
||||
```gradle
|
||||
android {
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.saayam.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
storePassword 'android'
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
release {
|
||||
if (project.hasProperty('SAAYAM_UPLOAD_STORE_FILE')) {
|
||||
storeFile file(SAAYAM_UPLOAD_STORE_FILE)
|
||||
storePassword SAAYAM_UPLOAD_STORE_PASSWORD
|
||||
keyAlias SAAYAM_UPLOAD_KEY_ALIAS
|
||||
keyPassword SAAYAM_UPLOAD_KEY_PASSWORD
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
applicationIdSuffix ".debug"
|
||||
debuggable true
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
}
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
debuggable false
|
||||
zipAlignEnabled true
|
||||
}
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
reset()
|
||||
enable true
|
||||
universalApk false
|
||||
include "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ProGuard Configuration
|
||||
|
||||
**android/app/proguard-rules.pro:**
|
||||
```proguard
|
||||
# React Native
|
||||
-keep class com.facebook.react.** { *; }
|
||||
-keep class com.facebook.hermes.** { *; }
|
||||
-keep class com.facebook.jni.** { *; }
|
||||
|
||||
# Keep our application class
|
||||
-keep class com.saayam.** { *; }
|
||||
|
||||
# Keep native methods
|
||||
-keepclassmembers class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Keep enums
|
||||
-keepclassmembers enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
|
||||
# Keep Parcelable implementations
|
||||
-keep class * implements android.os.Parcelable {
|
||||
public static final android.os.Parcelable$Creator *;
|
||||
}
|
||||
|
||||
# Keep serializable classes
|
||||
-keepnames class * implements java.io.Serializable
|
||||
-keepclassmembers class * implements java.io.Serializable {
|
||||
static final long serialVersionUID;
|
||||
private static final java.io.ObjectStreamField[] serialPersistentFields;
|
||||
!static !transient <fields>;
|
||||
private void writeObject(java.io.ObjectOutputStream);
|
||||
private void readObject(java.io.ObjectInputStream);
|
||||
java.lang.Object writeReplace();
|
||||
java.lang.Object readResolve();
|
||||
}
|
||||
|
||||
# Firebase
|
||||
-keep class com.google.firebase.** { *; }
|
||||
-keep class com.google.android.gms.** { *; }
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-dontwarn javax.annotation.**
|
||||
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
||||
|
||||
# Retrofit
|
||||
-dontwarn retrofit2.**
|
||||
-keep class retrofit2.** { *; }
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
|
||||
# Gson
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
-dontwarn sun.misc.**
|
||||
-keep class com.google.gson.** { *; }
|
||||
-keep class * implements com.google.gson.TypeAdapterFactory
|
||||
-keep class * implements com.google.gson.JsonSerializer
|
||||
-keep class * implements com.google.gson.JsonDeserializer
|
||||
```
|
||||
|
||||
## Build Process
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
# Clean build
|
||||
cd android && ./gradlew clean
|
||||
|
||||
# Build debug APK
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Build release APK
|
||||
./gradlew assembleRelease
|
||||
|
||||
# Build release AAB (recommended for Play Store)
|
||||
./gradlew bundleRelease
|
||||
|
||||
# Install debug APK
|
||||
./gradlew installDebug
|
||||
|
||||
# Install release APK
|
||||
./gradlew installRelease
|
||||
```
|
||||
|
||||
### Build Variants
|
||||
|
||||
```gradle
|
||||
android {
|
||||
flavorDimensions "version"
|
||||
|
||||
productFlavors {
|
||||
development {
|
||||
dimension "version"
|
||||
applicationIdSuffix ".dev"
|
||||
versionNameSuffix "-dev"
|
||||
buildConfigField "String", "API_BASE_URL", '"https://dev-api.saayam.com"'
|
||||
resValue "string", "app_name", "Saayam Dev"
|
||||
}
|
||||
|
||||
staging {
|
||||
dimension "version"
|
||||
applicationIdSuffix ".staging"
|
||||
versionNameSuffix "-staging"
|
||||
buildConfigField "String", "API_BASE_URL", '"https://staging-api.saayam.com"'
|
||||
resValue "string", "app_name", "Saayam Staging"
|
||||
}
|
||||
|
||||
production {
|
||||
dimension "version"
|
||||
buildConfigField "String", "API_BASE_URL", '"https://api.saayam.com"'
|
||||
resValue "string", "app_name", "Saayam"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Google Play Console Setup
|
||||
|
||||
### Create Application
|
||||
|
||||
1. **Go to Google Play Console**
|
||||
2. **Create Application**
|
||||
- App name: Saayam
|
||||
- Default language: English
|
||||
- App or game: App
|
||||
- Free or paid: Free
|
||||
|
||||
3. **App Content**
|
||||
- Privacy Policy URL
|
||||
- App category
|
||||
- Content rating questionnaire
|
||||
- Target audience
|
||||
- Data safety form
|
||||
|
||||
### Upload Release
|
||||
|
||||
```bash
|
||||
# Build release AAB
|
||||
cd android && ./gradlew bundleRelease
|
||||
|
||||
# AAB file location
|
||||
# android/app/build/outputs/bundle/release/app-release.aab
|
||||
```
|
||||
|
||||
### Release Tracks
|
||||
|
||||
1. **Internal Testing**
|
||||
- Upload AAB
|
||||
- Add internal testers
|
||||
- Release to internal testing
|
||||
|
||||
2. **Closed Testing**
|
||||
- Create closed testing track
|
||||
- Add test users via email lists
|
||||
- Release to closed testing
|
||||
|
||||
3. **Open Testing**
|
||||
- Create open testing track
|
||||
- Set country availability
|
||||
- Release to open testing
|
||||
|
||||
4. **Production**
|
||||
- Review release
|
||||
- Set rollout percentage
|
||||
- Release to production
|
||||
|
||||
## Version Management
|
||||
|
||||
### Automated Version Bumping
|
||||
|
||||
**scripts/bump-android-version.sh:**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
VERSION_TYPE=${1:-patch} # major, minor, patch
|
||||
BUILD_GRADLE="android/app/build.gradle"
|
||||
|
||||
# Get current version
|
||||
CURRENT_VERSION=$(grep "versionName" $BUILD_GRADLE | sed 's/.*versionName "\(.*\)"/\1/')
|
||||
CURRENT_CODE=$(grep "versionCode" $BUILD_GRADLE | sed 's/.*versionCode \(.*\)/\1/')
|
||||
|
||||
echo "Current version: $CURRENT_VERSION ($CURRENT_CODE)"
|
||||
|
||||
# Calculate new version
|
||||
IFS='.' read -ra VERSION_PARTS <<< "$CURRENT_VERSION"
|
||||
MAJOR=${VERSION_PARTS[0]}
|
||||
MINOR=${VERSION_PARTS[1]}
|
||||
PATCH=${VERSION_PARTS[2]}
|
||||
|
||||
case $VERSION_TYPE in
|
||||
major)
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
;;
|
||||
minor)
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
;;
|
||||
patch)
|
||||
PATCH=$((PATCH + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
||||
NEW_CODE=$((CURRENT_CODE + 1))
|
||||
|
||||
echo "New version: $NEW_VERSION ($NEW_CODE)"
|
||||
|
||||
# Update build.gradle
|
||||
sed -i "s/versionCode $CURRENT_CODE/versionCode $NEW_CODE/" $BUILD_GRADLE
|
||||
sed -i "s/versionName \"$CURRENT_VERSION\"/versionName \"$NEW_VERSION\"/" $BUILD_GRADLE
|
||||
|
||||
echo "Version updated successfully!"
|
||||
```
|
||||
|
||||
### Git Tagging
|
||||
|
||||
```bash
|
||||
# Create and push tag
|
||||
git tag -a v1.0.0 -m "Release version 1.0.0"
|
||||
git push origin v1.0.0
|
||||
|
||||
# List tags
|
||||
git tag -l
|
||||
|
||||
# Delete tag
|
||||
git tag -d v1.0.0
|
||||
git push origin --delete v1.0.0
|
||||
```
|
||||
|
||||
## Release Checklist
|
||||
|
||||
### Pre-Release Checklist
|
||||
|
||||
- [ ] Update version number
|
||||
- [ ] Update changelog
|
||||
- [ ] Run all tests
|
||||
- [ ] Test on multiple devices
|
||||
- [ ] Check ProGuard configuration
|
||||
- [ ] Verify signing configuration
|
||||
- [ ] Test release build locally
|
||||
- [ ] Update app store metadata
|
||||
- [ ] Prepare release notes
|
||||
|
||||
### Post-Release Checklist
|
||||
|
||||
- [ ] Monitor crash reports
|
||||
- [ ] Check app store reviews
|
||||
- [ ] Monitor performance metrics
|
||||
- [ ] Update documentation
|
||||
- [ ] Create git tag
|
||||
- [ ] Notify team of release
|
||||
- [ ] Plan next release
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Build Issues
|
||||
|
||||
```bash
|
||||
# Clean and rebuild
|
||||
cd android
|
||||
./gradlew clean
|
||||
./gradlew assembleRelease
|
||||
|
||||
# Clear React Native cache
|
||||
npx react-native start --reset-cache
|
||||
|
||||
# Clear Metro cache
|
||||
npx react-native start --reset-cache
|
||||
|
||||
# Reset Android build
|
||||
cd android
|
||||
rm -rf build/
|
||||
rm -rf app/build/
|
||||
./gradlew clean
|
||||
```
|
||||
|
||||
### Keystore Issues
|
||||
|
||||
```bash
|
||||
# Verify keystore
|
||||
keytool -list -v -keystore your-keystore.keystore
|
||||
|
||||
# Check keystore alias
|
||||
keytool -list -keystore your-keystore.keystore
|
||||
|
||||
# Export certificate
|
||||
keytool -export -alias your-alias -keystore your-keystore.keystore -file certificate.crt
|
||||
```
|
|
@ -0,0 +1,492 @@
|
|||
# iOS Release Process
|
||||
|
||||
## Apple Developer Account Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Apple Developer Account** ($99/year)
|
||||
2. **Xcode** (latest version)
|
||||
3. **Valid certificates and provisioning profiles**
|
||||
|
||||
### App Store Connect Setup
|
||||
|
||||
1. **Create App Record**
|
||||
- App name: Saayam
|
||||
- Bundle ID: com.saayam.app
|
||||
- SKU: unique identifier
|
||||
- Primary language: English
|
||||
|
||||
2. **App Information**
|
||||
- Category: Social Networking / Lifestyle
|
||||
- Content rights
|
||||
- Age rating
|
||||
- App Review Information
|
||||
|
||||
## Certificates & Provisioning
|
||||
|
||||
### Certificate Types
|
||||
|
||||
```bash
|
||||
# Development Certificate
|
||||
# - Used for development and testing
|
||||
# - Install on development devices
|
||||
|
||||
# Distribution Certificate
|
||||
# - Used for App Store distribution
|
||||
# - Required for production builds
|
||||
```
|
||||
|
||||
### Using Fastlane Match
|
||||
|
||||
```bash
|
||||
# Initialize match
|
||||
fastlane match init
|
||||
|
||||
# Generate development certificates
|
||||
fastlane match development
|
||||
|
||||
# Generate App Store certificates
|
||||
fastlane match appstore
|
||||
|
||||
# Generate Ad Hoc certificates (for testing)
|
||||
fastlane match adhoc
|
||||
```
|
||||
|
||||
**Matchfile:**
|
||||
```ruby
|
||||
git_url("https://github.com/your-org/certificates")
|
||||
storage_mode("git")
|
||||
type("development")
|
||||
app_identifier(["com.saayam.app"])
|
||||
username("your-apple-id@example.com")
|
||||
team_id("YOUR_TEAM_ID")
|
||||
```
|
||||
|
||||
### Manual Certificate Management
|
||||
|
||||
1. **Create Certificate Signing Request (CSR)**
|
||||
- Open Keychain Access
|
||||
- Certificate Assistant → Request Certificate from Certificate Authority
|
||||
- Save CSR file
|
||||
|
||||
2. **Create Certificates in Developer Portal**
|
||||
- iOS Development Certificate
|
||||
- iOS Distribution Certificate
|
||||
|
||||
3. **Create App ID**
|
||||
- Bundle ID: com.saayam.app
|
||||
- Enable required capabilities (Push Notifications, etc.)
|
||||
|
||||
4. **Create Provisioning Profiles**
|
||||
- Development Profile
|
||||
- App Store Distribution Profile
|
||||
|
||||
## Xcode Configuration
|
||||
|
||||
### Project Settings
|
||||
|
||||
**Build Settings:**
|
||||
```
|
||||
# Code Signing
|
||||
CODE_SIGN_IDENTITY = "iPhone Distribution"
|
||||
DEVELOPMENT_TEAM = "YOUR_TEAM_ID"
|
||||
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.saayam.app"
|
||||
|
||||
# Build Configuration
|
||||
ENABLE_BITCODE = YES
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O"
|
||||
GCC_OPTIMIZATION_LEVEL = "s"
|
||||
|
||||
# Deployment
|
||||
IPHONEOS_DEPLOYMENT_TARGET = "12.0"
|
||||
TARGETED_DEVICE_FAMILY = "1,2" # iPhone and iPad
|
||||
```
|
||||
|
||||
### Info.plist Configuration
|
||||
|
||||
**ios/SaayamApp/Info.plist:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Saayam</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.saayam.app</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>This app needs access to location to show nearby fundraising requests.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app needs access to camera to take photos for fundraising requests.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>This app needs access to photo library to select images for fundraising requests.</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>This app needs access to contacts to help you share fundraising requests.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
## Build Process
|
||||
|
||||
### Manual Build
|
||||
|
||||
```bash
|
||||
# Clean build folder
|
||||
cd ios
|
||||
rm -rf build/
|
||||
|
||||
# Install dependencies
|
||||
pod install
|
||||
|
||||
# Build for device
|
||||
xcodebuild -workspace SaayamApp.xcworkspace \
|
||||
-scheme SaayamApp \
|
||||
-configuration Release \
|
||||
-destination generic/platform=iOS \
|
||||
-archivePath $PWD/build/SaayamApp.xcarchive \
|
||||
archive
|
||||
|
||||
# Export IPA
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath $PWD/build/SaayamApp.xcarchive \
|
||||
-exportPath $PWD/build/ \
|
||||
-exportOptionsPlist ExportOptions.plist
|
||||
```
|
||||
|
||||
### Export Options
|
||||
|
||||
**ios/ExportOptions.plist:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
<key>teamID</key>
|
||||
<string>YOUR_TEAM_ID</string>
|
||||
<key>uploadBitcode</key>
|
||||
<true/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>compileBitcode</key>
|
||||
<true/>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>com.saayam.app</key>
|
||||
<string>match AppStore com.saayam.app</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
### Fastlane Build
|
||||
|
||||
**ios/fastlane/Fastfile:**
|
||||
```ruby
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Build and upload to TestFlight"
|
||||
lane :beta do
|
||||
setup_ci if ENV['CI']
|
||||
|
||||
# Sync certificates and provisioning profiles
|
||||
match(
|
||||
type: "appstore",
|
||||
readonly: true
|
||||
)
|
||||
|
||||
# Increment build number
|
||||
increment_build_number(
|
||||
xcodeproj: "SaayamApp.xcodeproj"
|
||||
)
|
||||
|
||||
# Build the app
|
||||
build_app(
|
||||
scheme: "SaayamApp",
|
||||
export_method: "app-store",
|
||||
export_options: {
|
||||
provisioningProfiles: {
|
||||
"com.saayam.app" => "match AppStore com.saayam.app"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Upload to TestFlight
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true,
|
||||
changelog: "Bug fixes and performance improvements"
|
||||
)
|
||||
end
|
||||
|
||||
desc "Deploy to App Store"
|
||||
lane :release do
|
||||
setup_ci if ENV['CI']
|
||||
|
||||
match(
|
||||
type: "appstore",
|
||||
readonly: true
|
||||
)
|
||||
|
||||
build_app(
|
||||
scheme: "SaayamApp",
|
||||
export_method: "app-store"
|
||||
)
|
||||
|
||||
upload_to_app_store(
|
||||
force: true,
|
||||
reject_if_possible: true,
|
||||
skip_metadata: false,
|
||||
skip_screenshots: false,
|
||||
submit_for_review: true,
|
||||
automatic_release: false,
|
||||
submission_information: {
|
||||
add_id_info_limits_tracking: true,
|
||||
add_id_info_serves_ads: false,
|
||||
add_id_info_tracks_action: true,
|
||||
add_id_info_tracks_install: true,
|
||||
add_id_info_uses_idfa: true,
|
||||
content_rights_has_rights: true,
|
||||
content_rights_contains_third_party_content: true,
|
||||
export_compliance_platform: 'ios',
|
||||
export_compliance_compliance_required: false,
|
||||
export_compliance_encryption_updated: false,
|
||||
export_compliance_uses_encryption: false,
|
||||
export_compliance_is_exempt: false
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Version Management
|
||||
|
||||
### Automated Version Bumping
|
||||
|
||||
**scripts/bump-ios-version.sh:**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
VERSION_TYPE=${1:-patch} # major, minor, patch
|
||||
PLIST_PATH="ios/SaayamApp/Info.plist"
|
||||
|
||||
# Get current version
|
||||
CURRENT_VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$PLIST_PATH")
|
||||
CURRENT_BUILD=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$PLIST_PATH")
|
||||
|
||||
echo "Current version: $CURRENT_VERSION ($CURRENT_BUILD)"
|
||||
|
||||
# Calculate new version
|
||||
IFS='.' read -ra VERSION_PARTS <<< "$CURRENT_VERSION"
|
||||
MAJOR=${VERSION_PARTS[0]}
|
||||
MINOR=${VERSION_PARTS[1]}
|
||||
PATCH=${VERSION_PARTS[2]}
|
||||
|
||||
case $VERSION_TYPE in
|
||||
major)
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
;;
|
||||
minor)
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
;;
|
||||
patch)
|
||||
PATCH=$((PATCH + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
||||
NEW_BUILD=$((CURRENT_BUILD + 1))
|
||||
|
||||
echo "New version: $NEW_VERSION ($NEW_BUILD)"
|
||||
|
||||
# Update Info.plist
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $NEW_VERSION" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $NEW_BUILD" "$PLIST_PATH"
|
||||
|
||||
# Update Xcode project
|
||||
xcrun agvtool new-marketing-version $NEW_VERSION
|
||||
xcrun agvtool new-version -all $NEW_BUILD
|
||||
|
||||
echo "Version updated successfully!"
|
||||
```
|
||||
|
||||
## TestFlight Distribution
|
||||
|
||||
### Internal Testing
|
||||
|
||||
```ruby
|
||||
# Fastlane lane for internal testing
|
||||
lane :internal do
|
||||
build_app(scheme: "SaayamApp")
|
||||
|
||||
upload_to_testflight(
|
||||
groups: ["Internal Testers"],
|
||||
changelog: "Internal build for testing",
|
||||
distribute_external: false
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
### External Testing
|
||||
|
||||
```ruby
|
||||
# Fastlane lane for external testing
|
||||
lane :external do
|
||||
build_app(scheme: "SaayamApp")
|
||||
|
||||
upload_to_testflight(
|
||||
groups: ["Beta Testers"],
|
||||
changelog: "Beta version with new features",
|
||||
distribute_external: true,
|
||||
notify_external_testers: true
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
## App Store Submission
|
||||
|
||||
### App Store Metadata
|
||||
|
||||
**fastlane/metadata/en-US/:**
|
||||
```
|
||||
name.txt # App name
|
||||
subtitle.txt # App subtitle
|
||||
description.txt # App description
|
||||
keywords.txt # App Store keywords
|
||||
marketing_url.txt # Marketing URL
|
||||
support_url.txt # Support URL
|
||||
privacy_url.txt # Privacy policy URL
|
||||
```
|
||||
|
||||
**description.txt:**
|
||||
```
|
||||
Saayam is a revolutionary fundraising platform that connects donors with meaningful causes in their local community.
|
||||
|
||||
Key Features:
|
||||
• Browse local fundraising requests
|
||||
• Support NGOs and individual causes
|
||||
• Track your donation impact
|
||||
• Secure payment processing
|
||||
• Real-time updates on funded projects
|
||||
|
||||
Join thousands of users making a difference in their communities. Download Saayam today and start supporting causes that matter to you.
|
||||
```
|
||||
|
||||
### Screenshots
|
||||
|
||||
```bash
|
||||
# Generate screenshots using Fastlane
|
||||
fastlane snapshot
|
||||
|
||||
# Upload screenshots
|
||||
fastlane deliver --skip_binary_upload
|
||||
```
|
||||
|
||||
**Screenshotfile:**
|
||||
```ruby
|
||||
devices([
|
||||
"iPhone 14 Pro Max",
|
||||
"iPhone 14 Pro",
|
||||
"iPhone SE (3rd generation)",
|
||||
"iPad Pro (12.9-inch) (6th generation)"
|
||||
])
|
||||
|
||||
languages([
|
||||
"en-US"
|
||||
])
|
||||
|
||||
scheme("SaayamApp")
|
||||
|
||||
clear_previous_screenshots(true)
|
||||
```
|
||||
|
||||
## Release Checklist
|
||||
|
||||
### Pre-Submission Checklist
|
||||
|
||||
- [ ] Update version and build numbers
|
||||
- [ ] Test on multiple devices and iOS versions
|
||||
- [ ] Verify all app store metadata
|
||||
- [ ] Check app icons and screenshots
|
||||
- [ ] Test in-app purchases (if applicable)
|
||||
- [ ] Verify privacy policy and terms of service
|
||||
- [ ] Test deep linking
|
||||
- [ ] Check push notifications
|
||||
- [ ] Verify analytics integration
|
||||
- [ ] Test crash reporting
|
||||
|
||||
### App Store Review Guidelines
|
||||
|
||||
- [ ] App follows iOS Human Interface Guidelines
|
||||
- [ ] No crashes or major bugs
|
||||
- [ ] App provides value to users
|
||||
- [ ] Respects user privacy
|
||||
- [ ] Follows App Store Review Guidelines
|
||||
- [ ] Proper use of Apple APIs
|
||||
- [ ] Appropriate content rating
|
||||
- [ ] Valid contact information
|
||||
|
||||
### Post-Submission
|
||||
|
||||
- [ ] Monitor App Store Connect for review status
|
||||
- [ ] Respond to reviewer feedback if needed
|
||||
- [ ] Prepare for release day
|
||||
- [ ] Monitor crash reports and user feedback
|
||||
- [ ] Plan post-launch updates
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
```bash
|
||||
# Certificate issues
|
||||
# - Ensure certificates are valid and not expired
|
||||
# - Check provisioning profile matches bundle ID
|
||||
# - Verify team ID is correct
|
||||
|
||||
# Build issues
|
||||
# - Clean build folder: rm -rf ios/build
|
||||
# - Clean derived data: rm -rf ~/Library/Developer/Xcode/DerivedData
|
||||
# - Reset CocoaPods: cd ios && pod deintegrate && pod install
|
||||
|
||||
# Archive issues
|
||||
# - Check scheme is set to Release
|
||||
# - Verify code signing settings
|
||||
# - Ensure all dependencies are properly linked
|
||||
```
|
||||
|
||||
### Debugging Build Failures
|
||||
|
||||
```bash
|
||||
# Verbose build output
|
||||
xcodebuild -workspace SaayamApp.xcworkspace \
|
||||
-scheme SaayamApp \
|
||||
-configuration Release \
|
||||
-destination generic/platform=iOS \
|
||||
archive | xcpretty
|
||||
|
||||
# Check build settings
|
||||
xcodebuild -workspace SaayamApp.xcworkspace \
|
||||
-scheme SaayamApp \
|
||||
-showBuildSettings
|
||||
```
|
|
@ -0,0 +1,475 @@
|
|||
# Version Management
|
||||
|
||||
## Semantic Versioning
|
||||
|
||||
### Version Format
|
||||
|
||||
```
|
||||
MAJOR.MINOR.PATCH
|
||||
|
||||
Examples:
|
||||
1.0.0 - Initial release
|
||||
1.0.1 - Bug fix
|
||||
1.1.0 - New feature
|
||||
2.0.0 - Breaking changes
|
||||
```
|
||||
|
||||
### Version Rules
|
||||
|
||||
- **MAJOR**: Breaking changes, incompatible API changes
|
||||
- **MINOR**: New features, backward compatible
|
||||
- **PATCH**: Bug fixes, backward compatible
|
||||
|
||||
## Automated Versioning
|
||||
|
||||
### Package.json Version Management
|
||||
|
||||
```bash
|
||||
# Install version management tools
|
||||
npm install -g standard-version
|
||||
|
||||
# Bump version automatically
|
||||
npm version patch # 1.0.0 -> 1.0.1
|
||||
npm version minor # 1.0.0 -> 1.1.0
|
||||
npm version major # 1.0.0 -> 2.0.0
|
||||
|
||||
# With standard-version (recommended)
|
||||
npx standard-version --release-as patch
|
||||
npx standard-version --release-as minor
|
||||
npx standard-version --release-as major
|
||||
```
|
||||
|
||||
### Cross-Platform Version Script
|
||||
|
||||
**scripts/version-bump.js:**
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const versionType = process.argv[2] || 'patch';
|
||||
const packageJsonPath = path.join(__dirname, '../package.json');
|
||||
const androidBuildGradle = path.join(__dirname, '../android/app/build.gradle');
|
||||
const iosPlistPath = path.join(__dirname, '../ios/SaayamApp/Info.plist');
|
||||
|
||||
// Read current version from package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
console.log(`Current version: ${currentVersion}`);
|
||||
|
||||
// Bump version in package.json
|
||||
execSync(`npm version ${versionType} --no-git-tag-version`, { stdio: 'inherit' });
|
||||
|
||||
// Read new version
|
||||
const updatedPackageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const newVersion = updatedPackageJson.version;
|
||||
|
||||
console.log(`New version: ${newVersion}`);
|
||||
|
||||
// Update Android version
|
||||
updateAndroidVersion(newVersion);
|
||||
|
||||
// Update iOS version
|
||||
updateiOSVersion(newVersion);
|
||||
|
||||
// Create git commit and tag
|
||||
execSync(`git add .`, { stdio: 'inherit' });
|
||||
execSync(`git commit -m "chore: bump version to ${newVersion}"`, { stdio: 'inherit' });
|
||||
execSync(`git tag -a v${newVersion} -m "Release version ${newVersion}"`, { stdio: 'inherit' });
|
||||
|
||||
console.log(`Version bumped to ${newVersion} successfully!`);
|
||||
|
||||
function updateAndroidVersion(version) {
|
||||
let buildGradleContent = fs.readFileSync(androidBuildGradle, 'utf8');
|
||||
|
||||
// Update versionName
|
||||
buildGradleContent = buildGradleContent.replace(
|
||||
/versionName\s+"[^"]*"/,
|
||||
`versionName "${version}"`
|
||||
);
|
||||
|
||||
// Update versionCode (increment by 1)
|
||||
const versionCodeMatch = buildGradleContent.match(/versionCode\s+(\d+)/);
|
||||
if (versionCodeMatch) {
|
||||
const currentVersionCode = parseInt(versionCodeMatch[1]);
|
||||
const newVersionCode = currentVersionCode + 1;
|
||||
buildGradleContent = buildGradleContent.replace(
|
||||
/versionCode\s+\d+/,
|
||||
`versionCode ${newVersionCode}`
|
||||
);
|
||||
console.log(`Android versionCode updated to: ${newVersionCode}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(androidBuildGradle, buildGradleContent);
|
||||
console.log(`Android versionName updated to: ${version}`);
|
||||
}
|
||||
|
||||
function updateiOSVersion(version) {
|
||||
try {
|
||||
// Update CFBundleShortVersionString
|
||||
execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${version}" "${iosPlistPath}"`, { stdio: 'inherit' });
|
||||
|
||||
// Get current build number and increment
|
||||
const currentBuild = execSync(`/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "${iosPlistPath}"`, { encoding: 'utf8' }).trim();
|
||||
const newBuild = parseInt(currentBuild) + 1;
|
||||
|
||||
// Update CFBundleVersion
|
||||
execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${newBuild}" "${iosPlistPath}"`, { stdio: 'inherit' });
|
||||
|
||||
console.log(`iOS version updated to: ${version} (${newBuild})`);
|
||||
} catch (error) {
|
||||
console.error('Error updating iOS version:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Make Script Executable
|
||||
|
||||
```bash
|
||||
chmod +x scripts/version-bump.js
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Bump patch version
|
||||
./scripts/version-bump.js patch
|
||||
|
||||
# Bump minor version
|
||||
./scripts/version-bump.js minor
|
||||
|
||||
# Bump major version
|
||||
./scripts/version-bump.js major
|
||||
```
|
||||
|
||||
## Changelog Management
|
||||
|
||||
### Conventional Commits
|
||||
|
||||
```bash
|
||||
# Install commitizen for conventional commits
|
||||
npm install -g commitizen cz-conventional-changelog
|
||||
|
||||
# Configure commitizen
|
||||
echo '{ "path": "cz-conventional-changelog" }' > ~/.czrc
|
||||
|
||||
# Use commitizen for commits
|
||||
git cz
|
||||
```
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
**Types:**
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation changes
|
||||
- `style`: Code style changes
|
||||
- `refactor`: Code refactoring
|
||||
- `test`: Adding tests
|
||||
- `chore`: Maintenance tasks
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
feat(auth): add biometric authentication
|
||||
fix(api): resolve login timeout issue
|
||||
docs(readme): update installation instructions
|
||||
```
|
||||
|
||||
### Automated Changelog
|
||||
|
||||
**Install standard-version:**
|
||||
```bash
|
||||
npm install -D standard-version
|
||||
```
|
||||
|
||||
**package.json scripts:**
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"release": "standard-version",
|
||||
"release:minor": "standard-version --release-as minor",
|
||||
"release:major": "standard-version --release-as major",
|
||||
"release:patch": "standard-version --release-as patch"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**.versionrc.json:**
|
||||
```json
|
||||
{
|
||||
"types": [
|
||||
{"type": "feat", "section": "Features"},
|
||||
{"type": "fix", "section": "Bug Fixes"},
|
||||
{"type": "chore", "hidden": true},
|
||||
{"type": "docs", "hidden": true},
|
||||
{"type": "style", "hidden": true},
|
||||
{"type": "refactor", "section": "Code Refactoring"},
|
||||
{"type": "perf", "section": "Performance Improvements"},
|
||||
{"type": "test", "hidden": true}
|
||||
],
|
||||
"commitUrlFormat": "https://github.com/your-org/saayam-app/commit/{{hash}}",
|
||||
"compareUrlFormat": "https://github.com/your-org/saayam-app/compare/{{previousTag}}...{{currentTag}}"
|
||||
}
|
||||
```
|
||||
|
||||
## Release Branches
|
||||
|
||||
### Git Flow Strategy
|
||||
|
||||
```bash
|
||||
# Install git-flow
|
||||
brew install git-flow-avh # macOS
|
||||
apt-get install git-flow # Ubuntu
|
||||
|
||||
# Initialize git-flow
|
||||
git flow init
|
||||
|
||||
# Start a release branch
|
||||
git flow release start 1.2.0
|
||||
|
||||
# Finish a release branch
|
||||
git flow release finish 1.2.0
|
||||
```
|
||||
|
||||
### Branch Structure
|
||||
|
||||
```
|
||||
main (production)
|
||||
├── develop (development)
|
||||
├── feature/user-authentication
|
||||
├── feature/payment-integration
|
||||
├── release/1.2.0
|
||||
├── hotfix/critical-bug-fix
|
||||
```
|
||||
|
||||
### Release Process
|
||||
|
||||
```bash
|
||||
# 1. Create release branch from develop
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b release/1.2.0
|
||||
|
||||
# 2. Update version numbers
|
||||
./scripts/version-bump.js minor
|
||||
|
||||
# 3. Run final tests
|
||||
npm test
|
||||
npm run e2e
|
||||
|
||||
# 4. Merge to main
|
||||
git checkout main
|
||||
git merge release/1.2.0
|
||||
|
||||
# 5. Tag release
|
||||
git tag -a v1.2.0 -m "Release version 1.2.0"
|
||||
|
||||
# 6. Merge back to develop
|
||||
git checkout develop
|
||||
git merge release/1.2.0
|
||||
|
||||
# 7. Push everything
|
||||
git push origin main
|
||||
git push origin develop
|
||||
git push origin v1.2.0
|
||||
|
||||
# 8. Delete release branch
|
||||
git branch -d release/1.2.0
|
||||
```
|
||||
|
||||
## Build Numbers
|
||||
|
||||
### Platform-Specific Build Numbers
|
||||
|
||||
**Android (versionCode):**
|
||||
- Integer that increases with each release
|
||||
- Used by Google Play to determine newer versions
|
||||
- Must be incremented for each upload
|
||||
|
||||
**iOS (CFBundleVersion):**
|
||||
- String that increases with each build
|
||||
- Used by App Store to determine newer builds
|
||||
- Can be numeric or alphanumeric
|
||||
|
||||
### Build Number Strategy
|
||||
|
||||
```javascript
|
||||
// Generate build number based on timestamp
|
||||
function generateBuildNumber() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear().toString().slice(-2);
|
||||
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = now.getDate().toString().padStart(2, '0');
|
||||
const hour = now.getHours().toString().padStart(2, '0');
|
||||
const minute = now.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `${year}${month}${day}${hour}${minute}`;
|
||||
}
|
||||
|
||||
// Example: 2312151430 (23-12-15 14:30)
|
||||
```
|
||||
|
||||
## Version Validation
|
||||
|
||||
### Pre-Release Checks
|
||||
|
||||
**scripts/validate-version.js:**
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const packageJsonPath = path.join(__dirname, '../package.json');
|
||||
const androidBuildGradle = path.join(__dirname, '../android/app/build.gradle');
|
||||
const iosPlistPath = path.join(__dirname, '../ios/SaayamApp/Info.plist');
|
||||
|
||||
function validateVersions() {
|
||||
// Get package.json version
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const packageVersion = packageJson.version;
|
||||
|
||||
// Get Android version
|
||||
const buildGradleContent = fs.readFileSync(androidBuildGradle, 'utf8');
|
||||
const androidVersionMatch = buildGradleContent.match(/versionName\s+"([^"]*)"/);
|
||||
const androidVersion = androidVersionMatch ? androidVersionMatch[1] : null;
|
||||
|
||||
// Get iOS version
|
||||
let iosVersion = null;
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
iosVersion = execSync(`/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "${iosPlistPath}"`, { encoding: 'utf8' }).trim();
|
||||
} catch (error) {
|
||||
console.error('Could not read iOS version');
|
||||
}
|
||||
|
||||
console.log('Version Validation:');
|
||||
console.log(`Package.json: ${packageVersion}`);
|
||||
console.log(`Android: ${androidVersion}`);
|
||||
console.log(`iOS: ${iosVersion}`);
|
||||
|
||||
const allVersionsMatch = packageVersion === androidVersion && packageVersion === iosVersion;
|
||||
|
||||
if (allVersionsMatch) {
|
||||
console.log('✅ All versions match!');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('❌ Version mismatch detected!');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
validateVersions();
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
**.github/workflows/version-check.yml:**
|
||||
```yaml
|
||||
name: Version Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Validate versions
|
||||
run: node scripts/validate-version.js
|
||||
```
|
||||
|
||||
## Release Notes
|
||||
|
||||
### Automated Release Notes
|
||||
|
||||
**scripts/generate-release-notes.js:**
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
function generateReleaseNotes(fromTag, toTag) {
|
||||
// Get commits between tags
|
||||
const commits = execSync(`git log ${fromTag}..${toTag} --pretty=format:"%h %s"`, { encoding: 'utf8' })
|
||||
.split('\n')
|
||||
.filter(line => line.trim());
|
||||
|
||||
const features = [];
|
||||
const fixes = [];
|
||||
const others = [];
|
||||
|
||||
commits.forEach(commit => {
|
||||
if (commit.includes('feat:') || commit.includes('feat(')) {
|
||||
features.push(commit.replace(/^\w+\s+/, '').replace(/^feat(\([^)]+\))?:\s*/, ''));
|
||||
} else if (commit.includes('fix:') || commit.includes('fix(')) {
|
||||
fixes.push(commit.replace(/^\w+\s+/, '').replace(/^fix(\([^)]+\))?:\s*/, ''));
|
||||
} else {
|
||||
others.push(commit.replace(/^\w+\s+/, ''));
|
||||
}
|
||||
});
|
||||
|
||||
let releaseNotes = `# Release Notes\n\n`;
|
||||
|
||||
if (features.length > 0) {
|
||||
releaseNotes += `## 🚀 New Features\n`;
|
||||
features.forEach(feature => {
|
||||
releaseNotes += `- ${feature}\n`;
|
||||
});
|
||||
releaseNotes += '\n';
|
||||
}
|
||||
|
||||
if (fixes.length > 0) {
|
||||
releaseNotes += `## 🐛 Bug Fixes\n`;
|
||||
fixes.forEach(fix => {
|
||||
releaseNotes += `- ${fix}\n`;
|
||||
});
|
||||
releaseNotes += '\n';
|
||||
}
|
||||
|
||||
if (others.length > 0) {
|
||||
releaseNotes += `## 🔧 Other Changes\n`;
|
||||
others.forEach(other => {
|
||||
releaseNotes += `- ${other}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
return releaseNotes;
|
||||
}
|
||||
|
||||
// Usage: node generate-release-notes.js v1.0.0 v1.1.0
|
||||
const fromTag = process.argv[2];
|
||||
const toTag = process.argv[3] || 'HEAD';
|
||||
|
||||
if (!fromTag) {
|
||||
console.error('Usage: node generate-release-notes.js <from-tag> [to-tag]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const releaseNotes = generateReleaseNotes(fromTag, toTag);
|
||||
console.log(releaseNotes);
|
||||
|
||||
// Save to file
|
||||
fs.writeFileSync('RELEASE_NOTES.md', releaseNotes);
|
||||
console.log('\nRelease notes saved to RELEASE_NOTES.md');
|
||||
```
|
|
@ -0,0 +1,591 @@
|
|||
# Analytics Setup
|
||||
|
||||
## Firebase Analytics
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
yarn add @react-native-firebase/app
|
||||
yarn add @react-native-firebase/analytics
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
**analytics.config.js:**
|
||||
```typescript
|
||||
import analytics from '@react-native-firebase/analytics';
|
||||
import Config from 'react-native-config';
|
||||
|
||||
class AnalyticsService {
|
||||
static async initialize() {
|
||||
if (__DEV__) {
|
||||
// Disable analytics in development
|
||||
await analytics().setAnalyticsCollectionEnabled(false);
|
||||
} else {
|
||||
await analytics().setAnalyticsCollectionEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
static async setUserId(userId: string) {
|
||||
await analytics().setUserId(userId);
|
||||
}
|
||||
|
||||
static async setUserProperties(properties: Record<string, string>) {
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
await analytics().setUserProperty(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
static async logEvent(eventName: string, parameters?: Record<string, any>) {
|
||||
try {
|
||||
await analytics().logEvent(eventName, parameters);
|
||||
console.log(`Analytics: ${eventName}`, parameters);
|
||||
} catch (error) {
|
||||
console.error('Analytics error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
static async logScreenView(screenName: string, screenClass?: string) {
|
||||
await this.logEvent('screen_view', {
|
||||
screen_name: screenName,
|
||||
screen_class: screenClass || screenName,
|
||||
});
|
||||
}
|
||||
|
||||
static async logLogin(method: string) {
|
||||
await this.logEvent('login', {
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
static async logSignUp(method: string) {
|
||||
await this.logEvent('sign_up', {
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
static async logPurchase(transactionId: string, value: number, currency: string = 'USD') {
|
||||
await this.logEvent('purchase', {
|
||||
transaction_id: transactionId,
|
||||
value,
|
||||
currency,
|
||||
});
|
||||
}
|
||||
|
||||
static async logShare(contentType: string, itemId: string) {
|
||||
await this.logEvent('share', {
|
||||
content_type: contentType,
|
||||
item_id: itemId,
|
||||
});
|
||||
}
|
||||
|
||||
static async logSearch(searchTerm: string) {
|
||||
await this.logEvent('search', {
|
||||
search_term: searchTerm,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default AnalyticsService;
|
||||
```
|
||||
|
||||
## Custom Event Tracking
|
||||
|
||||
### User Journey Tracking
|
||||
|
||||
```typescript
|
||||
// src/utils/userJourneyTracker.ts
|
||||
import AnalyticsService from '@config/analytics.config';
|
||||
|
||||
export class UserJourneyTracker {
|
||||
private static sessionStartTime: number = Date.now();
|
||||
private static currentScreen: string = '';
|
||||
|
||||
static startSession() {
|
||||
this.sessionStartTime = Date.now();
|
||||
AnalyticsService.logEvent('session_start', {
|
||||
timestamp: this.sessionStartTime,
|
||||
});
|
||||
}
|
||||
|
||||
static endSession() {
|
||||
const sessionDuration = Date.now() - this.sessionStartTime;
|
||||
AnalyticsService.logEvent('session_end', {
|
||||
session_duration: sessionDuration,
|
||||
});
|
||||
}
|
||||
|
||||
static trackScreenView(screenName: string) {
|
||||
const previousScreen = this.currentScreen;
|
||||
this.currentScreen = screenName;
|
||||
|
||||
AnalyticsService.logScreenView(screenName);
|
||||
|
||||
if (previousScreen) {
|
||||
AnalyticsService.logEvent('screen_transition', {
|
||||
from_screen: previousScreen,
|
||||
to_screen: screenName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static trackUserAction(action: string, context?: Record<string, any>) {
|
||||
AnalyticsService.logEvent('user_action', {
|
||||
action,
|
||||
screen: this.currentScreen,
|
||||
...context,
|
||||
});
|
||||
}
|
||||
|
||||
static trackFeatureUsage(feature: string, parameters?: Record<string, any>) {
|
||||
AnalyticsService.logEvent('feature_usage', {
|
||||
feature_name: feature,
|
||||
screen: this.currentScreen,
|
||||
...parameters,
|
||||
});
|
||||
}
|
||||
|
||||
static trackError(error: string, context?: Record<string, any>) {
|
||||
AnalyticsService.logEvent('app_error', {
|
||||
error_message: error,
|
||||
screen: this.currentScreen,
|
||||
...context,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Donation Tracking
|
||||
|
||||
```typescript
|
||||
// src/utils/donationTracker.ts
|
||||
import AnalyticsService from '@config/analytics.config';
|
||||
|
||||
export class DonationTracker {
|
||||
static trackDonationStart(requestId: string, amount: number) {
|
||||
AnalyticsService.logEvent('donation_start', {
|
||||
request_id: requestId,
|
||||
amount,
|
||||
currency: 'USD',
|
||||
});
|
||||
}
|
||||
|
||||
static trackDonationComplete(
|
||||
requestId: string,
|
||||
amount: number,
|
||||
paymentMethod: string,
|
||||
transactionId: string
|
||||
) {
|
||||
AnalyticsService.logEvent('donation_complete', {
|
||||
request_id: requestId,
|
||||
amount,
|
||||
currency: 'USD',
|
||||
payment_method: paymentMethod,
|
||||
transaction_id: transactionId,
|
||||
});
|
||||
|
||||
// Also log as purchase for e-commerce tracking
|
||||
AnalyticsService.logPurchase(transactionId, amount);
|
||||
}
|
||||
|
||||
static trackDonationFailed(
|
||||
requestId: string,
|
||||
amount: number,
|
||||
errorReason: string
|
||||
) {
|
||||
AnalyticsService.logEvent('donation_failed', {
|
||||
request_id: requestId,
|
||||
amount,
|
||||
currency: 'USD',
|
||||
error_reason: errorReason,
|
||||
});
|
||||
}
|
||||
|
||||
static trackRequestCreated(
|
||||
requestId: string,
|
||||
category: string,
|
||||
targetAmount: number
|
||||
) {
|
||||
AnalyticsService.logEvent('request_created', {
|
||||
request_id: requestId,
|
||||
category,
|
||||
target_amount: targetAmount,
|
||||
currency: 'USD',
|
||||
});
|
||||
}
|
||||
|
||||
static trackRequestViewed(requestId: string, category: string) {
|
||||
AnalyticsService.logEvent('request_viewed', {
|
||||
request_id: requestId,
|
||||
category,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Analytics
|
||||
|
||||
### App Performance Monitoring
|
||||
|
||||
```typescript
|
||||
// src/utils/performanceAnalytics.ts
|
||||
import AnalyticsService from '@config/analytics.config';
|
||||
|
||||
export class PerformanceAnalytics {
|
||||
private static metrics: Map<string, number> = new Map();
|
||||
|
||||
static startTiming(operation: string) {
|
||||
this.metrics.set(operation, Date.now());
|
||||
}
|
||||
|
||||
static endTiming(operation: string) {
|
||||
const startTime = this.metrics.get(operation);
|
||||
if (!startTime) return;
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.metrics.delete(operation);
|
||||
|
||||
AnalyticsService.logEvent('performance_timing', {
|
||||
operation,
|
||||
duration,
|
||||
});
|
||||
|
||||
// Track slow operations
|
||||
if (duration > 3000) {
|
||||
AnalyticsService.logEvent('slow_operation', {
|
||||
operation,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static trackAppLaunchTime(launchTime: number) {
|
||||
AnalyticsService.logEvent('app_launch_time', {
|
||||
launch_time: launchTime,
|
||||
});
|
||||
}
|
||||
|
||||
static trackAPIResponse(endpoint: string, duration: number, status: number) {
|
||||
AnalyticsService.logEvent('api_response', {
|
||||
endpoint,
|
||||
duration,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
static trackMemoryUsage(usedMemory: number, totalMemory: number) {
|
||||
AnalyticsService.logEvent('memory_usage', {
|
||||
used_memory: usedMemory,
|
||||
total_memory: totalMemory,
|
||||
usage_percentage: (usedMemory / totalMemory) * 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## User Behavior Analytics
|
||||
|
||||
### Engagement Tracking
|
||||
|
||||
```typescript
|
||||
// src/utils/engagementTracker.ts
|
||||
import AnalyticsService from '@config/analytics.config';
|
||||
|
||||
export class EngagementTracker {
|
||||
private static sessionEvents: string[] = [];
|
||||
|
||||
static trackAppOpen() {
|
||||
AnalyticsService.logEvent('app_open');
|
||||
this.sessionEvents = [];
|
||||
}
|
||||
|
||||
static trackAppBackground() {
|
||||
AnalyticsService.logEvent('app_background', {
|
||||
session_events: this.sessionEvents.length,
|
||||
events: this.sessionEvents.slice(-10), // Last 10 events
|
||||
});
|
||||
}
|
||||
|
||||
static trackUserEngagement(action: string, value?: number) {
|
||||
this.sessionEvents.push(action);
|
||||
|
||||
AnalyticsService.logEvent('user_engagement', {
|
||||
engagement_time_msec: value || 1000,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
static trackContentInteraction(
|
||||
contentType: string,
|
||||
contentId: string,
|
||||
action: string
|
||||
) {
|
||||
AnalyticsService.logEvent('content_interaction', {
|
||||
content_type: contentType,
|
||||
content_id: contentId,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
static trackSocialShare(platform: string, contentType: string) {
|
||||
AnalyticsService.logEvent('social_share', {
|
||||
platform,
|
||||
content_type: contentType,
|
||||
});
|
||||
}
|
||||
|
||||
static trackSearchUsage(query: string, resultsCount: number) {
|
||||
AnalyticsService.logEvent('search_usage', {
|
||||
search_term: query,
|
||||
results_count: resultsCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## A/B Testing Integration
|
||||
|
||||
### Feature Flag Analytics
|
||||
|
||||
```typescript
|
||||
// src/utils/abTestingTracker.ts
|
||||
import AnalyticsService from '@config/analytics.config';
|
||||
|
||||
export class ABTestingTracker {
|
||||
static trackExperimentExposure(
|
||||
experimentId: string,
|
||||
variantId: string,
|
||||
userId: string
|
||||
) {
|
||||
AnalyticsService.logEvent('experiment_exposure', {
|
||||
experiment_id: experimentId,
|
||||
variant_id: variantId,
|
||||
user_id: userId,
|
||||
});
|
||||
}
|
||||
|
||||
static trackExperimentConversion(
|
||||
experimentId: string,
|
||||
variantId: string,
|
||||
conversionType: string,
|
||||
value?: number
|
||||
) {
|
||||
AnalyticsService.logEvent('experiment_conversion', {
|
||||
experiment_id: experimentId,
|
||||
variant_id: variantId,
|
||||
conversion_type: conversionType,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
static trackFeatureFlagUsage(flagName: string, enabled: boolean) {
|
||||
AnalyticsService.logEvent('feature_flag_usage', {
|
||||
flag_name: flagName,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Dashboards
|
||||
|
||||
### Analytics Dashboard Data
|
||||
|
||||
```typescript
|
||||
// src/utils/analyticsDashboard.ts
|
||||
import AnalyticsService from '@config/analytics.config';
|
||||
|
||||
export class AnalyticsDashboard {
|
||||
static trackKPI(kpiName: string, value: number, unit?: string) {
|
||||
AnalyticsService.logEvent('kpi_metric', {
|
||||
kpi_name: kpiName,
|
||||
value,
|
||||
unit: unit || 'count',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
static trackBusinessMetric(
|
||||
metricName: string,
|
||||
value: number,
|
||||
category: string
|
||||
) {
|
||||
AnalyticsService.logEvent('business_metric', {
|
||||
metric_name: metricName,
|
||||
value,
|
||||
category,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
static trackUserRetention(daysSinceInstall: number, isActive: boolean) {
|
||||
AnalyticsService.logEvent('user_retention', {
|
||||
days_since_install: daysSinceInstall,
|
||||
is_active: isActive,
|
||||
});
|
||||
}
|
||||
|
||||
static trackFunnelStep(funnelName: string, step: string, completed: boolean) {
|
||||
AnalyticsService.logEvent('funnel_step', {
|
||||
funnel_name: funnelName,
|
||||
step,
|
||||
completed,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Privacy Compliance
|
||||
|
||||
### GDPR/CCPA Compliance
|
||||
|
||||
```typescript
|
||||
// src/utils/privacyCompliance.ts
|
||||
import AnalyticsService from '@config/analytics.config';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export class PrivacyCompliance {
|
||||
private static readonly CONSENT_KEY = 'analytics_consent';
|
||||
|
||||
static async hasUserConsent(): Promise<boolean> {
|
||||
try {
|
||||
const consent = await AsyncStorage.getItem(this.CONSENT_KEY);
|
||||
return consent === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async setUserConsent(hasConsent: boolean) {
|
||||
try {
|
||||
await AsyncStorage.setItem(this.CONSENT_KEY, hasConsent.toString());
|
||||
|
||||
if (hasConsent) {
|
||||
await AnalyticsService.initialize();
|
||||
AnalyticsService.logEvent('analytics_consent_granted');
|
||||
} else {
|
||||
await AnalyticsService.setAnalyticsCollectionEnabled(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting analytics consent:', error);
|
||||
}
|
||||
}
|
||||
|
||||
static async revokeConsent() {
|
||||
await this.setUserConsent(false);
|
||||
await AsyncStorage.removeItem(this.CONSENT_KEY);
|
||||
|
||||
// Clear any stored analytics data
|
||||
AnalyticsService.logEvent('analytics_consent_revoked');
|
||||
}
|
||||
|
||||
static trackConsentRequest(source: string) {
|
||||
AnalyticsService.logEvent('consent_request_shown', {
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
static trackConsentResponse(granted: boolean, source: string) {
|
||||
AnalyticsService.logEvent('consent_response', {
|
||||
granted,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Analytics Integration
|
||||
|
||||
### Navigation Analytics
|
||||
|
||||
```typescript
|
||||
// src/navigation/AnalyticsNavigationContainer.tsx
|
||||
import React from 'react';
|
||||
import {NavigationContainer, NavigationState} from '@react-navigation/native';
|
||||
import {UserJourneyTracker} from '@utils/userJourneyTracker';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AnalyticsNavigationContainer: React.FC<Props> = ({children}) => {
|
||||
const routeNameRef = React.useRef<string>();
|
||||
|
||||
const onStateChange = (state: NavigationState | undefined) => {
|
||||
const previousRouteName = routeNameRef.current;
|
||||
const currentRouteName = getActiveRouteName(state);
|
||||
|
||||
if (previousRouteName !== currentRouteName) {
|
||||
UserJourneyTracker.trackScreenView(currentRouteName || 'Unknown');
|
||||
}
|
||||
|
||||
routeNameRef.current = currentRouteName;
|
||||
};
|
||||
|
||||
return (
|
||||
<NavigationContainer onStateChange={onStateChange}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
);
|
||||
};
|
||||
|
||||
function getActiveRouteName(state: NavigationState | undefined): string | undefined {
|
||||
if (!state) return undefined;
|
||||
|
||||
const route = state.routes[state.index];
|
||||
|
||||
if (route.state) {
|
||||
return getActiveRouteName(route.state as NavigationState);
|
||||
}
|
||||
|
||||
return route.name;
|
||||
}
|
||||
```
|
||||
|
||||
### Component Analytics HOC
|
||||
|
||||
```typescript
|
||||
// src/hoc/withAnalytics.tsx
|
||||
import React, {useEffect} from 'react';
|
||||
import {UserJourneyTracker} from '@utils/userJourneyTracker';
|
||||
|
||||
interface AnalyticsProps {
|
||||
screenName?: string;
|
||||
trackProps?: string[];
|
||||
}
|
||||
|
||||
export function withAnalytics<P extends object>(
|
||||
WrappedComponent: React.ComponentType<P>,
|
||||
analyticsProps: AnalyticsProps
|
||||
) {
|
||||
return function AnalyticsComponent(props: P) {
|
||||
useEffect(() => {
|
||||
if (analyticsProps.screenName) {
|
||||
UserJourneyTracker.trackScreenView(analyticsProps.screenName);
|
||||
}
|
||||
|
||||
// Track specific props if specified
|
||||
if (analyticsProps.trackProps) {
|
||||
const trackedData = analyticsProps.trackProps.reduce((acc, propName) => {
|
||||
if (propName in props) {
|
||||
acc[propName] = (props as any)[propName];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
if (Object.keys(trackedData).length > 0) {
|
||||
UserJourneyTracker.trackUserAction('component_rendered', trackedData);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <WrappedComponent {...props} />;
|
||||
};
|
||||
}
|
||||
|
||||
// Usage example:
|
||||
// export default withAnalytics(MyComponent, {
|
||||
// screenName: 'MyScreen',
|
||||
// trackProps: ['userId', 'category']
|
||||
// });
|
||||
```
|
|
@ -0,0 +1,612 @@
|
|||
# Crash Monitoring
|
||||
|
||||
## Bugsnag Setup
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
yarn add @bugsnag/react-native
|
||||
yarn add @bugsnag/react-native-cli
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
**bugsnag.config.js:**
|
||||
```javascript
|
||||
import Bugsnag from '@bugsnag/react-native';
|
||||
import Config from 'react-native-config';
|
||||
|
||||
Bugsnag.start({
|
||||
apiKey: Config.BUGSNAG_API_KEY,
|
||||
appVersion: Config.APP_VERSION,
|
||||
releaseStage: Config.APP_ENV,
|
||||
enabledReleaseStages: ['production', 'staging'],
|
||||
autoDetectErrors: true,
|
||||
autoCaptureSessions: true,
|
||||
enabledErrorTypes: {
|
||||
unhandledExceptions: true,
|
||||
unhandledRejections: true,
|
||||
nativeCrashes: true,
|
||||
},
|
||||
onError: (event) => {
|
||||
// Add custom metadata
|
||||
event.addMetadata('app', {
|
||||
buildNumber: Config.BUILD_NUMBER,
|
||||
environment: Config.APP_ENV,
|
||||
});
|
||||
|
||||
// Filter sensitive data
|
||||
event.request.url = event.request.url?.replace(/token=[^&]+/, 'token=***');
|
||||
|
||||
return true;
|
||||
},
|
||||
onSession: (session) => {
|
||||
// Add user context
|
||||
session.user = {
|
||||
id: 'user-id',
|
||||
name: 'User Name',
|
||||
email: 'user@example.com',
|
||||
};
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export default Bugsnag;
|
||||
```
|
||||
|
||||
### Integration
|
||||
|
||||
**App.tsx:**
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import Bugsnag from './src/config/bugsnag.config';
|
||||
import {AppNavigator} from './src/navigation/AppNavigator';
|
||||
|
||||
const App: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
// Set user context when available
|
||||
Bugsnag.setUser('user-123', 'john@example.com', 'John Doe');
|
||||
|
||||
// Leave breadcrumb for app start
|
||||
Bugsnag.leaveBreadcrumb('App started');
|
||||
}, []);
|
||||
|
||||
return <AppNavigator />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
## Firebase Crashlytics
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
yarn add @react-native-firebase/app
|
||||
yarn add @react-native-firebase/crashlytics
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
**firebase.config.js:**
|
||||
```typescript
|
||||
import crashlytics from '@react-native-firebase/crashlytics';
|
||||
import Config from 'react-native-config';
|
||||
|
||||
class CrashlyticsService {
|
||||
static initialize() {
|
||||
if (__DEV__) {
|
||||
// Disable crashlytics in development
|
||||
crashlytics().setCrashlyticsCollectionEnabled(false);
|
||||
} else {
|
||||
crashlytics().setCrashlyticsCollectionEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
static setUserId(userId: string) {
|
||||
crashlytics().setUserId(userId);
|
||||
}
|
||||
|
||||
static setUserAttributes(attributes: Record<string, string>) {
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
crashlytics().setAttribute(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
static logError(error: Error, context?: Record<string, any>) {
|
||||
if (context) {
|
||||
Object.entries(context).forEach(([key, value]) => {
|
||||
crashlytics().setAttribute(key, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
crashlytics().recordError(error);
|
||||
}
|
||||
|
||||
static log(message: string) {
|
||||
crashlytics().log(message);
|
||||
}
|
||||
|
||||
static crash() {
|
||||
crashlytics().crash();
|
||||
}
|
||||
}
|
||||
|
||||
export default CrashlyticsService;
|
||||
```
|
||||
|
||||
## Error Boundary
|
||||
|
||||
### React Error Boundary
|
||||
|
||||
```typescript
|
||||
// src/components/ErrorBoundary/ErrorBoundary.tsx
|
||||
import React, {Component, ErrorInfo, ReactNode} from 'react';
|
||||
import {View, Text, TouchableOpacity, StyleSheet} from 'react-native';
|
||||
import Bugsnag from '@config/bugsnag.config';
|
||||
import CrashlyticsService from '@config/firebase.config';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {hasError: false};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {hasError: true, error};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// Log to crash reporting services
|
||||
Bugsnag.notify(error, (event) => {
|
||||
event.addMetadata('errorBoundary', {
|
||||
componentStack: errorInfo.componentStack,
|
||||
errorBoundary: true,
|
||||
});
|
||||
});
|
||||
|
||||
CrashlyticsService.logError(error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
errorBoundary: 'true',
|
||||
});
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({hasError: false, error: undefined});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Oops! Something went wrong</Text>
|
||||
<Text style={styles.message}>
|
||||
We're sorry for the inconvenience. The error has been reported and we're working on a fix.
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.button} onPress={this.handleRetry}>
|
||||
<Text style={styles.buttonText}>Try Again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
color: '#333',
|
||||
},
|
||||
message: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
color: '#666',
|
||||
lineHeight: 24,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Global Error Handler
|
||||
|
||||
### Unhandled Promise Rejections
|
||||
|
||||
```typescript
|
||||
// src/utils/errorHandler.ts
|
||||
import Bugsnag from '@config/bugsnag.config';
|
||||
import CrashlyticsService from '@config/firebase.config';
|
||||
|
||||
export class GlobalErrorHandler {
|
||||
static initialize() {
|
||||
// Handle unhandled promise rejections
|
||||
const originalHandler = global.Promise.prototype.catch;
|
||||
global.Promise.prototype.catch = function(onRejected) {
|
||||
return originalHandler.call(this, (error) => {
|
||||
GlobalErrorHandler.handleError(error, 'unhandledRejection');
|
||||
if (onRejected) {
|
||||
return onRejected(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle uncaught exceptions
|
||||
if (global.ErrorUtils) {
|
||||
const originalGlobalHandler = global.ErrorUtils.getGlobalHandler();
|
||||
global.ErrorUtils.setGlobalHandler((error, isFatal) => {
|
||||
GlobalErrorHandler.handleError(error, isFatal ? 'fatal' : 'nonfatal');
|
||||
originalGlobalHandler(error, isFatal);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static handleError(error: Error, type: string) {
|
||||
console.error(`${type} error:`, error);
|
||||
|
||||
// Log to crash reporting services
|
||||
Bugsnag.notify(error, (event) => {
|
||||
event.addMetadata('error', {
|
||||
type,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
CrashlyticsService.logError(error, {
|
||||
errorType: type,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
static logError(error: Error, context?: Record<string, any>) {
|
||||
this.handleError(error, 'manual');
|
||||
|
||||
if (context) {
|
||||
Bugsnag.addMetadata('context', context);
|
||||
CrashlyticsService.setUserAttributes(
|
||||
Object.fromEntries(
|
||||
Object.entries(context).map(([k, v]) => [k, String(v)])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Network Error Monitoring
|
||||
|
||||
### API Error Tracking
|
||||
|
||||
```typescript
|
||||
// src/services/errorTrackingAPI.ts
|
||||
import {AxiosError, AxiosResponse} from 'axios';
|
||||
import Bugsnag from '@config/bugsnag.config';
|
||||
import CrashlyticsService from '@config/firebase.config';
|
||||
|
||||
export class APIErrorTracker {
|
||||
static trackRequest(config: any) {
|
||||
Bugsnag.leaveBreadcrumb(`API Request: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
CrashlyticsService.log(`API Request: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
}
|
||||
|
||||
static trackResponse(response: AxiosResponse) {
|
||||
const {status, config} = response;
|
||||
Bugsnag.leaveBreadcrumb(`API Response: ${status} ${config.url}`);
|
||||
CrashlyticsService.log(`API Response: ${status} ${config.url}`);
|
||||
}
|
||||
|
||||
static trackError(error: AxiosError) {
|
||||
const {response, config, message} = error;
|
||||
|
||||
const errorData = {
|
||||
url: config?.url,
|
||||
method: config?.method,
|
||||
status: response?.status,
|
||||
statusText: response?.statusText,
|
||||
message,
|
||||
responseData: response?.data,
|
||||
};
|
||||
|
||||
// Log to crash reporting
|
||||
Bugsnag.notify(error, (event) => {
|
||||
event.addMetadata('apiError', errorData);
|
||||
event.severity = response?.status && response.status >= 500 ? 'error' : 'warning';
|
||||
});
|
||||
|
||||
CrashlyticsService.logError(error, {
|
||||
apiUrl: config?.url || 'unknown',
|
||||
apiMethod: config?.method || 'unknown',
|
||||
apiStatus: String(response?.status || 0),
|
||||
});
|
||||
|
||||
console.error('API Error:', errorData);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
```typescript
|
||||
// src/utils/performanceMonitor.ts
|
||||
import Bugsnag from '@config/bugsnag.config';
|
||||
import CrashlyticsService from '@config/firebase.config';
|
||||
|
||||
export class PerformanceMonitor {
|
||||
private static metrics: Map<string, number> = new Map();
|
||||
|
||||
static startTiming(label: string) {
|
||||
this.metrics.set(label, Date.now());
|
||||
CrashlyticsService.log(`Performance: Started ${label}`);
|
||||
}
|
||||
|
||||
static endTiming(label: string): number {
|
||||
const startTime = this.metrics.get(label);
|
||||
if (!startTime) {
|
||||
console.warn(`No start time found for ${label}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.metrics.delete(label);
|
||||
|
||||
// Log performance metrics
|
||||
Bugsnag.leaveBreadcrumb(`Performance: ${label} took ${duration}ms`);
|
||||
CrashlyticsService.log(`Performance: ${label} took ${duration}ms`);
|
||||
|
||||
// Alert on slow operations
|
||||
if (duration > 5000) {
|
||||
Bugsnag.notify(new Error(`Slow operation: ${label}`), (event) => {
|
||||
event.severity = 'warning';
|
||||
event.addMetadata('performance', {
|
||||
operation: label,
|
||||
duration,
|
||||
threshold: 5000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
static measureAsync<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
this.startTiming(label);
|
||||
return fn().finally(() => {
|
||||
this.endTiming(label);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Crash Reports
|
||||
|
||||
### User Feedback Integration
|
||||
|
||||
```typescript
|
||||
// src/components/FeedbackModal/FeedbackModal.tsx
|
||||
import React, {useState} from 'react';
|
||||
import {Modal, View, Text, TextInput, TouchableOpacity, StyleSheet} from 'react-native';
|
||||
import Bugsnag from '@config/bugsnag.config';
|
||||
|
||||
interface FeedbackModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export const FeedbackModal: React.FC<FeedbackModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
error,
|
||||
}) => {
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (error) {
|
||||
Bugsnag.notify(error, (event) => {
|
||||
event.addMetadata('userFeedback', {
|
||||
feedback,
|
||||
email,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
Bugsnag.leaveBreadcrumb('User feedback submitted', {
|
||||
feedback,
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
setFeedback('');
|
||||
setEmail('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide">
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Help us improve</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Tell us what happened so we can fix the issue
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Your email (optional)"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
placeholder="Describe what you were doing when the error occurred"
|
||||
value={feedback}
|
||||
onChangeText={setFeedback}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity style={styles.cancelButton} onPress={onClose}>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.submitButton} onPress={handleSubmit}>
|
||||
<Text style={styles.submitText}>Send Feedback</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
width: '90%',
|
||||
maxWidth: 400,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E5E7',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
marginBottom: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
height: 80,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 20,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
marginRight: 8,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E5E7',
|
||||
},
|
||||
submitButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
marginLeft: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
cancelText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
submitText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Crash Report Analysis
|
||||
|
||||
### Automated Alerts
|
||||
|
||||
```typescript
|
||||
// src/utils/crashAlerts.ts
|
||||
export class CrashAlerts {
|
||||
static setupAlerts() {
|
||||
// Configure Bugsnag alerts
|
||||
// This would typically be done in the Bugsnag dashboard
|
||||
|
||||
// High-priority alerts:
|
||||
// - App crashes affecting > 1% of users
|
||||
// - New error types
|
||||
// - Errors in critical user flows (login, payment)
|
||||
|
||||
// Medium-priority alerts:
|
||||
// - Performance degradation
|
||||
// - API errors > 5% error rate
|
||||
// - Memory warnings
|
||||
}
|
||||
|
||||
static generateCrashReport(timeframe: string = '24h') {
|
||||
// This would integrate with Bugsnag API to generate reports
|
||||
return {
|
||||
totalCrashes: 0,
|
||||
uniqueErrors: 0,
|
||||
affectedUsers: 0,
|
||||
topErrors: [],
|
||||
performanceMetrics: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
|
@ -0,0 +1,580 @@
|
|||
# OTA Updates with CodePush
|
||||
|
||||
## CodePush Setup
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install CodePush CLI
|
||||
npm install -g code-push-cli
|
||||
|
||||
# Install React Native CodePush
|
||||
yarn add react-native-code-push
|
||||
```
|
||||
|
||||
### App Center Configuration
|
||||
|
||||
```bash
|
||||
# Login to App Center
|
||||
code-push login
|
||||
|
||||
# Create apps
|
||||
code-push app add SaayamApp-iOS ios react-native
|
||||
code-push app add SaayamApp-Android android react-native
|
||||
|
||||
# Get deployment keys
|
||||
code-push deployment ls SaayamApp-iOS -k
|
||||
code-push deployment ls SaayamApp-Android -k
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
**.env files:**
|
||||
|
||||
```bash
|
||||
# .env.staging
|
||||
CODEPUSH_DEPLOYMENT_KEY_IOS=staging-ios-key
|
||||
CODEPUSH_DEPLOYMENT_KEY_ANDROID=staging-android-key
|
||||
|
||||
# .env.production
|
||||
CODEPUSH_DEPLOYMENT_KEY_IOS=production-ios-key
|
||||
CODEPUSH_DEPLOYMENT_KEY_ANDROID=production-android-key
|
||||
```
|
||||
|
||||
## CodePush Integration
|
||||
|
||||
### App Configuration
|
||||
|
||||
**App.tsx:**
|
||||
|
||||
```typescript
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, Platform } from "react-native";
|
||||
import codePush from "react-native-code-push";
|
||||
import Config from "react-native-config";
|
||||
import { AppNavigator } from "./src/navigation/AppNavigator";
|
||||
import { LoadingScreen } from "./src/components/LoadingScreen";
|
||||
|
||||
const codePushOptions = {
|
||||
checkFrequency: codePush.CheckFrequency.ON_APP_RESUME,
|
||||
installMode: codePush.InstallMode.ON_NEXT_RESUME,
|
||||
mandatoryInstallMode: codePush.InstallMode.IMMEDIATE,
|
||||
deploymentKey: Platform.select({
|
||||
ios: Config.CODEPUSH_DEPLOYMENT_KEY_IOS,
|
||||
android: Config.CODEPUSH_DEPLOYMENT_KEY_ANDROID,
|
||||
}),
|
||||
updateDialog: {
|
||||
appendReleaseDescription: true,
|
||||
descriptionPrefix: "\n\nWhat's New:\n",
|
||||
mandatoryContinueButtonLabel: "Install Now",
|
||||
mandatoryUpdateMessage:
|
||||
"A critical update is available and must be installed.",
|
||||
optionalIgnoreButtonLabel: "Later",
|
||||
optionalInstallButtonLabel: "Install",
|
||||
optionalUpdateMessage:
|
||||
"An update is available. Would you like to install it?",
|
||||
title: "Update Available",
|
||||
},
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [syncInProgress, setSyncInProgress] = useState(false);
|
||||
const [syncMessage, setSyncMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
codePush.notifyAppReady();
|
||||
}, []);
|
||||
|
||||
const codePushStatusDidChange = (syncStatus: codePush.SyncStatus) => {
|
||||
switch (syncStatus) {
|
||||
case codePush.SyncStatus.CHECKING_FOR_UPDATE:
|
||||
setSyncMessage("Checking for updates...");
|
||||
break;
|
||||
case codePush.SyncStatus.DOWNLOADING_PACKAGE:
|
||||
setSyncMessage("Downloading update...");
|
||||
setSyncInProgress(true);
|
||||
break;
|
||||
case codePush.SyncStatus.INSTALLING_UPDATE:
|
||||
setSyncMessage("Installing update...");
|
||||
break;
|
||||
case codePush.SyncStatus.UP_TO_DATE:
|
||||
setSyncMessage("App is up to date");
|
||||
setSyncInProgress(false);
|
||||
break;
|
||||
case codePush.SyncStatus.UPDATE_INSTALLED:
|
||||
setSyncMessage("Update installed successfully");
|
||||
setSyncInProgress(false);
|
||||
break;
|
||||
default:
|
||||
setSyncInProgress(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const codePushDownloadDidProgress = (progress: codePush.DownloadProgress) => {
|
||||
const percentage = Math.round(
|
||||
(progress.receivedBytes / progress.totalBytes) * 100
|
||||
);
|
||||
setSyncMessage(`Downloading update... ${percentage}%`);
|
||||
};
|
||||
|
||||
if (syncInProgress) {
|
||||
return <LoadingScreen message={syncMessage} />;
|
||||
}
|
||||
|
||||
return <AppNavigator />;
|
||||
};
|
||||
|
||||
export default codePush(codePushOptions)(App);
|
||||
```
|
||||
|
||||
### Manual Update Check
|
||||
|
||||
```typescript
|
||||
// src/utils/updateManager.ts
|
||||
import codePush from "react-native-code-push";
|
||||
import { Alert } from "react-native";
|
||||
|
||||
export class UpdateManager {
|
||||
static async checkForUpdate(): Promise<void> {
|
||||
try {
|
||||
const update = await codePush.checkForUpdate();
|
||||
|
||||
if (update) {
|
||||
Alert.alert(
|
||||
"Update Available",
|
||||
`Version ${update.appVersion} is available. Would you like to install it now?`,
|
||||
[
|
||||
{ text: "Later", style: "cancel" },
|
||||
{ text: "Install", onPress: () => this.downloadAndInstall() },
|
||||
]
|
||||
);
|
||||
} else {
|
||||
Alert.alert("No Updates", "Your app is up to date!");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking for updates:", error);
|
||||
}
|
||||
}
|
||||
|
||||
static async downloadAndInstall(): Promise<void> {
|
||||
try {
|
||||
await codePush.sync({
|
||||
installMode: codePush.InstallMode.IMMEDIATE,
|
||||
updateDialog: {
|
||||
appendReleaseDescription: true,
|
||||
descriptionPrefix: "\n\nChanges:\n",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error downloading update:", error);
|
||||
Alert.alert(
|
||||
"Update Failed",
|
||||
"Failed to download update. Please try again later."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static async getUpdateMetadata(): Promise<codePush.LocalPackage | null> {
|
||||
try {
|
||||
return await codePush.getUpdateMetadata();
|
||||
} catch (error) {
|
||||
console.error("Error getting update metadata:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async rollback(): Promise<void> {
|
||||
try {
|
||||
await codePush.restartApp();
|
||||
} catch (error) {
|
||||
console.error("Error rolling back:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment Strategies
|
||||
|
||||
### Staged Rollout
|
||||
|
||||
```bash
|
||||
# Deploy to staging first
|
||||
code-push release-react SaayamApp-iOS ios --deploymentName Staging
|
||||
|
||||
# Test staging deployment
|
||||
# If successful, promote to production with rollout percentage
|
||||
|
||||
# Deploy to 10% of production users
|
||||
code-push release-react SaayamApp-iOS ios --deploymentName Production --rollout 10%
|
||||
|
||||
# Monitor metrics and gradually increase rollout
|
||||
code-push patch SaayamApp-iOS Production --rollout 25%
|
||||
code-push patch SaayamApp-iOS Production --rollout 50%
|
||||
code-push patch SaayamApp-iOS Production --rollout 100%
|
||||
```
|
||||
|
||||
### Automated Deployment
|
||||
|
||||
**scripts/deploy-codepush.js:**
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const platform = process.argv[2]; // ios or android
|
||||
const environment = process.argv[3] || "staging"; // staging or production
|
||||
const rollout = process.argv[4] || "100"; // rollout percentage
|
||||
|
||||
if (!platform || !["ios", "android"].includes(platform)) {
|
||||
console.error(
|
||||
"Usage: node deploy-codepush.js <ios|android> [staging|production] [rollout%]"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const appName = platform === "ios" ? "SaayamApp-iOS" : "SaayamApp-Android";
|
||||
const deploymentName = environment === "production" ? "Production" : "Staging";
|
||||
|
||||
// Get version from package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
||||
const version = packageJson.version;
|
||||
|
||||
// Generate release notes from git commits
|
||||
const releaseNotes = execSync("git log --oneline -10", { encoding: "utf8" })
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => `• ${line.split(" ").slice(1).join(" ")}`)
|
||||
.join("\n");
|
||||
|
||||
console.log(`Deploying to ${appName} ${deploymentName}...`);
|
||||
console.log(`Version: ${version}`);
|
||||
console.log(`Rollout: ${rollout}%`);
|
||||
|
||||
try {
|
||||
const command = [
|
||||
"code-push release-react",
|
||||
appName,
|
||||
platform,
|
||||
`--deploymentName ${deploymentName}`,
|
||||
`--description "${releaseNotes}"`,
|
||||
`--rollout ${rollout}%`,
|
||||
"--mandatory false",
|
||||
].join(" ");
|
||||
|
||||
execSync(command, { stdio: "inherit" });
|
||||
|
||||
console.log(`✅ Successfully deployed to ${appName} ${deploymentName}`);
|
||||
|
||||
// Send notification (Slack, email, etc.)
|
||||
notifyDeployment(appName, deploymentName, version, rollout);
|
||||
} catch (error) {
|
||||
console.error("❌ Deployment failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function notifyDeployment(appName, deployment, version, rollout) {
|
||||
// Implementation for sending notifications
|
||||
console.log(
|
||||
`📱 ${appName} ${deployment} v${version} deployed to ${rollout}% of users`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Rollback Strategy
|
||||
|
||||
```bash
|
||||
# Check deployment history
|
||||
code-push deployment history SaayamApp-iOS Production
|
||||
|
||||
# Rollback to previous version
|
||||
code-push rollback SaayamApp-iOS Production
|
||||
|
||||
# Rollback to specific label
|
||||
code-push rollback SaayamApp-iOS Production --targetRelease v1.2.3
|
||||
```
|
||||
|
||||
## Update Policies
|
||||
|
||||
### Update Configuration
|
||||
|
||||
```typescript
|
||||
// src/config/updatePolicy.ts
|
||||
import codePush from "react-native-code-push";
|
||||
|
||||
export const UpdatePolicy = {
|
||||
// Check for updates when app resumes from background
|
||||
checkFrequency: codePush.CheckFrequency.ON_APP_RESUME,
|
||||
|
||||
// Install updates on next app restart (non-disruptive)
|
||||
installMode: codePush.InstallMode.ON_NEXT_RESTART,
|
||||
|
||||
// For mandatory updates, install immediately
|
||||
mandatoryInstallMode: codePush.InstallMode.IMMEDIATE,
|
||||
|
||||
// Minimum background duration before checking (in seconds)
|
||||
minimumBackgroundDuration: 60,
|
||||
|
||||
// Update dialog configuration
|
||||
updateDialog: {
|
||||
appendReleaseDescription: true,
|
||||
descriptionPrefix: "\n\nWhat's New:\n",
|
||||
mandatoryContinueButtonLabel: "Install Now",
|
||||
mandatoryUpdateMessage:
|
||||
"A critical update is required to continue using the app.",
|
||||
optionalIgnoreButtonLabel: "Not Now",
|
||||
optionalInstallButtonLabel: "Install",
|
||||
optionalUpdateMessage:
|
||||
"An update is available with new features and improvements.",
|
||||
title: "Update Available",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Conditional Updates
|
||||
|
||||
```typescript
|
||||
// src/utils/conditionalUpdates.ts
|
||||
import codePush from "react-native-code-push";
|
||||
import { Platform } from "react-native";
|
||||
import DeviceInfo from "react-native-device-info";
|
||||
|
||||
export class ConditionalUpdates {
|
||||
static async shouldCheckForUpdate(): Promise<boolean> {
|
||||
// Don't check for updates in development
|
||||
if (__DEV__) return false;
|
||||
|
||||
// Check device conditions
|
||||
const batteryLevel = await DeviceInfo.getBatteryLevel();
|
||||
const isCharging = await DeviceInfo.isBatteryCharging();
|
||||
const connectionType = await DeviceInfo.getConnectionType();
|
||||
|
||||
// Only update if:
|
||||
// - Battery > 20% OR device is charging
|
||||
// - Connected to WiFi (for large updates)
|
||||
const batteryOk = batteryLevel > 0.2 || isCharging;
|
||||
const connectionOk = connectionType === "wifi";
|
||||
|
||||
return batteryOk && connectionOk;
|
||||
}
|
||||
|
||||
static async checkForUpdateWithConditions(): Promise<void> {
|
||||
const shouldCheck = await this.shouldCheckForUpdate();
|
||||
|
||||
if (!shouldCheck) {
|
||||
console.log("Skipping update check due to device conditions");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const update = await codePush.checkForUpdate();
|
||||
|
||||
if (update) {
|
||||
// Check update size
|
||||
const updateSizeMB = (update.packageSize || 0) / (1024 * 1024);
|
||||
|
||||
if (updateSizeMB > 10) {
|
||||
// Large update - require WiFi and user confirmation
|
||||
this.promptForLargeUpdate(update, updateSizeMB);
|
||||
} else {
|
||||
// Small update - download automatically
|
||||
this.downloadUpdate(update);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking for conditional update:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static promptForLargeUpdate(
|
||||
update: codePush.RemotePackage,
|
||||
sizeMB: number
|
||||
) {
|
||||
// Show custom dialog for large updates
|
||||
// Implementation depends on your UI framework
|
||||
}
|
||||
|
||||
private static async downloadUpdate(update: codePush.RemotePackage) {
|
||||
try {
|
||||
await update.download();
|
||||
await update.install(codePush.InstallMode.ON_NEXT_RESTART);
|
||||
} catch (error) {
|
||||
console.error("Error downloading update:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring & Analytics
|
||||
|
||||
### Update Analytics
|
||||
|
||||
```typescript
|
||||
// src/utils/updateAnalytics.ts
|
||||
import AnalyticsService from "@config/analytics.config";
|
||||
import codePush from "react-native-code-push";
|
||||
|
||||
export class UpdateAnalytics {
|
||||
static trackUpdateCheck() {
|
||||
AnalyticsService.logEvent("codepush_check_for_update");
|
||||
}
|
||||
|
||||
static trackUpdateAvailable(update: codePush.RemotePackage) {
|
||||
AnalyticsService.logEvent("codepush_update_available", {
|
||||
app_version: update.appVersion,
|
||||
package_size: update.packageSize,
|
||||
is_mandatory: update.isMandatory,
|
||||
deployment_key: update.deploymentKey,
|
||||
});
|
||||
}
|
||||
|
||||
static trackUpdateDownloadStart(update: codePush.RemotePackage) {
|
||||
AnalyticsService.logEvent("codepush_download_start", {
|
||||
app_version: update.appVersion,
|
||||
package_size: update.packageSize,
|
||||
});
|
||||
}
|
||||
|
||||
static trackUpdateDownloadComplete(update: codePush.LocalPackage) {
|
||||
AnalyticsService.logEvent("codepush_download_complete", {
|
||||
app_version: update.appVersion,
|
||||
package_size: update.packageSize,
|
||||
});
|
||||
}
|
||||
|
||||
static trackUpdateInstalled(update: codePush.LocalPackage) {
|
||||
AnalyticsService.logEvent("codepush_update_installed", {
|
||||
app_version: update.appVersion,
|
||||
install_mode: update.installMode,
|
||||
});
|
||||
}
|
||||
|
||||
static trackUpdateFailed(error: Error, context?: string) {
|
||||
AnalyticsService.logEvent("codepush_update_failed", {
|
||||
error_message: error.message,
|
||||
context: context || "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
static trackRollback(reason: string) {
|
||||
AnalyticsService.logEvent("codepush_rollback", {
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Health Monitoring
|
||||
|
||||
```typescript
|
||||
// src/utils/updateHealthMonitor.ts
|
||||
import codePush from "react-native-code-push";
|
||||
import CrashlyticsService from "@config/firebase.config";
|
||||
|
||||
export class UpdateHealthMonitor {
|
||||
private static readonly CRASH_THRESHOLD = 3;
|
||||
private static crashCount = 0;
|
||||
|
||||
static async monitorUpdateHealth() {
|
||||
try {
|
||||
const updateMetadata = await codePush.getUpdateMetadata();
|
||||
|
||||
if (updateMetadata && updateMetadata.isFirstRun) {
|
||||
// This is the first run after an update
|
||||
this.trackFirstRunAfterUpdate(updateMetadata);
|
||||
|
||||
// Start monitoring for crashes
|
||||
this.startCrashMonitoring();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error monitoring update health:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static trackFirstRunAfterUpdate(update: codePush.LocalPackage) {
|
||||
CrashlyticsService.log(
|
||||
`First run after CodePush update: ${update.appVersion}`
|
||||
);
|
||||
|
||||
// Set custom attributes for crash reporting
|
||||
CrashlyticsService.setUserAttributes({
|
||||
codepush_version: update.appVersion || "unknown",
|
||||
codepush_label: update.label || "unknown",
|
||||
is_codepush_update: "true",
|
||||
});
|
||||
}
|
||||
|
||||
private static startCrashMonitoring() {
|
||||
// Monitor app crashes for the next 24 hours
|
||||
const monitoringDuration = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
setTimeout(() => {
|
||||
this.evaluateUpdateStability();
|
||||
}, monitoringDuration);
|
||||
}
|
||||
|
||||
private static async evaluateUpdateStability() {
|
||||
if (this.crashCount >= this.CRASH_THRESHOLD) {
|
||||
console.warn(
|
||||
`Update appears unstable (${this.crashCount} crashes). Consider rollback.`
|
||||
);
|
||||
|
||||
// Optionally trigger automatic rollback
|
||||
// await this.performAutomaticRollback();
|
||||
} else {
|
||||
console.log("Update appears stable");
|
||||
CrashlyticsService.log("CodePush update validated as stable");
|
||||
}
|
||||
}
|
||||
|
||||
private static async performAutomaticRollback() {
|
||||
try {
|
||||
await codePush.restartApp();
|
||||
CrashlyticsService.log("Automatic rollback performed due to instability");
|
||||
} catch (error) {
|
||||
console.error("Failed to perform automatic rollback:", error);
|
||||
}
|
||||
}
|
||||
|
||||
static reportCrash() {
|
||||
this.crashCount++;
|
||||
CrashlyticsService.log(`Crash reported. Count: ${this.crashCount}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Update Guidelines
|
||||
|
||||
1. **Test Thoroughly**
|
||||
|
||||
- Test updates on staging environment
|
||||
- Verify on multiple devices and OS versions
|
||||
- Test offline scenarios
|
||||
|
||||
2. **Gradual Rollout**
|
||||
|
||||
- Start with small percentage (5-10%)
|
||||
- Monitor crash rates and user feedback
|
||||
- Gradually increase rollout
|
||||
|
||||
3. **Rollback Strategy**
|
||||
|
||||
- Have rollback plan ready
|
||||
- Monitor key metrics after deployment
|
||||
- Set up automated alerts for issues
|
||||
|
||||
4. **Update Frequency**
|
||||
|
||||
- Don't update too frequently (user fatigue)
|
||||
- Bundle related changes together
|
||||
- Consider user timezone for deployments
|
||||
|
||||
5. **Communication**
|
||||
- Provide clear release notes
|
||||
- Notify users of important changes
|
||||
- Use appropriate update messaging
|
46
README.md
46
README.md
|
@ -2,12 +2,46 @@
|
|||
|
||||
## Complete Guide from Setup to Production Release
|
||||
|
||||
_Version 1.0 | Last Updated: December 2024_
|
||||
_Created by Drashti Patel_
|
||||
*Version 1.0 | Last Updated: December 2024*
|
||||
|
||||
Welcome to the comprehensive React Native Development Standard Operating Procedure (SOP). This documentation provides a complete guide for building React Native applications from scratch, covering everything from initial environment setup to production release and post-launch maintenance.
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
This SOP is designed for developers at all levels and covers:
|
||||
|
||||
- **Environment Setup**: Complete development environment configuration for Mac, Windows, and Linux
|
||||
- **Project Architecture**: Best practices for structuring React Native applications
|
||||
- **Development Standards**: Code quality, testing, and security guidelines
|
||||
- **Production Deployment**: Step-by-step release process for both Android and iOS
|
||||
- **Maintenance**: Post-release monitoring, updates, and optimization
|
||||
|
||||
## Who This Is For
|
||||
|
||||
- **Beginner Developers**: Step-by-step instructions to get started with React Native
|
||||
- **Experienced Teams**: Enterprise-level standards and best practices
|
||||
- **Project Managers**: Understanding of the complete development lifecycle
|
||||
- **DevOps Engineers**: CI/CD pipeline setup and deployment processes
|
||||
|
||||
## Key Features
|
||||
|
||||
✅ **Comprehensive Coverage**: From setup to production release
|
||||
✅ **Platform Support**: Android and iOS deployment guides
|
||||
✅ **Code Quality**: ESLint, Prettier, and testing configurations
|
||||
✅ **Security**: Best practices for secure mobile app development
|
||||
✅ **Performance**: Optimization techniques and monitoring
|
||||
✅ **Automation**: CI/CD pipelines and automated testing
|
||||
|
||||
## Getting Started
|
||||
|
||||
Begin with the [Requirements & Environment Setup](01-requirements/environment-setup.md) section to configure your development environment, then follow the guide sequentially for the best learning experience.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Basic knowledge of JavaScript/TypeScript
|
||||
- Familiarity with React concepts
|
||||
- Understanding of mobile app development concepts
|
||||
|
||||
---
|
||||
|
||||
This GitBook provides a **complete SOP** for building, testing, deploying, and maintaining React Native applications.
|
||||
It covers everything from environment setup to production release and ongoing maintenance.
|
||||
|
||||
Use the sidebar to navigate through each stage of the development lifecycle.
|
||||
*This SOP serves as the single source of truth for React Native development standards and can be adapted to fit specific project requirements while maintaining core principles of quality, security, and maintainability.*
|
|
@ -0,0 +1,40 @@
|
|||
# Table of Contents
|
||||
|
||||
- [Introduction](README.md)
|
||||
|
||||
## Getting Started
|
||||
|
||||
- [Requirements & Environment Setup](01-requirements/environment-setup.md)
|
||||
- [Project Initialization](02-project-initialization/project-setup.md)
|
||||
|
||||
## Development
|
||||
|
||||
- [Development Standards & Guidelines](03-development-standards/coding-guidelines.md)
|
||||
- [Components](03-development-standards/components.md)
|
||||
- [VSCode Snippets](03-development-standards/vscode-snippets.md)
|
||||
- [Design Integration & Assets](04-design-integration/design-system.md)
|
||||
- [Asset Management](04-design-integration/assets.md)
|
||||
- [Icons & Fonts](04-design-integration/icons-fonts.md)
|
||||
- [State Management & Navigation](05-state-management-navigation/redux-setup.md)
|
||||
- [Navigation Setup](05-state-management-navigation/navigation.md)
|
||||
- [API Integration](05-state-management-navigation/api-integration.md)
|
||||
|
||||
## Quality Assurance
|
||||
|
||||
- [Testing & Quality Assurance](06-testing-quality-assurance/unit-testing.md)
|
||||
- [E2E Testing](06-testing-quality-assurance/e2e-testing.md)
|
||||
- [Performance Optimization](07-performance-optimization/optimization.md)
|
||||
- [Security](08-security/secure-storage.md)
|
||||
|
||||
## Deployment
|
||||
|
||||
- [CI/CD Pipeline](09-ci-cd-pipeline/github-actions-fastlane.md)
|
||||
- [Release Process](10-release-process/android-release.md)
|
||||
- [iOS Release](10-release-process/ios-release.md)
|
||||
- [Version Management](10-release-process/versioning.md)
|
||||
|
||||
## Maintenance
|
||||
|
||||
- [Post-Release Maintenance](11-post-release-maintenance/crash-monitoring.md)
|
||||
- [Analytics](11-post-release-maintenance/analytics.md)
|
||||
- [OTA Updates](11-post-release-maintenance/ota-updates.md)
|
Loading…
Reference in New Issue