Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Capstone Project

Project Overview

Estimated Time: 40+ hours | Difficulty: Advanced | Prerequisites: All previous modules
Congratulations on reaching the capstone project. This is where all the modules come together into something real. You will build TaskFlow — a comprehensive project management application with real-time collaboration, offline support, and enterprise-grade features. The goal is not to build a toy app, but to simulate the experience of building a production mobile application from scratch, making the same architectural decisions and trade-offs a senior mobile engineer faces every day. What You’ll Build:
  • Full-featured project management app
  • Real-time collaboration
  • Offline-first architecture
  • Push notifications
  • Analytics & monitoring
  • CI/CD pipeline

TaskFlow - Project Management App

┌─────────────────────────────────────────────────────────────────────────────┐
│                         TaskFlow Application                                 │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│   Core Features                          Advanced Features                   │
│   ─────────────                          ─────────────────                   │
│   • User authentication                  • Real-time sync                    │
│   • Project management                   • Offline support                   │
│   • Task boards (Kanban)                 • Push notifications                │
│   • Team collaboration                   • File attachments                  │
│   • Comments & mentions                  • Activity timeline                 │
│   • Due dates & reminders                • Search & filters                  │
│                                                                              │
│   Technical Requirements                 Enterprise Features                 │
│   ─────────────────────                  ───────────────────                 │
│   • TypeScript                           • Analytics                         │
│   • React Navigation                     • Error tracking                    │
│   • Zustand + React Query                • Feature flags                     │
│   • Reanimated animations                • A/B testing                       │
│   • Unit & E2E tests                     • CI/CD pipeline                    │
│   • Accessibility                        • App Store deployment              │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Phase 1: Project Setup (Week 1)

The first week is about building a solid foundation. Resist the urge to start coding features immediately — investing in proper project structure, TypeScript configuration, and tooling pays dividends for the remaining weeks. A common mistake is rushing to build the Kanban board on day one and then spending week three refactoring the navigation because the initial structure could not accommodate the auth flow.

1.1 Initialize Project

# Create new Expo project with TypeScript
npx create-expo-app@latest taskflow --template expo-template-blank-typescript

cd taskflow

# Install core dependencies
npx expo install expo-router expo-linking expo-constants expo-status-bar

# Install navigation
npm install @react-navigation/native @react-navigation/native-stack @react-navigation/bottom-tabs

# Install state management
npm install zustand @tanstack/react-query

# Install UI libraries
npm install react-native-reanimated react-native-gesture-handler
npm install @expo/vector-icons

# Install form handling
npm install react-hook-form zod @hookform/resolvers

# Install storage
npx expo install expo-secure-store @react-native-async-storage/async-storage

# Install dev dependencies
npm install -D @types/react @testing-library/react-native jest-expo

1.2 Project Structure

taskflow/
├── app/                          # Expo Router screens
│   ├── (auth)/
│   │   ├── login.tsx
│   │   ├── register.tsx
│   │   └── forgot-password.tsx
│   ├── (tabs)/
│   │   ├── _layout.tsx
│   │   ├── index.tsx            # Dashboard
│   │   ├── projects.tsx
│   │   ├── notifications.tsx
│   │   └── profile.tsx
│   ├── project/
│   │   ├── [id]/
│   │   │   ├── index.tsx        # Project detail
│   │   │   ├── board.tsx        # Kanban board
│   │   │   ├── tasks.tsx        # Task list
│   │   │   └── settings.tsx
│   │   └── create.tsx
│   ├── task/
│   │   ├── [id].tsx             # Task detail
│   │   └── create.tsx
│   ├── _layout.tsx
│   └── +not-found.tsx
├── src/
│   ├── components/
│   │   ├── ui/                  # Base UI components
│   │   │   ├── Button.tsx
│   │   │   ├── Input.tsx
│   │   │   ├── Card.tsx
│   │   │   ├── Avatar.tsx
│   │   │   ├── Badge.tsx
│   │   │   └── index.ts
│   │   ├── forms/
│   │   │   ├── LoginForm.tsx
│   │   │   ├── TaskForm.tsx
│   │   │   └── ProjectForm.tsx
│   │   ├── project/
│   │   │   ├── ProjectCard.tsx
│   │   │   ├── ProjectList.tsx
│   │   │   └── KanbanBoard.tsx
│   │   ├── task/
│   │   │   ├── TaskCard.tsx
│   │   │   ├── TaskList.tsx
│   │   │   └── TaskDetail.tsx
│   │   └── common/
│   │       ├── Header.tsx
│   │       ├── EmptyState.tsx
│   │       └── ErrorBoundary.tsx
│   ├── hooks/
│   │   ├── useAuth.ts
│   │   ├── useProjects.ts
│   │   ├── useTasks.ts
│   │   ├── useOffline.ts
│   │   └── useNotifications.ts
│   ├── services/
│   │   ├── api/
│   │   │   ├── client.ts
│   │   │   ├── auth.ts
│   │   │   ├── projects.ts
│   │   │   └── tasks.ts
│   │   ├── storage/
│   │   │   ├── secure.ts
│   │   │   └── async.ts
│   │   └── notifications/
│   │       └── push.ts
│   ├── stores/
│   │   ├── authStore.ts
│   │   ├── projectStore.ts
│   │   └── uiStore.ts
│   ├── types/
│   │   ├── auth.ts
│   │   ├── project.ts
│   │   ├── task.ts
│   │   └── api.ts
│   ├── utils/
│   │   ├── date.ts
│   │   ├── validation.ts
│   │   └── helpers.ts
│   ├── constants/
│   │   ├── colors.ts
│   │   ├── spacing.ts
│   │   └── config.ts
│   └── theme/
│       ├── index.ts
│       └── tokens.ts
├── __tests__/
│   ├── components/
│   ├── hooks/
│   └── screens/
├── e2e/
│   ├── auth.test.ts
│   ├── projects.test.ts
│   └── tasks.test.ts
├── assets/
├── app.json
├── eas.json
├── tsconfig.json
└── package.json

1.3 TypeScript Configuration

// tsconfig.json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@hooks/*": ["src/hooks/*"],
      "@services/*": ["src/services/*"],
      "@stores/*": ["src/stores/*"],
      "@types/*": ["src/types/*"],
      "@utils/*": ["src/utils/*"],
      "@constants/*": ["src/constants/*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}

Phase 2: Core Features (Week 2-3)

This phase builds the features users interact with directly. Start with authentication (it gates everything else), then project management, then the Kanban board. This order matters because each feature depends on the one before it. A useful mental model: think of this phase as building a house. Authentication is the front door — you need it before anything inside makes sense. The project list is the hallway. The Kanban board is the main living room where users spend most of their time.

2.1 Authentication System

// src/types/auth.ts
export interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  createdAt: string;
}

export interface AuthState {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
  isLoading: boolean;
}

export interface LoginCredentials {
  email: string;
  password: string;
}

export interface RegisterData extends LoginCredentials {
  name: string;
  confirmPassword: string;
}
// src/stores/authStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import { User, LoginCredentials, RegisterData } from '@/types/auth';
import { authApi } from '@/services/api/auth';

interface AuthStore {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  
  login: (credentials: LoginCredentials) => Promise<void>;
  register: (data: RegisterData) => Promise<void>;
  logout: () => Promise<void>;
  refreshToken: () => Promise<void>;
  updateProfile: (data: Partial<User>) => Promise<void>;
}

export const useAuthStore = create<AuthStore>()(
  persist(
    (set, get) => ({
      user: null,
      isAuthenticated: false,
      isLoading: false,

      login: async (credentials) => {
        set({ isLoading: true });
        try {
          const { user, token, refreshToken } = await authApi.login(credentials);
          
          // Store tokens securely
          await SecureStore.setItemAsync('accessToken', token);
          await SecureStore.setItemAsync('refreshToken', refreshToken);
          
          set({ user, isAuthenticated: true, isLoading: false });
        } catch (error) {
          set({ isLoading: false });
          throw error;
        }
      },

      register: async (data) => {
        set({ isLoading: true });
        try {
          const { user, token, refreshToken } = await authApi.register(data);
          
          await SecureStore.setItemAsync('accessToken', token);
          await SecureStore.setItemAsync('refreshToken', refreshToken);
          
          set({ user, isAuthenticated: true, isLoading: false });
        } catch (error) {
          set({ isLoading: false });
          throw error;
        }
      },

      logout: async () => {
        await SecureStore.deleteItemAsync('accessToken');
        await SecureStore.deleteItemAsync('refreshToken');
        set({ user: null, isAuthenticated: false });
      },

      refreshToken: async () => {
        const refreshToken = await SecureStore.getItemAsync('refreshToken');
        if (!refreshToken) throw new Error('No refresh token');
        
        const { token } = await authApi.refresh(refreshToken);
        await SecureStore.setItemAsync('accessToken', token);
      },

      updateProfile: async (data) => {
        const user = get().user;
        if (!user) throw new Error('Not authenticated');
        
        const updatedUser = await authApi.updateProfile(user.id, data);
        set({ user: updatedUser });
      },
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => AsyncStorage),
      partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
    }
  )
);

2.2 Project Management

// src/types/project.ts
export interface Project {
  id: string;
  name: string;
  description?: string;
  color: string;
  icon: string;
  ownerId: string;
  members: ProjectMember[];
  taskCount: number;
  completedTaskCount: number;
  createdAt: string;
  updatedAt: string;
}

export interface ProjectMember {
  userId: string;
  role: 'owner' | 'admin' | 'member' | 'viewer';
  user: {
    id: string;
    name: string;
    avatar?: string;
  };
}

export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done';
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';

export interface Task {
  id: string;
  projectId: string;
  title: string;
  description?: string;
  status: TaskStatus;
  priority: TaskPriority;
  assigneeId?: string;
  assignee?: {
    id: string;
    name: string;
    avatar?: string;
  };
  dueDate?: string;
  tags: string[];
  attachments: Attachment[];
  comments: Comment[];
  createdAt: string;
  updatedAt: string;
}

export interface Attachment {
  id: string;
  name: string;
  url: string;
  type: string;
  size: number;
}

export interface Comment {
  id: string;
  taskId: string;
  userId: string;
  user: {
    id: string;
    name: string;
    avatar?: string;
  };
  content: string;
  mentions: string[];
  createdAt: string;
}

2.3 Kanban Board Component

The Kanban board is the centerpiece of TaskFlow and the most technically challenging component. It combines horizontal scrolling (between columns), vertical scrolling (within columns), drag-and-drop gesture handling, and animated state transitions. Getting this right requires Reanimated for performant animations and Gesture Handler for responsive drag interactions.
Architecture decision: The board uses a horizontally-scrolling FlatList of columns, where each column is a vertically-scrolling list of task cards. This nested scrolling pattern is common in production apps (Trello, Jira, Asana all use it). The key challenge is preventing gesture conflicts — a horizontal swipe should scroll columns, while a long-press-then-drag should move a task card. The Gesture.Pan() with onStart after a long press avoids this ambiguity.
// src/components/project/KanbanBoard.tsx
import { View, StyleSheet, ScrollView } from 'react-native';
import { useCallback, useState } from 'react';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withSpring,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Task, TaskStatus } from '@/types/project';
import { TaskCard } from '../task/TaskCard';
import { Text } from '../ui/Text';

interface KanbanBoardProps {
  tasks: Task[];
  onTaskMove: (taskId: string, newStatus: TaskStatus) => void;
  onTaskPress: (task: Task) => void;
}

const COLUMNS: { status: TaskStatus; title: string; color: string }[] = [
  { status: 'todo', title: 'To Do', color: '#6b7280' },
  { status: 'in_progress', title: 'In Progress', color: '#3b82f6' },
  { status: 'review', title: 'Review', color: '#f59e0b' },
  { status: 'done', title: 'Done', color: '#22c55e' },
];

export function KanbanBoard({ tasks, onTaskMove, onTaskPress }: KanbanBoardProps) {
  const [draggingTask, setDraggingTask] = useState<Task | null>(null);

  const getTasksByStatus = useCallback(
    (status: TaskStatus) => tasks.filter((task) => task.status === status),
    [tasks]
  );

  return (
    <ScrollView
      horizontal
      showsHorizontalScrollIndicator={false}
      contentContainerStyle={styles.container}
    >
      {COLUMNS.map((column) => (
        <KanbanColumn
          key={column.status}
          title={column.title}
          color={column.color}
          tasks={getTasksByStatus(column.status)}
          onTaskPress={onTaskPress}
          onTaskDrop={(taskId) => onTaskMove(taskId, column.status)}
          isDragging={!!draggingTask}
        />
      ))}
    </ScrollView>
  );
}

interface KanbanColumnProps {
  title: string;
  color: string;
  tasks: Task[];
  onTaskPress: (task: Task) => void;
  onTaskDrop: (taskId: string) => void;
  isDragging: boolean;
}

function KanbanColumn({
  title,
  color,
  tasks,
  onTaskPress,
  onTaskDrop,
  isDragging,
}: KanbanColumnProps) {
  const isHovered = useSharedValue(false);

  const columnStyle = useAnimatedStyle(() => ({
    backgroundColor: isHovered.value ? `${color}20` : '#f9fafb',
    borderColor: isHovered.value ? color : '#e5e7eb',
  }));

  return (
    <Animated.View style={[styles.column, columnStyle]}>
      <View style={styles.columnHeader}>
        <View style={[styles.statusDot, { backgroundColor: color }]} />
        <Text style={styles.columnTitle}>{title}</Text>
        <View style={styles.taskCount}>
          <Text style={styles.taskCountText}>{tasks.length}</Text>
        </View>
      </View>

      <ScrollView
        style={styles.taskList}
        showsVerticalScrollIndicator={false}
      >
        {tasks.map((task) => (
          <DraggableTaskCard
            key={task.id}
            task={task}
            onPress={() => onTaskPress(task)}
          />
        ))}
      </ScrollView>
    </Animated.View>
  );
}

interface DraggableTaskCardProps {
  task: Task;
  onPress: () => void;
}

function DraggableTaskCard({ task, onPress }: DraggableTaskCardProps) {
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const scale = useSharedValue(1);
  const zIndex = useSharedValue(0);

  const gesture = Gesture.Pan()
    .onStart(() => {
      scale.value = withSpring(1.05);
      zIndex.value = 100;
    })
    .onUpdate((event) => {
      translateX.value = event.translationX;
      translateY.value = event.translationY;
    })
    .onEnd(() => {
      translateX.value = withSpring(0);
      translateY.value = withSpring(0);
      scale.value = withSpring(1);
      zIndex.value = 0;
    });

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: translateX.value },
      { translateY: translateY.value },
      { scale: scale.value },
    ],
    zIndex: zIndex.value,
  }));

  return (
    <GestureDetector gesture={gesture}>
      <Animated.View style={animatedStyle}>
        <TaskCard task={task} onPress={onPress} />
      </Animated.View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 16,
    gap: 16,
  },
  column: {
    width: 300,
    backgroundColor: '#f9fafb',
    borderRadius: 12,
    borderWidth: 2,
    borderColor: '#e5e7eb',
    padding: 12,
    maxHeight: '100%',
  },
  columnHeader: {
    flexDirection: 'row',
    alignItems: 'center',
    marginBottom: 12,
    gap: 8,
  },
  statusDot: {
    width: 8,
    height: 8,
    borderRadius: 4,
  },
  columnTitle: {
    fontSize: 14,
    fontWeight: '600',
    color: '#374151',
    flex: 1,
  },
  taskCount: {
    backgroundColor: '#e5e7eb',
    paddingHorizontal: 8,
    paddingVertical: 2,
    borderRadius: 10,
  },
  taskCountText: {
    fontSize: 12,
    fontWeight: '500',
    color: '#6b7280',
  },
  taskList: {
    flex: 1,
  },
});

Phase 3: Advanced Features (Week 4-5)

This phase adds the features that separate a demo app from a production app: real-time collaboration, offline support, and push notifications. These are the hardest features to implement correctly because they involve distributed systems concerns — what happens when two users edit the same task simultaneously? What happens when a user makes changes offline and then reconnects? Do not aim for perfection here. Implement the simplest version that works, handle the most common edge cases, and document the known limitations. A “last writer wins” conflict resolution strategy is simpler than operational transforms and sufficient for most project management use cases. Conflict resolution decision matrix for TaskFlow:
ScenarioStrategyImplementation
Two users edit different fields of the same taskField-level merge — both edits surviveServer merges by field timestamp: if User A edits title and User B edits status, both changes apply
Two users edit the same field of the same taskLast writer wins (by server timestamp)Server accepts the latest updatedAt; the other edit is silently overwritten
User edits a task offline that was deleted by another userDelete winsOn sync, server returns 404; client removes the task from local store and shows “This task was deleted”
User creates a task offline with a UUID that collidesClient-generated UUIDs (v4) — collision probability is negligibleUse crypto.randomUUID() or uuid library; server rejects duplicates with 409

3.1 Real-time Sync with WebSocket

// src/services/realtime/socket.ts
import { io, Socket } from 'socket.io-client';
import * as SecureStore from 'expo-secure-store';
import { useProjectStore } from '@/stores/projectStore';
import { useTaskStore } from '@/stores/taskStore';

class RealtimeService {
  private socket: Socket | null = null;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 5;

  async connect() {
    const token = await SecureStore.getItemAsync('accessToken');
    if (!token) return;

    this.socket = io(process.env.EXPO_PUBLIC_WS_URL!, {
      auth: { token },
      transports: ['websocket'],
      reconnection: true,
      reconnectionAttempts: this.maxReconnectAttempts,
      reconnectionDelay: 1000,
    });

    this.setupListeners();
  }

  private setupListeners() {
    if (!this.socket) return;

    this.socket.on('connect', () => {
      console.log('WebSocket connected');
      this.reconnectAttempts = 0;
    });

    this.socket.on('disconnect', (reason) => {
      console.log('WebSocket disconnected:', reason);
    });

    this.socket.on('connect_error', (error) => {
      console.error('WebSocket connection error:', error);
      this.reconnectAttempts++;
    });

    // Project events
    this.socket.on('project:created', (project) => {
      useProjectStore.getState().addProject(project);
    });

    this.socket.on('project:updated', (project) => {
      useProjectStore.getState().updateProject(project.id, project);
    });

    this.socket.on('project:deleted', ({ projectId }) => {
      useProjectStore.getState().removeProject(projectId);
    });

    // Task events
    this.socket.on('task:created', (task) => {
      useTaskStore.getState().addTask(task);
    });

    this.socket.on('task:updated', (task) => {
      useTaskStore.getState().updateTask(task.id, task);
    });

    this.socket.on('task:moved', ({ taskId, newStatus }) => {
      useTaskStore.getState().moveTask(taskId, newStatus);
    });

    this.socket.on('task:deleted', ({ taskId }) => {
      useTaskStore.getState().removeTask(taskId);
    });

    // Comment events
    this.socket.on('comment:created', ({ taskId, comment }) => {
      useTaskStore.getState().addComment(taskId, comment);
    });
  }

  joinProject(projectId: string) {
    this.socket?.emit('project:join', { projectId });
  }

  leaveProject(projectId: string) {
    this.socket?.emit('project:leave', { projectId });
  }

  disconnect() {
    this.socket?.disconnect();
    this.socket = null;
  }
}

export const realtimeService = new RealtimeService();

3.2 Offline Support

// src/hooks/useOfflineSync.ts
import { useEffect, useCallback } from 'react';
import NetInfo from '@react-native-community/netinfo';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useQueryClient } from '@tanstack/react-query';

interface PendingAction {
  id: string;
  type: 'create' | 'update' | 'delete';
  entity: 'task' | 'project' | 'comment';
  data: unknown;
  timestamp: number;
}

const PENDING_ACTIONS_KEY = 'pending_actions';

export function useOfflineSync() {
  const queryClient = useQueryClient();

  // Queue action for offline sync
  const queueAction = useCallback(async (action: Omit<PendingAction, 'id' | 'timestamp'>) => {
    const pendingActions = await getPendingActions();
    const newAction: PendingAction = {
      ...action,
      id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
      timestamp: Date.now(),
    };
    
    await AsyncStorage.setItem(
      PENDING_ACTIONS_KEY,
      JSON.stringify([...pendingActions, newAction])
    );
  }, []);

  // Sync pending actions when online
  const syncPendingActions = useCallback(async () => {
    const pendingActions = await getPendingActions();
    if (pendingActions.length === 0) return;

    const failedActions: PendingAction[] = [];

    for (const action of pendingActions) {
      try {
        await executeAction(action);
      } catch (error) {
        console.error('Failed to sync action:', action, error);
        failedActions.push(action);
      }
    }

    // Keep failed actions for retry
    await AsyncStorage.setItem(PENDING_ACTIONS_KEY, JSON.stringify(failedActions));

    // Invalidate queries to refresh data
    queryClient.invalidateQueries();
  }, [queryClient]);

  // Listen for network changes
  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener((state) => {
      if (state.isConnected && state.isInternetReachable) {
        syncPendingActions();
      }
    });

    return () => unsubscribe();
  }, [syncPendingActions]);

  return { queueAction, syncPendingActions };
}

async function getPendingActions(): Promise<PendingAction[]> {
  const data = await AsyncStorage.getItem(PENDING_ACTIONS_KEY);
  return data ? JSON.parse(data) : [];
}

async function executeAction(action: PendingAction): Promise<void> {
  // Implement API calls based on action type
  const { type, entity, data } = action;
  
  switch (`${entity}:${type}`) {
    case 'task:create':
      await fetch('/api/tasks', {
        method: 'POST',
        body: JSON.stringify(data),
      });
      break;
    case 'task:update':
      await fetch(`/api/tasks/${(data as any).id}`, {
        method: 'PATCH',
        body: JSON.stringify(data),
      });
      break;
    // Add more cases...
  }
}

3.3 Push Notifications

// src/services/notifications/push.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import { router } from 'expo-router';

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

export async function registerForPushNotifications(): Promise<string | null> {
  if (!Device.isDevice) {
    console.log('Push notifications require a physical device');
    return null;
  }

  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    console.log('Push notification permission denied');
    return null;
  }

  const token = await Notifications.getExpoPushTokenAsync({
    projectId: process.env.EXPO_PUBLIC_PROJECT_ID,
  });

  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#3b82f6',
    });
  }

  return token.data;
}

export function setupNotificationListeners() {
  // Handle notification received while app is foregrounded
  const foregroundSubscription = Notifications.addNotificationReceivedListener(
    (notification) => {
      console.log('Notification received:', notification);
    }
  );

  // Handle notification tap
  const responseSubscription = Notifications.addNotificationResponseReceivedListener(
    (response) => {
      const data = response.notification.request.content.data;
      
      // Navigate based on notification type
      if (data.type === 'task_assigned') {
        router.push(`/task/${data.taskId}`);
      } else if (data.type === 'comment_mention') {
        router.push(`/task/${data.taskId}`);
      } else if (data.type === 'project_invite') {
        router.push(`/project/${data.projectId}`);
      }
    }
  );

  return () => {
    foregroundSubscription.remove();
    responseSubscription.remove();
  };
}

Phase 4: Testing & Quality (Week 6)

Testing a capstone project is where you discover whether your architecture is actually testable. If your components are tightly coupled to navigation, global state, and network calls, writing tests becomes a slog of mock setup. If you followed the patterns from earlier modules (dependency injection via hooks, separated business logic, thin screen components), testing will be straightforward. Focus your testing effort where it matters most: auth flows (because bugs here lock users out), data mutations (because bugs here corrupt data), and offline sync (because bugs here lose user work). Do not spend time snapshot-testing every presentational component.

4.1 Unit Tests

// __tests__/stores/authStore.test.ts
import { renderHook, act } from '@testing-library/react-native';
import { useAuthStore } from '@/stores/authStore';
import { authApi } from '@/services/api/auth';

jest.mock('@/services/api/auth');
jest.mock('expo-secure-store');

describe('authStore', () => {
  beforeEach(() => {
    useAuthStore.setState({
      user: null,
      isAuthenticated: false,
      isLoading: false,
    });
  });

  it('logs in user successfully', async () => {
    const mockUser = { id: '1', email: 'test@example.com', name: 'Test User' };
    (authApi.login as jest.Mock).mockResolvedValue({
      user: mockUser,
      token: 'access-token',
      refreshToken: 'refresh-token',
    });

    const { result } = renderHook(() => useAuthStore());

    await act(async () => {
      await result.current.login({ email: 'test@example.com', password: 'password' });
    });

    expect(result.current.user).toEqual(mockUser);
    expect(result.current.isAuthenticated).toBe(true);
  });

  it('handles login error', async () => {
    (authApi.login as jest.Mock).mockRejectedValue(new Error('Invalid credentials'));

    const { result } = renderHook(() => useAuthStore());

    await expect(
      act(async () => {
        await result.current.login({ email: 'test@example.com', password: 'wrong' });
      })
    ).rejects.toThrow('Invalid credentials');

    expect(result.current.isAuthenticated).toBe(false);
  });
});

4.2 E2E Tests with Detox

// e2e/auth.test.ts
describe('Authentication', () => {
  beforeAll(async () => {
    await device.launchApp();
  });

  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('should login successfully', async () => {
    await element(by.id('email-input')).typeText('test@example.com');
    await element(by.id('password-input')).typeText('password123');
    await element(by.id('login-button')).tap();

    await expect(element(by.id('dashboard-screen'))).toBeVisible();
  });

  it('should show error for invalid credentials', async () => {
    await element(by.id('email-input')).typeText('test@example.com');
    await element(by.id('password-input')).typeText('wrongpassword');
    await element(by.id('login-button')).tap();

    await expect(element(by.text('Invalid credentials'))).toBeVisible();
  });

  it('should navigate to register screen', async () => {
    await element(by.id('register-link')).tap();

    await expect(element(by.id('register-screen'))).toBeVisible();
  });
});
// e2e/tasks.test.ts
describe('Task Management', () => {
  beforeAll(async () => {
    await device.launchApp();
    // Login first
    await element(by.id('email-input')).typeText('test@example.com');
    await element(by.id('password-input')).typeText('password123');
    await element(by.id('login-button')).tap();
  });

  it('should create a new task', async () => {
    await element(by.id('projects-tab')).tap();
    await element(by.id('project-card-1')).tap();
    await element(by.id('add-task-button')).tap();

    await element(by.id('task-title-input')).typeText('New Test Task');
    await element(by.id('task-description-input')).typeText('Task description');
    await element(by.id('save-task-button')).tap();

    await expect(element(by.text('New Test Task'))).toBeVisible();
  });

  it('should move task between columns', async () => {
    await element(by.id('task-card-1')).longPress();
    await element(by.id('column-in_progress')).tap();

    await expect(
      element(by.id('task-card-1').withAncestor(by.id('column-in_progress')))
    ).toBeVisible();
  });
});

Phase 5: Deployment (Week 7)

The final mile is often the hardest. Code signing, provisioning profiles, and store review guidelines will test your patience. The good news: with EAS Build, the most painful parts are handled for you. The bad news: you still need to understand what EAS is doing (creating certificates, managing profiles, submitting binaries) so you can debug when things go wrong — and they will. Start by deploying to internal testing tracks (TestFlight for iOS, Internal Testing track on Google Play). These do not require full store review and let your testers install builds within minutes. Only submit to production after your internal testers have verified the build on real devices.

5.1 CI/CD Pipeline

# .github/workflows/ci-cd.yml
name: CI/CD

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test -- --coverage

  build-preview:
    needs: test
    if: github.ref == 'refs/heads/develop'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: npm ci
      - run: eas build --platform all --profile preview --non-interactive

  build-production:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: npm ci
      - run: eas build --platform all --profile production --non-interactive
      - run: eas submit --platform all --latest --non-interactive

5.2 EAS Configuration

// eas.json
{
  "cli": {
    "version": ">= 5.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal",
      "env": {
        "APP_ENV": "staging",
        "EXPO_PUBLIC_API_URL": "https://staging-api.taskflow.app"
      }
    },
    "production": {
      "distribution": "store",
      "env": {
        "APP_ENV": "production",
        "EXPO_PUBLIC_API_URL": "https://api.taskflow.app"
      }
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "your@email.com",
        "ascAppId": "1234567890"
      },
      "android": {
        "serviceAccountKeyPath": "./google-services.json",
        "track": "production"
      }
    }
  }
}

Technology Selection Rationale

Before building, understand why each technology was chosen. In interviews and architecture reviews, the ability to articulate trade-offs behind tech choices matters more than the choices themselves.
DecisionChoiceWhy This Over Alternatives
FrameworkExpo (managed workflow)Eliminates native build toolchain setup; EAS handles code signing; config plugins cover 95% of native customization needs
NavigationExpo Router (file-based)Type-safe routes from file structure; deep linking automatic; aligns with React Server Components direction
Client stateZustandMinimal boilerplate vs Redux; no provider nesting; works outside React components (useful for WebSocket handlers)
Server stateReact QueryCaching, background refresh, optimistic updates built-in; separates server state from client state cleanly
FormsReact Hook Form + ZodUncontrolled by default (fewer re-renders); Zod schemas reusable for API validation; better TypeScript inference than Yup
AnimationsReanimated 3Runs on UI thread (no bridge crossing); worklets enable 60fps animations; gesture handler integration for drag-and-drop
Secure storageexpo-secure-storeiOS Keychain + Android Keystore backed; simple API; no native module linking required in Expo
TestingJest + RNTL + DetoxJest for unit/hook tests; RNTL for behavioral component tests; Detox for E2E (real device, real gestures)
CI/CDEAS Build + GitHub ActionsEAS for builds (no macOS runner needed); GitHub Actions for lint/test/deploy orchestration

When You Might Choose Differently

These are not universal “best” choices — they are the best choices for this project’s constraints (Expo, small team, MVP timeline). Here is when the alternatives win:
If Your Constraint Is…Consider Instead
Heavy custom native code (Bluetooth, AR, custom camera)Bare React Native CLI workflow
Existing Redux codebase with 100+ reducersRedux Toolkit + RTK Query (migration cost of switching is too high)
Exclusively GraphQL backendApollo Client (normalized cache is purpose-built for GraphQL)
Team has no React experienceFlutter (single language, less ecosystem fragmentation)
Need pixel-perfect iOS + separate Android UXTwo native apps (SwiftUI + Jetpack Compose)

Architectural Decision Records

Throughout the capstone, document key decisions as lightweight ADRs (Architecture Decision Records). This practice is what separates a portfolio project from a tutorial copy-paste. When a hiring manager reviews your repo, these records demonstrate senior-level thinking.
DecisionContextOptions ConsideredChosenRationale
Conflict resolution strategyTwo users edit same task offlineOperational Transforms (OT), CRDTs, Last Writer Wins (LWW)LWW with field-level granularityOT/CRDTs add significant complexity; field-level LWW means simultaneous edits to different fields both survive; only same-field conflicts are lost
Offline queue structurePending mutations need orderingSingle FIFO queue, per-entity queues, event sourcingSingle FIFO queueSimplest to implement; ordering guarantees are sufficient for task management; per-entity queues add complexity without proportional benefit
Auth token storageTokens must survive app restartAsyncStorage, MMKV, Secure StoreSecure Store for tokens, AsyncStorage for non-sensitive user prefsTokens are high-value targets; Secure Store provides hardware-backed encryption; AsyncStorage is faster for non-sensitive data
WebSocket reconnectionNetwork transitions on mobile are frequentReconnect immediately, exponential backoff, user-initiated reconnectExponential backoff with jitter, max 5 attempts, then user promptImmediate reconnect causes thundering herd; infinite retry wastes battery; user prompt after max attempts is honest UX

Evaluation Criteria

Code Quality (25%)

  • TypeScript usage
  • Clean architecture
  • Code organization
  • Best practices

Features (25%)

  • Core functionality
  • Advanced features
  • Error handling
  • Edge cases

Testing (20%)

  • Unit test coverage
  • Integration tests
  • E2E tests
  • Test quality

UX/Performance (15%)

  • Smooth animations
  • Fast load times
  • Offline support
  • Accessibility

DevOps (15%)

  • CI/CD pipeline
  • Environment management
  • Monitoring setup
  • Documentation

What Separates Good from Great

Most capstone submissions meet the functional requirements. Here is what distinguishes the top tier:
AreaGood (Meets Requirements)Great (Exceeds Expectations)
Error handlingTry/catch around API calls; generic error messagesError boundaries per feature; contextual error messages; retry with backoff; offline fallbacks
TypeScriptTypes on props and stateDiscriminated unions for state machines; branded types for IDs; strict mode with no any
Testing70% coverage; snapshot testsBehavioral tests that survive refactors; factory functions for test data; separate test utilities
OfflineQueue mutations; replay on reconnectOptimistic UI; conflict detection; partial sync; offline indicator in UI
PerformanceApp loads and scrolls smoothlyPerformance budgets in CI; lazy loading for below-fold screens; preloading on navigation intent
DocumentationREADME with setup instructionsADRs for key decisions; inline comments explaining why not what; API documentation

Submission Requirements

  1. GitHub Repository with complete source code
  2. README.md with setup instructions
  3. Demo Video (5-10 minutes) showcasing features
  4. Architecture Document explaining design decisions
  5. Test Coverage Report (minimum 70%)
  6. Working CI/CD Pipeline
  7. Published App on TestFlight/Play Store Internal

Congratulations

You have completed the React Native Enterprise Mastery course. You now have the skills to build production-ready mobile applications used by millions of users. More importantly, you understand why things are built the way they are — not just the syntax, but the trade-offs, the failure modes, and the patterns that scale.

Get Certified

Complete the certification exam to validate your skills