This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
OpenLitterMap is a React Native mobile app (iOS & Android) for crowdsourced litter mapping. Users photograph litter, tag it by category, and upload geotagged data to the OpenLitterMap Laravel backend API.
App Version: 7.1.2 | React Native: 0.84.1 | Branch: openlittermap/v7 (main: main5)
npm install # Install dependencies
npm start # Start Metro bundler
npm run ios # Run on iOS
npm run android # Run on Android
cd ios && bundle exec pod install && cd .. # Install iOS native pods
npm run lint # ESLint
npm test # Jest (no tests exist yet)Runtime: Node v22.22.1, npm 10.9.4 (prefer npm over yarn) — RN 0.84 requires Node ≥ 22.11
Gallery → Select photos → Tag each photo → Upload → Server
- Gallery (
GalleryScreen) — Browse camera roll, select geotagged photos (non-GPS photos blocked) - Home (
HomeScreen) — View selected photos in grid, tap to tag, tap upload to start - Tag (
AddTagScreen) — Full-screen image viewer with search/browse for litter tags, materials, brands - Upload (
HomeScreen) — Two-step: upload photo binary → POST tags. Sequential with progress tracking.
MainRoutes (Stack)
├── [No token] AuthStack → WelcomeScreen → AuthScreen
└── [Has token]
├── TabRoutes (3 tabs)
│ ├── HOME → HomeScreen
│ ├── TEAM → TeamStack (TeamScreen, TopTeams, TeamDetails, TeamLeaderboard)
│ └── USER_STATS → ProfileScreen
├── ADD_TAGS → AddTagScreen (modal)
├── ALBUM → GalleryScreen (modal)
├── SETTING → SettingsScreen (modal)
├── PERMISSION → PermissionStack
├── UPDATE → NewUpdateScreen (modal)
└── MY_UPLOADS → MyUploads (modal)
| Slice | File | Key Data | Persisted |
|---|---|---|---|
auth |
auth_reducer.js |
token, user profile | Yes |
photos |
photos_reducer.js |
imagesArray (local gallery photos + tags), editingPhoto, swiperIndex | Yes (imagesArray only) |
serverPhotos |
server_photos_reducer.js |
untaggedCount, untaggedPreview, editTagsOnPhoto thunk | No |
uploadFlow |
upload_flow_reducer.js |
uploadPhase, counters, modal state, uploadImage/postTagsToPhoto thunks | No |
gallery |
gallery_reducer.js |
CameraRoll photos, GPS metadata | No |
tags |
tags_reducer.js |
Search index, materials, brands (cached 7-day TTL) | AsyncStorage cache |
teams |
team_reducer.js |
User teams, team members, top teams | No |
uploads |
uploads_reducer.js |
Upload history (My Uploads), stats | No |
settings |
settings_reducer.js |
User preferences, privacy toggles | No |
shared |
shared_reducer.js |
App version | No |
stats |
stats_reducer.js |
Global statistics | No |
leaderboard |
leaderboards_reducer.js |
Leaderboard data | No |
locations |
locations_reducer.js |
Location hierarchy | No |
Store configured in store/index.js with redux-persist (AsyncStorage backend). In dev mode, redux-immutable-state-invariant middleware is included.
Tag data is fetched from GET /api/tags/all and cached in AsyncStorage (tags_cache_v5, 7-day TTL).
Key concept — cloId (category_litter_object_id): Unique ID for an (object, category) pair. Objects like "bottle" exist in multiple categories (alcohol, beverages) and are disambiguated by cloId.
Per-image tags stored as:
image.tags = [{ cloId, quantity, materials: [id,...], brands: [{id, quantity}], customTags: ['...'] }]
image.customTags = ['...'] // Image-level custom tags (merged into first tag on upload)
image.picked_up = trueDisplay names resolved at render time from state.tags.entriesByCloId[cloId].
Two-step process orchestrated in HomeScreen.js:
- Upload photo →
POST /api/v3/upload(FormData with photo + GPS) → returnsphoto_id - POST tags →
POST /api/v3/tags(photo_id + resolved tags viabuildTagsPayload)
Pre-upload: GPS validation via isGeotagged() (rejects null, 0,0). Uploaded images bypass GPS check.
On failure: image stays with uploaded: true + tags intact for retry (tag-only path).
On 401: axios interceptor signals abort via uploadAbortReason('token-expired'), recovery flow after re-login.
Cancel: AbortController aborts the in-flight axios request, resets uploadPhase to idle, closes modal.
Custom-tag-only images: When an image has only custom tags (no CLO tags), buildTagsPayload sends { custom: true, key: "tag-text" } entries per the backend's ClassifyTagsService spec. Custom tags are validated: 3–100 chars, /^[\w\s:-]+$/.
- Login:
POST /api/auth/tokenwith{identifier, password}→ returns{token, user} - Token stored in AsyncStorage key
"jwt"and Reduxstate.auth.token - All requests use
Authorization: Bearer {token}via axios - On boot:
checkValidTokenvalidates stored JWT, awaitsfetchUserbefore rendering fetchUserretries 2× with 1s/3s backoff for transient errors (timeout, network, 5xx). Only clears session on 401.- Global 401 interceptor in
utils/setupAxiosInterceptors.js(30s timeout)
All endpoints verified against Laravel backend. See readme/AUDIT.md §2 for complete table with payload/response details.
| Area | Endpoints | Key Routes |
|---|---|---|
| Auth | 5 | /api/auth/token, /api/auth/register, /api/user/profile/index, /api/validate-token, /api/password/email |
| Images | 4 | /api/v3/upload, /api/v3/tags (POST & PUT), /api/v3/user/photos |
| My Uploads | 3 | /api/v3/user/photos, /api/v3/user/photos/stats, /api/profile/photos/delete |
| Tags | 1 | /api/tags/all |
| Teams | 8 | /api/teams/{create,join,leave,active,inactivate,members,leaderboard,list} |
| Settings | 4 | /api/settings/update/, /api/settings (PATCH), /api/settings/privacy/{endpoint}, /api/settings/delete-account/ |
| Other | 6 | Leaderboard, stats, app version, locations (2), XP levels |
├── actions/types.js # Environment config, API URL selection
├── store/index.js # Redux store + persist config
├── reducers/ # 14 Redux slices (all use createSlice + createAsyncThunk)
├── routes/ # React Navigation v6 navigators
├── screens/
│ ├── home/ # HomeScreen (upload orchestration) + homeComponents/
│ ├── addTag/ # AddTagScreen + components/ (TagPills, TagSearchBar, TagDetailSheet, etc.)
│ ├── gallery/ # GalleryScreen + galleryComponents/
│ ├── auth/ # WelcomeScreen, AuthScreen + authComponents/
│ ├── team/ # TeamScreen, TeamDetailsScreen, TopTeamsScreen, TeamLeaderboardScreen
│ ├── userStats/ # UserStatsScreen + userComponents/ (MyUploads, ProgressCircleCard)
│ ├── profile/ # ProfileScreen + helpers/
│ ├── setting/ # SettingsScreen + settingComponents/
│ ├── permission/ # CameraPermissionScreen, GalleryPermissionScreen
│ ├── components/ # Shared: theme/, typography/, Button, Header, CustomTextInput, etc.
│ └── NewUpdateScreen.js
├── utils/
│ ├── gps.js # GPS coordinate validation (isValidGpsCoords)
│ ├── isGeotagged.js # Image GPS check (uses gps.js)
│ ├── isTagged.js # Check image has CLO/custom tags
│ ├── readGpsFromExif.js # Android EXIF GPS fallback
│ ├── buildTagsPayload.js # Convert tags → POST format
│ ├── classifyError.js # Classify upload errors for UI + Sentry
│ ├── getTagsFromBackend.js # Convert backend new_tags → local tags
│ ├── formatKey.js # snake_case → Title Case
│ ├── setupAxiosInterceptors.js # Global 401 handler + 30s timeout
│ ├── dayjs.js # dayjs plugins
│ └── permissions/ # Camera, camera roll, location permission helpers
├── assets/langs/ # 8 languages: en, ar, de, es, fr, ie, nl, pt
│ └── {lang}/ # {lang}.json (flat UI strings) + litter.json (nested litter taxonomy)
└── i18n.js # i18next configuration
i18next with react-i18next. Translation keys are full British English string literals (key = English display text).
- UI strings:
assets/langs/{lang}/{lang}.json— flat structure, sorted A-Z - Litter taxonomy:
assets/langs/{lang}/litter.json— nested by category, 174 keys per language - Access:
t('Your string')for UI,t('litter.smoking.butts')for litter
When adding a new user-facing string: Add to en/en.json (key = value), then translate and add to ALL 7 other language files. Keep sorted A-Z.
When adding a new litter key: Add to en/litter.json in the appropriate section, then translate and add to ALL other litter.json files.
.env— NEVER read, modify, or delete. Contains production secrets, signing keys, and credentials used by build tooling outside the JS codebase..gitignore— Do not read or modify.
- ESLint:
@react-nativeconfig, 4-space indentation - Prettier: single quotes, no bracket spacing, arrow parens avoided, trailing commas
- Mixed JS/TS (newer files tend to be TypeScript)
- Screens export via barrel files (
index.jsorindex.ts)
@shopify/flash-list— performant listsformik+yup— form handling/validationreact-native-gesture-handlerv2 +react-native-reanimatedv3 — image viewer gesturesreact-native-permissions— camera/location/photo library (iOS permissions inreactNativePermissionsIOSin package.json)@sentry/react-native— error tracking (production only)@lodev09/react-native-exify— Android EXIF GPS fallbackdayjs— date formattinglottie-react-native— onboarding animations
- Laravel:
http://0.0.0.0:8000(serves on all interfaces), web viaolm.test(Laravel Valet) - Minio:
http://127.0.0.1:9000(S3-compatible storage) - Mobile API:
http://192.168.1.28:8000(LAN IP inactions/types.js) - Minio image URLs: Stored as
http://127.0.0.1:9000/...which the phone can't reach.ImageViewer.jsrewrites127.0.0.1to the LAN host in dev builds. Long-term fix: setAWS_URL=http://192.168.1.28:9000/olm-publicin Laravel.env. - See
readme/LocalDev.mdfor full setup details including tag data structure.
- BUG-11:
TopTeamsScreenuses fake 3s loading instead of actual API loading state - No test coverage: Jest configured but no test files exist
- Upload state consolidated: Upload modal + phase now in single
uploadFlowslice (formerly split acrosssharedandimages)
- Xcode 26+/macOS Tahoe: Sentry Cocoa SDK < 8.46.0 fails to compile. The
postinstallscript patches the RNSentry podspec to use 8.46.0. Afternpm install, runcd ios && pod update Sentry && cd ..ifPodfile.lockstill references an older version. - After modifying native dependencies: clean Xcode build folder (Cmd+Shift+K) and rebuild.
Detailed documentation for each feature area lives in readme/:
| File | Covers |
|---|---|
AUDIT.md |
Start here — Complete file inventory, all 31 API endpoints with payloads/responses, Redux state map, navigation tree, bug tracker, dependency list |
MobileUpload.md |
Upload flow, two-step process, GPS validation, error classification, retry behavior |
MobileTagging.md |
CLO tagging system, search index, tag pills, detail sheet, category colors, XP estimate |
MobileGallery.md |
Camera roll access, GPS detection, EXIF fallback, pagination strategies, gesture selection |
MobileAuth.md |
Sanctum auth, onboarding UI, animated slides, password strength, language picker |
MobileTeams.md |
Team CRUD, members, leaderboard |
MobileSettings.md |
Settings, privacy toggles, account deletion |
MobileNavigation.md |
Navigation structure (authoritative — 3 tabs, not 5) |
MobilePermissions.md |
iOS/Android permission handling |
MobileMyUploads.md |
Upload history, filters, swipe actions |
BackendAPI.md |
Backend API architecture and field mappings |
BackendMobileApi.md |
Mobile-specific API contracts |
BackendTagging.md |
Backend tagging architecture, XP calculation |
BackendTagsConfig.md |
Backend TagsConfig source of truth |
BackendLocations.md |
Backend location resolution (design doc) |
XP.md |
XP formula — backend awards correctly; mobile preview is incomplete (doesn't handle special object bonuses) |
GPS_AUDIT.md |
GPS handling audit (thorough, accurate) |
LocalDev.md |
Local development setup, tag data structure |
When the user says "BOOP", perform all of the following:
- Determine if the change is a new feature (minor bump) or a fix/improvement (patch bump). Ask if unsure
- Bump the appropriate version in
package.json - Append a one-line entry to
readme/changelog/YYYY-MM-DD.md(today's date) - Update any readme docs (
readme/*.md) affected by the changes - Update any skills files affected by the changes