Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions bounty-6/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Warpspeed Bounty #6 — Enhanced Image Preview

## Bounty Value
**$660**

## Goal
Build a full-screen image preview modal (lightbox) for React Native + TypeScript that supports:
- Tap to open
- Pinch-to-zoom and pan
- Swipe navigation between multiple images
- Smooth open/close transitions
- Download / share / delete actions

## Architecture

```
ImagePreview (Modal)
├── AnimatedBackdrop — fade-in/out overlay
├── GallerySwiper — horizontal swipe between items
│ └── ZoomableImage[] — pinch-to-zoom + pan per image
└── ActionOverlay — close, counter, download, share, delete
```

## State Management (MobX)
`PreviewStore` holds gallery items, current index, and visibility flag. Any screen can call `store.open(items, index)` to launch the preview.

## Gesture Strategy

| Gesture | When | Handler |
|---------|------|---------|
| Horizontal pan | scale == 1 | GallerySwiper → next/prev |
| Horizontal pan | scale > 1 | ZoomableImage → translate |
| Pinch (2 fingers) | always | ZoomableImage → scale |
| Double tap | always | ZoomableImage → toggle 2× / 1× |
| Single tap | always | ZoomableImage → toggle overlay |
| Vertical pan | scale == 1 | close modal (pull-down) |

> **Zoom takes priority**: while an image is zoomed in, the swiper is locked so pans don't change pages.

## Key Design Decisions

1. **Pure React Native** — no `react-native-gesture-handler` or `react-native-reanimated` dependency. Uses `PanResponder` and `Animated` from RN core so the implementation works out of the box.
2. **FlatList for swiping** — `horizontal` + `pagingEnabled` + `scrollEnabled` toggle gives us swipe navigation with zero extra code. We toggle `scrollEnabled` off when the current image is zoomed in.
3. **Animated values, not state** — zoom & pan use `Animated.Value` for 60fps gesture feedback.
4. **Clamped transformations** — scale is clamped to `[1, 5]`, translation is bounded so image edges don't drift past screen edges.
5. **MobX** — lightweight observable store for preview state.

## Data Flow

```
User taps thumbnail
→ Screen calls previewStore.open(items, index)
→ ImagePreview modal renders
→ GallerySwiper renders items
→ ZoomableImage handles scale/translate
→ ActionOverlay shows actions
User taps close / swipes last item / pulls down
→ previewStore.close()
→ Modal unmounts
```

## File Structure

```
components/
ImagePreview.tsx — Modal orchestrator
ZoomableImage.tsx — Pinch-to-zoom + pan
GallerySwiper.tsx — Swipe navigation
ActionOverlay.tsx — Action buttons overlay
state/
previewStore.ts — MobX store
types.ts — Shared TypeScript types
README.md — Usage guide
DESIGN.md — This file
```
143 changes: 143 additions & 0 deletions bounty-6/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Warpspeed Bounty #6 — Enhanced Image Preview

**Value:** $660

## Overview

A full-screen image preview modal (lightbox) for React Native + TypeScript. Supports pinch-to-zoom, pan, swipe navigation, and download/share/delete actions. Built with zero external gesture dependencies — uses only `PanResponder` and `Animated` from React Native core.

## Features

- ✅ Full-screen modal lightbox
- ✅ Tap thumbnail to open
- ✅ Pinch-to-zoom (2 fingers)
- ✅ Pan when zoomed in
- ✅ Double-tap to zoom in/out at focal point
- ✅ Horizontal swipe between multiple images
- ✅ Smooth open/close fade transitions
- ✅ Download / Share / Delete action buttons
- ✅ Image counter (e.g., "3 / 8")
- ✅ Zoomed images lock swiping (no accidental page turns)
- ✅ Lightweight — no external gesture libraries needed

## File Structure

```
components/
ImagePreview.tsx — Modal that orchestrates everything
ZoomableImage.tsx — Pinch-to-zoom + pan gesture handling
GallerySwiper.tsx — FlatList-based horizontal swipe
ActionOverlay.tsx — Close / counter / download / share / delete
state/
previewStore.ts — MobX store for preview state
types.ts — GalleryItem, PreviewState, ZoomLevel
```

## Quick Start

```tsx
import React from 'react'
import { Button } from 'react-native'
import ImagePreview from './components/ImagePreview'
import type { GalleryItem } from './types'

const images: GalleryItem[] = [
{ id: '1', uri: 'https://example.com/photo1.jpg', filename: 'photo1.jpg' },
{ id: '2', uri: 'https://example.com/photo2.jpg', filename: 'photo2.jpg' },
]

const GalleryScreen = () => {
const [visible, setVisible] = React.useState(false)

return (
<>
<Button title="Open Gallery" onPress={() => setVisible(true)} />

<ImagePreview
visible={visible}
items={images}
initialIndex={0}
onClose={() => setVisible(false)}
onDownload={(item) => console.log('Download', item.filename)}
onShare={(item) => console.log('Share', item.filename)}
onDelete={(item) => console.log('Delete', item.id)}
/>
</>
)
}
```

## Using with MobX (optional)

```tsx
import { observer } from 'mobx-react-lite'
import previewStore from './state/previewStore'

const App = observer(() => (
<ImagePreview
visible={previewStore.isVisible}
items={previewStore.items}
initialIndex={previewStore.currentIndex}
onClose={() => previewStore.close()}
onDownload={handleDownload}
onShare={handleShare}
onDelete={handleDelete}
/>
))

// Open from anywhere:
previewStore.open(galleryItems, 3)
```

## Props

### ImagePreview

| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `visible` | `boolean` | ✅ | Show/hide modal |
| `items` | `GalleryItem[]` | ✅ | Image list |
| `initialIndex` | `number` | ❌ | Start at this index (default `0`) |
| `onClose` | `() => void` | ✅ | Dismiss callback |
| `onDownload` | `(item) => void` | ❌ | Download action |
| `onShare` | `(item) => void` | ❌ | Share action |
| `onDelete` | `(item) => void` | ❌ | Delete action |
| `renderLoader` | `(item) => ReactElement` | ❌ | Custom loader/placeholder |

### GalleryItem

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | `string` | ✅ | Unique identifier |
| `uri` | `string` | ✅ | Image source URI |
| `thumbnailUri` | `string` | ❌ | Thumbnail for placeholder |
| `type` | `'image' \| 'video'` | ❌ | Media type |
| `filename` | `string` | ❌ | Original file name |
| `metadata` | `Record<string, unknown>` | ❌ | Arbitrary metadata |

## Gesture Reference

| Gesture | Action |
|---------|--------|
| Single tap | Toggle action overlay |
| Double tap | Zoom in at tap point / zoom out |
| Pinch (2 fingers) | Zoom in / out |
| Pan (1 finger, zoomed) | Move image |
| Swipe (1 finger, not zoomed) | Next / previous image |

## Customization

- Replace emoji icons in `ActionOverlay.tsx` with your own `Image` or icon component.
- Adjust `MAX_SCALE` and `MIN_SCALE` constants in `ZoomableImage.tsx`.
- Override `styles` in each component for custom theming.
- Pass `renderLoader` to `ImagePreview` for skeleton placeholders while images load.

## Dependencies

- `react-native` (>= 0.72)
- `mobx` + `mobx-react-lite` (optional — only needed if using `previewStore.ts`)
- TypeScript

## License

MIT
169 changes: 169 additions & 0 deletions bounty-6/components/ActionOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import React, { useEffect, useRef } from 'react'
import {
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
Platform,
StatusBar,
} from 'react-native'
import { GalleryItem } from '../types'

const SAFE_TOP = Platform.OS === 'ios' ? 50 : (StatusBar.currentHeight ?? 24) + 12

interface Props {
visible: boolean
item: GalleryItem | null
currentIndex: number
totalCount: number
onClose: () => void
onDownload?: (item: GalleryItem) => void
onShare?: (item: GalleryItem) => void
onDelete?: (item: GalleryItem) => void
}

const ActionOverlay: React.FC<Props> = ({
visible,
item,
currentIndex,
totalCount,
onClose,
onDownload,
onShare,
onDelete,
}) => {
const opacity = useRef(new Animated.Value(1)).current
const prevVisible = useRef(visible)

useEffect(() => {
if (prevVisible.current !== visible) {
Animated.timing(opacity, {
toValue: visible ? 1 : 0,
duration: 200,
useNativeDriver: true,
}).start()
}
prevVisible.current = visible
}, [visible, opacity])

if (!visible && !prevVisible.current) return null

const handleDownload = () => item && onDownload?.(item)
const handleShare = () => item && onShare?.(item)
const handleDelete = () => item && onDelete?.(item)

return (
<Animated.View style={[styles.container, { opacity }]} pointerEvents={visible ? 'auto' : 'none'}>
{/* Top bar */}
<View style={styles.topBar}>
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}>
<Text style={styles.closeText}>✕</Text>
</TouchableOpacity>

<Text style={styles.counter}>
{totalCount > 1 ? `${currentIndex + 1} / ${totalCount}` : ''}
</Text>

<View style={styles.placeholder} />
</View>

{/* Bottom actions */}
<View style={styles.bottomBar}>
{!!onDownload && (
<TouchableOpacity onPress={handleDownload} style={styles.actionButton}>
<Text style={styles.actionIcon}>⬇</Text>
<Text style={styles.actionLabel}>Save</Text>
</TouchableOpacity>
)}
{!!onShare && (
<TouchableOpacity onPress={handleShare} style={styles.actionButton}>
<Text style={styles.actionIcon}>↗</Text>
<Text style={styles.actionLabel}>Share</Text>
</TouchableOpacity>
)}
{!!onDelete && (
<TouchableOpacity onPress={handleDelete} style={styles.actionButton}>
<Text style={[styles.actionIcon, styles.deleteIcon]}>🗑</Text>
<Text style={[styles.actionLabel, styles.deleteLabel]}>Delete</Text>
</TouchableOpacity>
)}
{!onDownload && !onShare && !onDelete && (
<Text style={styles.emptyHint}>Tap image to show actions</Text>
)}
</View>
</Animated.View>
)
}

const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
justifyContent: 'space-between',
zIndex: 10,
},
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: SAFE_TOP,
paddingHorizontal: 16,
paddingBottom: 12,
},
closeButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(0,0,0,0.45)',
justifyContent: 'center',
alignItems: 'center',
},
closeText: {
color: '#fff',
fontSize: 18,
fontWeight: '700',
},
counter: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
placeholder: {
width: 40,
},
bottomBar: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
paddingHorizontal: 24,
gap: 32,
},
actionButton: {
alignItems: 'center',
justifyContent: 'center',
width: 64,
paddingVertical: 8,
},
actionIcon: {
fontSize: 22,
marginBottom: 4,
},
actionLabel: {
color: '#fff',
fontSize: 12,
fontWeight: '500',
},
deleteIcon: {
color: '#ff4444',
},
deleteLabel: {
color: '#ff4444',
},
emptyHint: {
color: 'rgba(255,255,255,0.6)',
fontSize: 13,
},
})

export default ActionOverlay
Loading