17 KiB
Mobile App Refactoring Plan
A systematic plan to improve code maintainability, reduce duplication, and establish patterns for long-term scalability.
Executive Summary
| Metric | Current | Target |
|---|---|---|
| Largest file (chat) | 2,213 lines | <400 lines per file |
| Store file | 666 lines (5 stores) | ~150 lines per store |
| Animation duplication | 3+ files | 1 shared hook |
| UI component duplication | 6+ instances | Shared components |
Phase 1: Extract Shared Hooks (Low Risk, High Impact)
Goal: Eliminate animation and logic duplication across the app.
1.1 Create useAmbientOrbAnimation Hook
Files affected:
app/(app)/swipe.tsx(lines 41-85)app/(app)/chat/[id].tsx(lines 70-122)src/components/SwipeCard.native.tsx(lines 60-70)
New file: src/hooks/useAmbientOrbAnimation.ts
import { useEffect } from 'react';
import { useSharedValue, withRepeat, withSequence, withTiming, useAnimatedStyle } from 'react-native-reanimated';
interface AmbientOrbConfig {
color1?: string;
color2?: string;
size?: number;
opacity?: number;
}
export const useAmbientOrbAnimation = (config?: AmbientOrbConfig) => {
const orbBreathing1 = useSharedValue(0);
const orbBreathing2 = useSharedValue(0);
const orbDrift1X = useSharedValue(0);
const orbDrift1Y = useSharedValue(0);
const orbDrift2X = useSharedValue(0);
const orbDrift2Y = useSharedValue(0);
useEffect(() => {
// Breathing animations
orbBreathing1.value = withRepeat(
withSequence(
withTiming(1, { duration: 3000 }),
withTiming(0, { duration: 3000 })
),
-1,
true
);
// ... rest of animation setup
}, []);
const orbStyle1 = useAnimatedStyle(() => ({
// ... transform and opacity logic
}));
const orbStyle2 = useAnimatedStyle(() => ({
// ... transform and opacity logic
}));
return { orbStyle1, orbStyle2 };
};
1.2 Create useMediaPicker Hook
Extract from: app/(app)/chat/[id].tsx (lines 296-380)
New file: src/hooks/useMediaPicker.ts
interface MediaPickerOptions {
allowsVideo?: boolean;
maxDuration?: number;
quality?: number;
onSelect: (uri: string, type: 'image' | 'video') => void;
onError?: (error: Error) => void;
}
export const useMediaPicker = (options: MediaPickerOptions) => {
const pickImage = async () => { /* ... */ };
const takePhoto = async () => { /* ... */ };
const pickVideo = async () => { /* ... */ };
const recordVideo = async () => { /* ... */ };
return { pickImage, takePhoto, pickVideo, recordVideo };
};
1.3 Create useTypingIndicator Hook
Extract from: app/(app)/chat/[id].tsx (lines 200-260)
New file: src/hooks/useTypingIndicator.ts
export const useTypingIndicator = (matchId: string, userId: string) => {
const [isPartnerTyping, setIsPartnerTyping] = useState(false);
const sendTypingStatus = useCallback((isTyping: boolean) => { /* ... */ }, []);
// Subscribe to typing channel
useEffect(() => { /* ... */ }, [matchId]);
return { isPartnerTyping, sendTypingStatus };
};
1.4 Create useMessageSubscription Hook
Extract from: app/(app)/chat/[id].tsx (lines 450-550)
New file: src/hooks/useMessageSubscription.ts
export const useMessageSubscription = (matchId: string, userId: string) => {
const [messages, setMessages] = useState<Message[]>([]);
// Real-time subscription
// Message status updates
// Optimistic updates
return { messages, sendMessage, markAsRead };
};
Phase 2: Create Shared UI Components (Low Risk, Medium Impact)
Goal: Extract repeated UI patterns into reusable components.
2.1 Create DecorativeSeparator Component
Duplicate instances found in:
app/(app)/swipe.tsx(6+ instances)app/(app)/chat/[id].tsx(lines 648-652)src/components/SwipeTutorial.tsx
New file: src/components/ui/DecorativeSeparator.tsx
interface DecorativeSeparatorProps {
variant?: 'diamond' | 'dot' | 'line';
color?: string;
width?: number | string;
}
export const DecorativeSeparator: React.FC<DecorativeSeparatorProps> = ({
variant = 'diamond',
color = theme.colors.glass.border,
width = '60%',
}) => (
<View style={[styles.container, { width }]}>
<View style={[styles.line, { backgroundColor: color }]} />
{variant === 'diamond' && <View style={[styles.diamond, { backgroundColor: color }]} />}
{variant === 'dot' && <View style={[styles.dot, { backgroundColor: color }]} />}
<View style={[styles.line, { backgroundColor: color }]} />
</View>
);
2.2 Create LoadingOverlay Component
Similar patterns in:
app/(app)/chat/[id].tsx(UploadingSkeleton)src/components/Paywall.tsx- Various loading states
New file: src/components/ui/LoadingOverlay.tsx
interface LoadingOverlayProps {
visible: boolean;
message?: string;
progress?: number;
variant?: 'spinner' | 'shimmer' | 'progress';
}
export const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ ... });
2.3 Create ReadReceipt Component
Extract from: app/(app)/chat/[id].tsx (lines 1354-1365)
New file: src/components/ui/ReadReceipt.tsx
type ReceiptStatus = 'sent' | 'delivered' | 'read';
interface ReadReceiptProps {
status: ReceiptStatus;
size?: number;
}
export const ReadReceipt: React.FC<ReadReceiptProps> = ({ status, size = 14 }) => {
const icon = status === 'read' ? 'checkmark-done' : status === 'delivered' ? 'checkmark-done' : 'checkmark';
const color = status === 'read' ? theme.colors.primary : theme.colors.text.secondary;
return <Ionicons name={icon} size={size} color={color} />;
};
2.4 Create AmbientOrbs Component
New file: src/components/ui/AmbientOrbs.tsx
interface AmbientOrbsProps {
variant?: 'default' | 'chat' | 'premium';
}
export const AmbientOrbs: React.FC<AmbientOrbsProps> = ({ variant = 'default' }) => {
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
return (
<>
<Animated.View style={[styles.orb1, orbStyle1]} />
<Animated.View style={[styles.orb2, orbStyle2]} />
</>
);
};
Phase 3: Split the Zustand Store (Medium Risk, High Impact)
Goal: Improve maintainability by splitting the monolithic store into domain-specific modules.
Current Structure (666 lines, 1 file)
src/store/index.ts
├── useAuthStore
├── useMatchStore
├── usePacksStore
├── useMessageStore
└── useSubscriptionStore
Target Structure (5 files, ~100-150 lines each)
src/store/
├── index.ts # Re-exports all stores
├── authStore.ts # Auth, user, couple, partner
├── matchStore.ts # Matches, sorting, filtering
├── packsStore.ts # Question packs, enabled packs
├── messageStore.ts # Messages, unread counts
└── subscriptionStore.ts # RevenueCat, offerings, purchases
Implementation Steps
- Create store directory structure
- Move
useAuthStoretoauthStore.ts- Keep auth refresh logic isolated
- Export type definitions
- Move
useMatchStoretomatchStore.ts - Move
usePacksStoretopacksStore.ts - Move
useMessageStoretomessageStore.ts - Move
useSubscriptionStoretosubscriptionStore.ts - Update
index.tsto re-export all stores
// src/store/index.ts
export { useAuthStore } from './authStore';
export { useMatchStore } from './matchStore';
export { usePacksStore } from './packsStore';
export { useMessageStore } from './messageStore';
export { useSubscriptionStore } from './subscriptionStore';
Import changes: None required - all imports from @/store will continue to work.
Phase 4: Decompose Chat Screen (Medium Risk, Highest Impact)
Goal: Break the 2,213-line chat screen into maintainable, testable components.
Current Structure
app/(app)/chat/[id].tsx (2,213 lines)
├── Animations (lines 70-122)
├── State management (lines 130-200)
├── Typing indicator logic (lines 200-260)
├── Media handling (lines 296-450)
├── Message subscription (lines 450-550)
├── UI Components (inline)
│ ├── MessageContent
│ ├── ChatVideoPlayer
│ ├── MessageMeta
│ ├── InputBar
│ └── UploadingSkeleton
└── Main render (lines 600-2213)
Target Structure
src/features/chat/
├── index.ts # Public exports
├── ChatScreen.tsx # Main screen (orchestration only, <400 lines)
├── components/
│ ├── ChatHeader.tsx # Header with match info
│ ├── ChatMessages.tsx # Message list + scroll logic
│ ├── MessageBubble.tsx # Single message rendering
│ ├── MessageContent.tsx # Text/image/video content
│ ├── ChatVideoPlayer.tsx # Video player with controls
│ ├── InputBar.tsx # Text input + media buttons
│ ├── MediaMenu.tsx # Expandable media picker
│ ├── TypingIndicator.tsx # Animated typing dots
│ └── UploadProgress.tsx # Upload skeleton/progress
├── hooks/
│ ├── useChatMessages.ts # Message fetching & subscription
│ ├── useTypingIndicator.ts # Typing broadcast channel
│ ├── useMediaUpload.ts # Image/video compression & upload
│ └── useVideoCache.ts # Video download & caching
└── types.ts # Chat-specific types
Implementation Steps
Step 1: Create Feature Directory
mkdir -p src/features/chat/components
mkdir -p src/features/chat/hooks
Step 2: Extract Components (in order of dependency)
2a. Extract MessageContent.tsx
- Lines 680-800 from current file
- Handles text, image, video rendering
- Props:
{ message, onImagePress, onVideoPress }
2b. Extract ChatVideoPlayer.tsx
- Lines 740-800 from current file
- Custom video player with controls
- Props:
{ uri, onClose, visible }
2c. Extract MessageBubble.tsx
- Lines 640-900 from current file
- Single message with content + meta
- Props:
{ message, isOwn, onMediaPress }
2d. Extract TypingIndicator.tsx
- Lines 870-910 from current file
- Animated typing dots
- Props:
{ visible }
2e. Extract InputBar.tsx
- Lines 1380-1510 from current file
- Text input + send button + media toggle
- Props:
{ onSend, onMediaSelect, disabled }
2f. Extract MediaMenu.tsx
- Lines 1510-1600 from current file
- Expandable media picker buttons
- Props:
{ visible, onSelect, onClose }
2g. Extract ChatHeader.tsx
- Lines 600-640 from current file
- Match card + question display
- Props:
{ match, onBack }
2h. Extract ChatMessages.tsx
- Lines 1600-1800 from current file
- FlatList with message rendering
- Props:
{ messages, userId, onMediaPress }
Step 3: Extract Hooks
3a. Create useChatMessages.ts
export const useChatMessages = (matchId: string) => {
// Fetch initial messages
// Subscribe to new messages
// Handle optimistic updates
// Mark messages as read
return { messages, sendMessage, loading, error };
};
3b. Create useMediaUpload.ts
export const useMediaUpload = (matchId: string) => {
// Compress image/video
// Upload to storage
// Track progress
return { upload, progress, uploading };
};
Step 4: Create Main Screen
// src/features/chat/ChatScreen.tsx (~300-400 lines)
export const ChatScreen: React.FC = () => {
const { id } = useLocalSearchParams();
const { messages, sendMessage } = useChatMessages(id);
const { isPartnerTyping, sendTypingStatus } = useTypingIndicator(id);
const { upload, progress } = useMediaUpload(id);
return (
<View style={styles.container}>
<AmbientOrbs variant="chat" />
<ChatHeader match={match} onBack={router.back} />
<ChatMessages messages={messages} userId={userId} />
{isPartnerTyping && <TypingIndicator />}
<InputBar onSend={sendMessage} onMediaSelect={upload} />
</View>
);
};
Step 5: Update Route File
// app/(app)/chat/[id].tsx
export { ChatScreen as default } from '@/features/chat';
Phase 5: Decompose Profile Screen (Medium Risk, High Impact)
Goal: Break the 1,703-line profile screen into manageable sections.
Target Structure
src/features/profile/
├── index.ts
├── ProfileScreen.tsx # Main orchestration (<400 lines)
├── components/
│ ├── ProfileHeader.tsx # Avatar, name, edit
│ ├── CoupleStatus.tsx # Partner info, invite code
│ ├── SettingsSection.tsx # Generic settings group
│ ├── NotificationSettings.tsx
│ ├── PrivacySettings.tsx
│ ├── AppearanceSettings.tsx
│ └── DangerZone.tsx # Delete account, leave couple
└── hooks/
├── useProfileSettings.ts
└── useCoupleManagement.ts
Phase 6: Improve Component Organization (Low Risk)
Goal: Establish clear component categories and naming conventions.
Current Structure
src/components/
├── ui/ # Primitive UI components
├── [Feature].tsx # Mixed feature components
└── [Tutorial].tsx # Tutorial components
Target Structure
src/components/
├── ui/ # Primitive UI (buttons, cards, inputs)
│ ├── GlassButton.tsx
│ ├── GlassCard.tsx
│ ├── GlassInput.tsx
│ ├── DecorativeSeparator.tsx
│ ├── LoadingOverlay.tsx
│ ├── ReadReceipt.tsx
│ ├── AmbientOrbs.tsx
│ └── ShimmerEffect.tsx
├── feedback/ # User feedback components
│ ├── FeedbackModal.tsx
│ └── QuestionFeedbackModal.tsx
├── tutorials/ # Onboarding tutorials
│ ├── SwipeTutorial.tsx
│ └── MatchesTutorial.tsx
├── paywall/ # Subscription components
│ ├── Paywall.tsx
│ └── PremiumBadge.tsx
└── swipe/ # Swipe card variants
├── SwipeCard.native.tsx
├── SwipeCard.web.tsx
└── SwipeCardPremium.tsx
Phase 7: Add Testing Infrastructure (Long-term)
Goal: Enable confident refactoring with test coverage.
Recommended Test Structure
src/
├── __tests__/
│ ├── components/
│ ├── hooks/
│ ├── stores/
│ └── utils/
└── features/
└── chat/
└── __tests__/
├── ChatMessages.test.tsx
└── useChatMessages.test.ts
Testing Priorities
- Hooks - Pure logic, easy to test
- Store actions - Critical business logic
- Utility functions - Edge cases
- Components - Snapshot + interaction tests
Implementation Order
| Phase | Risk | Impact | Effort | Priority |
|---|---|---|---|---|
| 1. Shared Hooks | Low | High | 2-3 days | 1 |
| 2. Shared UI Components | Low | Medium | 1-2 days | 2 |
| 3. Split Store | Medium | High | 1 day | 3 |
| 4. Decompose Chat | Medium | Highest | 3-5 days | 4 |
| 5. Decompose Profile | Medium | High | 2-3 days | 5 |
| 6. Component Organization | Low | Low | 1 day | 6 |
| 7. Testing | Low | High | Ongoing | 7 |
Migration Strategy
For Each Phase:
- Create new files without modifying existing code
- Import and use new components/hooks in the target file
- Verify functionality works identically
- Remove old code from the original file
- Update imports across the codebase if needed
Rollback Strategy:
- Each phase can be rolled back independently
- Keep original files until phase is complete
- Use feature flags for large changes if needed
Success Metrics
| Metric | Current | Target |
|---|---|---|
| Max lines per file | 2,213 | <500 |
| Duplicate animation code | 3 files | 1 hook |
| Store files | 1 (666 lines) | 5 (~130 lines each) |
| Test coverage | 0% | >50% for hooks/stores |
| Component reuse | Low | High (shared UI components) |
Notes
- Do not change the tab bar background styling (see CLAUDE.md)
- Follow DESIGN.md for all UI changes
- Use theme imports, never hardcode colors
- Preserve all existing functionality during refactoring