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
269 changes: 269 additions & 0 deletions App.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
// App.js
// React Native (Expo) example implementing navigation, map markers, event details,
// dynamic status box, volunteer apply/unapply, and map fitting to user + events.

import React, { useRef, useState, useContext, createContext, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Alert, Platform } from 'react-native';
import MapView, { Marker } from 'react-native-maps';
import { NavigationContainer, useFocusEffect } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';

// ---------- Mock data and context ----------
const currentUser = { id: 'user-1', name: 'Alice' };

const initialEvents = [
{
id: 'e1',
title: 'Park Clean-up',
description: 'Help clean the neighbourhood park',
coordinate: { latitude: 51.0447, longitude: -114.0719 },
required: 5,
volunteers: ['user-2'],
contact: { phone: '+11234567890' }
},
{
id: 'e2',
title: 'Food Bank Sorting',
description: 'Sort donated food',
coordinate: { latitude: 51.0486, longitude: -114.0708 },
required: 3,
volunteers: ['user-1','user-3'],
contact: { phone: '+19876543210' }
},
{
id: 'e3',
title: 'Community Garden',
description: 'Plant seedlings',
coordinate: { latitude: 51.0500, longitude: -114.0620 },
required: 4,
volunteers: [],
contact: { phone: '+10123456789' }
}
];

const EventsContext = createContext();

function EventsProvider({ children }) {
const [events, setEvents] = useState(initialEvents);

const toggleVolunteer = (eventId, userId) => {
setEvents(prev => prev.map(ev => {
if (ev.id !== eventId) return ev;
const has = ev.volunteers.includes(userId);
return {
...ev,
volunteers: has ? ev.volunteers.filter(id => id !== userId) : [...ev.volunteers, userId]
};
}));
};

return (
<EventsContext.Provider value={{ events, setEvents, toggleVolunteer, currentUser }}>
{children}
</EventsContext.Provider>
);
}

// ---------- Screens ----------
function MainScreen({ navigation }) {
const { events, currentUser } = useContext(EventsContext);
const mapRef = useRef(null);

// Mock user location (in a real app you'd request permission and watch position)
const userLocation = { latitude: 51.046, longitude: -114.070, latitudeDelta: 0.01, longitudeDelta: 0.01 };

// Fit map to markers + user location whenever screen is focused or events change
useFocusEffect(
React.useCallback(() => {
fitMap();
}, [events])
);

const fitMap = () => {
if (!mapRef.current || events.length === 0) return;
const coords = events.map(e => e.coordinate);
coords.push({ latitude: userLocation.latitude, longitude: userLocation.longitude });
mapRef.current.fitToCoordinates(coords, {
edgePadding: { top: 80, right: 40, bottom: 180, left: 40 },
animated: true,
});
};

const onMarkerPress = (event) => {
navigation.navigate('EventDetails', { eventId: event.id });
};

// Determine bottom label based on event states
const total = events.length;
const appliedCount = events.filter(e => e.volunteers.includes(currentUser.id)).length;
const fullCount = events.filter(e => e.volunteers.length >= e.required).length;

const bottomLabel = `Events: ${total} · Applied: ${appliedCount} · Full: ${fullCount}`;

return (
<View style={styles.container}>
<MapView
style={styles.map}
ref={mapRef}
initialRegion={userLocation}
showsUserLocation={true}
>
{events.map(ev => {
const isFull = ev.volunteers.length >= ev.required;
const isApplied = ev.volunteers.includes(currentUser.id);
// marker color variation based on state
const pinColor = isApplied ? 'green' : isFull ? 'gray' : 'red';
return (
<Marker
key={ev.id}
coordinate={ev.coordinate}
title={ev.title}
description={ev.description}
pinColor={pinColor}
onPress={() => onMarkerPress(ev)}
/>
);
})}
</MapView>

<View style={styles.bottomBar}>
<Text style={styles.bottomLabel}>{bottomLabel}</Text>
</View>

<TouchableOpacity
style={styles.centerButton}
onPress={() => fitMap()}
>
<Text style={{ color: '#fff' }}>Recenter</Text>
</TouchableOpacity>
</View>
);
}

function EventDetails({ route, navigation }) {
const { eventId } = route.params;
const { events, toggleVolunteer, currentUser } = useContext(EventsContext);
const event = events.find(e => e.id === eventId);

if (!event) return (
<View style={styles.center}><Text>Event not found</Text></View>
);

const isApplied = event.volunteers.includes(currentUser.id);
const isFull = event.volunteers.length >= event.required;

// Dynamic status box text
let statusText = '';
if (isApplied) statusText = 'Volunteered';
else if (isFull) statusText = 'Team is full';
else statusText = `${event.volunteers.length} of ${event.required} volunteers`;

const onVolunteerPress = () => {
if (isFull) return;
toggleVolunteer(event.id, currentUser.id);
Alert.alert(isApplied ? 'Unapplied' : 'Applied', isApplied ? 'You left the event.' : 'You applied to this event.');
};

const onCall = () => {
// On a real device you'd use Linking.openURL('tel:...')
Alert.alert('Call', `Would call ${event.contact.phone}`);
};

const onText = () => {
// In real app: Linking.openURL('sms:...')
Alert.alert('Text', `Would text ${event.contact.phone}`);
};

const onShare = () => {
Alert.alert('Share', `Share event: ${event.title}`);
};

return (
<View style={styles.detailsContainer}>
<Text style={styles.title}>{event.title}</Text>
<Text style={styles.description}>{event.description}</Text>

<View style={[styles.statusBox, isApplied ? styles.statusApplied : isFull ? styles.statusFull : styles.statusOpen]}>
<Text style={styles.statusText}>{statusText}</Text>
</View>

<View style={styles.buttonRow}>
{/* Contact buttons - only for applied users */}
{isApplied && (
<>
<TouchableOpacity style={styles.actionButton} onPress={onCall}><Text>Call</Text></TouchableOpacity>
<TouchableOpacity style={styles.actionButton} onPress={onText}><Text>Text</Text></TouchableOpacity>
</>
)}

{/* Volunteer button - only when not full and not applied */}
{!isFull && !isApplied && (
<TouchableOpacity style={[styles.primaryButton]} onPress={onVolunteerPress}><Text style={{ color: '#fff' }}>Volunteer</Text></TouchableOpacity>
)}

{/* Share button - when not full or when the user has applied */}
{(!isFull || isApplied) && (
<TouchableOpacity style={styles.actionButton} onPress={onShare}><Text>Share</Text></TouchableOpacity>
)}
</View>

</View>
);
}

// ---------- Navigation ----------
const Stack = createStackNavigator();

export default function App() {
return (
<EventsProvider>
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Main" component={MainScreen} options={{ title: 'Events Map' }} />
<Stack.Screen name="EventDetails" component={EventDetails} options={{ title: 'Event Details' }} />
</Stack.Navigator>
</NavigationContainer>
</EventsProvider>
);
}

// ---------- Styles ----------
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
bottomBar: {
position: 'absolute',
left: 20,
right: 20,
bottom: 20,
backgroundColor: '#fff',
padding: 12,
borderRadius: 10,
shadowColor: '#000',
shadowOpacity: 0.1,
shadowRadius: 6,
elevation: 4,
alignItems: 'center'
},
bottomLabel: { fontSize: 14 },
centerButton: {
position: 'absolute',
right: 20,
bottom: 100,
backgroundColor: '#007aff',
padding: 10,
borderRadius: 8
},
detailsContainer: { flex: 1, padding: 20 },
title: { fontSize: 22, fontWeight: '600', marginBottom: 8 },
description: { fontSize: 16, marginBottom: 16 },
statusBox: { padding: 12, borderRadius: 8, marginBottom: 16 },
statusText: { fontWeight: '600' },
statusApplied: { backgroundColor: '#dff0d8' },
statusFull: { backgroundColor: '#f0f0f0' },
statusOpen: { backgroundColor: '#fff4e5' },
buttonRow: { flexDirection: 'row', gap: 10, alignItems: 'center', flexWrap: 'wrap' },
actionButton: { padding: 10, borderRadius: 8, borderWidth: 1, borderColor: '#ccc', marginRight: 8 },
primaryButton: { padding: 10, borderRadius: 8, backgroundColor: '#28a745', marginRight: 8 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' }
});
111 changes: 87 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,101 @@
# Volunteam App
# Assignment Task 1 – Event Map Volunteer App

## Setting up the fake API (json-server)
This project is a React Native / Expo application that displays community events on a map and lets users view details and apply as volunteers. It demonstrates problem‑solving skills, state management, offline caching, and automated testing for a mobile app.

Update the file `src/services/api.ts`.
## Features

Before running your 'json-server', get your computer's IP address and update your baseURL to `http://your_ip_address_here:3333` and then run:
- Shows a map with markers for multiple community events.
- Taps on a marker navigate to an event details screen.
- Users can apply or unapply as volunteers for an event.
- Shows contact options (Call / Text) for events where the current user is already a volunteer.
- Fetches events from a remote API with offline caching in async storage.
- Basic Jest test suite using React Native Testing Library.

```
npx json-server --watch db.json --port 3333 --host your_ip_address_here -m ./node_modules/json-server-auth
```
## Technology Stack

To access your server online without running json-server locally, you can set your baseURL to:
- **Runtime / Framework**: React Native, Expo
- **Language**: JavaScript / TypeScript tooling
- **Navigation**: `@react-navigation/native`, `@react-navigation/stack`
- **State / Storage**: `@react-native-async-storage/async-storage`
- **Networking / Connectivity**: `fetch`, `@react-native-community/netinfo`
- **Testing**: Jest, `@testing-library/react-native`, `@testing-library/jest-native`
- **Misc**: `react-native-maps`, UUID, and other typical Expo RN dependencies

```
https://my-json-server.typicode.com/<your-github-username>/<your-github-repo>
```
## Project Structure

To use `my-json-server`, make sure your `db.json` is located at the repo root.
- `src/utils/eventService.js` – Fetches events from the API and manages local cache using AsyncStorage and NetInfo.
- `__tests__/events.test.js` – Integration-style tests for the main event map flow.
- `__mocks__/fileMock.js` – Jest mock for static file imports.
- `jest.config.js` – Jest configuration for React Native and asset mapping.
- `package.json` / `yarn.lock` / `package-lock.json` – Dependencies and scripts.

## Setting up the image upload API
## Event Fetching and Offline Cache

Update the file `src/services/imageApi.ts`.
The `fetchEventsWithCache` function in `src/utils/eventService.js`:

You can use any hosting service of your preference. In this case, we will use ImgBB API: https://api.imgbb.com/.
Sign up for free at https://imgbb.com/signup, get your API key and add it to the .env file in your root folder.
- Checks network connectivity using NetInfo.
- If online:
- Calls a configured events API endpoint.
- Parses the JSON response.
- Persists it into AsyncStorage under the `@events_cache` key.
- If offline or any error occurs:
- Attempts to read and return the last cached events from AsyncStorage.
- Falls back to an empty array when no cache is available.

To run the app in your local environment, you will need to set the IMGBB_API_KEY when starting the app using:
This design allows the events list to keep working with previously loaded data even when the device loses connectivity.

```
IMGBB_API_KEY="insert_your_api_key_here" npx expo start
```
## Testing

When creating your app build or publishing, import your secret values to EAS running:
The test suite in `__tests__/events.test.js` uses React Native Testing Library and Jest to verify key user flows:

- Renders the main screen with the map and event markers (map is mocked to avoid native rendering issues).
- Pressing a marker navigates to the event details screen and shows an event title.
- Users can:
- Press the **Volunteer** button to apply.
- Press the button again to unapply.
- See text feedback update when applying/unapplying.
- For an event where the current user is already a volunteer (e.g. “Food Bank Sorting”), the details screen shows **Call** and **Text** contact buttons.

`jest.config.js` configures React Native preset, jsdom test environment, asset mocks, and the Jest Native matchers.

## Scripts

Common scripts in `package.json`:

- `npm start` / `yarn start` – Start the Expo development server.
- `npm run android` – Run the app on an Android emulator or device.
- `npm run ios` – Run the app on an iOS simulator or device.
- `npm run web` – Run the app in a web browser (Expo for web).
- `npm test` – Run Jest tests.

> Note: Make sure to run `npm install` or `yarn install` before starting or testing the application.

## Getting Started

1. Clone the repository:
git clone https://github.com/Nicejv129/assignment-task1.git
cd assignment-task1

2. Install dependencies:
npm install

or
yarn install

3. Configure the events API endpoint in `src/utils/eventService.js` (`EVENTS_API` constant).
npm start

4. Start the app:

5. Run tests:
npm test


## Assignment Context

This repository is part of “Project 2 – Test Application by Demonstrating Problem Solving Skills” from the course template. It focuses on:

- Adding a network/caching service for events.
- Writing automated tests around navigation and user interaction.
- Updating project configuration and dependencies to support testing in React Native.

```
eas secret:push
```
1 change: 1 addition & 0 deletions _mocks_/fileMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = 'test-file-stub';
Loading