Apply your knowledge to build something amazing!
SpotFinder is a location-based discovery application that helps users explore their surroundings, search for places of interest, and save their favorites for quick access. Using Google Maps SDK and Places API, you'll create an interactive map experience that provides detailed information about restaurants, cafes, parks, and other points of interest.
This project combines real-time location services with powerful search capabilities, creating a practical tool users will want to keep on their phones. You'll implement core mobile development patterns including API integration, local data persistence, and navigation between multiple screens. The result is a portfolio-ready application that demonstrates your ability to work with third-party SDKs and build location-aware mobile experiences.
By the end of this project, you'll have built a fully functional discovery app that you can expand with additional features or customize for specific use cases like finding pet-friendly locations, accessibility-focused venues, or local events.
By completing this project, you will:
src/
├── components/
│ ├── MapView.js
│ ├── PlaceMarker.js
│ ├── SearchBar.js
│ ├── PlaceCard.js
│ ├── CategoryFilter.js
│ └── FavoritesList.js
├── screens/
│ ├── HomeScreen.js
│ ├── PlaceDetailsScreen.js
│ ├── FavoritesScreen.js
│ └── SettingsScreen.js
├── services/
│ ├── placesAPI.js
│ ├── locationService.js
│ └── favoritesService.js
├── hooks/
│ ├── useLocation.js
│ ├── usePlaces.js
│ └── useFavorites.js
├── utils/
│ ├── mapHelpers.js
│ └── constants.js
└── navigation/
└── AppNavigator.js
The app follows a unidirectional data flow pattern. User location is fetched on app launch and passed to the MapView component. When users search for places, the Places API returns results that are displayed as markers. Selecting a marker updates the app state and navigates to the PlaceDetailsScreen. Favorites are managed through a context provider that syncs with AsyncStorage, ensuring data persists across app restarts.
Goal: Set up project structure and core map functionality
Initialize your Expo project and install required dependencies.
npx create-expo-app SpotFinder
cd SpotFinder
npx expo install react-native-maps expo-location
npm install @react-navigation/native @react-navigation/stack
npx expo install react-native-gesture-handler react-native-reanimated
Configure your app.json with necessary permissions:
{
"expo": {
"name": "SpotFinder",
"slug": "spotfinder",
"plugins": [
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow SpotFinder to access your location for discovering nearby places."
}
]
],
"ios": {
"config": {
"googleMapsApiKey": "YOUR_IOS_API_KEY"
}
},
"android": {
"config": {
"googleMaps": {
"apiKey": "YOUR_ANDROID_API_KEY"
}
}
}
}
}
Create the location service to handle permission requests and position updates.
// src/services/locationService.js
import * as Location from 'expo-location';
export const requestLocationPermission = async () => {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
return status === 'granted';
} catch (error) {
console.error('Permission error:', error);
return false;
}
};
export const getCurrentLocation = async () => {
try {
const location = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
return {
latitude: location.coords.latitude,
longitude: location.coords.longitude,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
};
} catch (error) {
console.error('Location fetch error:', error);
throw error;
}
};
Create the home screen with an interactive map.
// src/screens/HomeScreen.js
import React, { useState, useEffect } from 'react';
import { View, StyleSheet, Alert } from 'react-native';
import MapView, { Marker } from 'react-native-maps';
import { getCurrentLocation, requestLocationPermission } from '../services/locationService';
export default function HomeScreen() {
const [region, setRegion] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
initializeLocation();
}, []);
const initializeLocation = async () => {
const hasPermission = await requestLocationPermission();
if (!hasPermission) {
Alert.alert(
'Location Permission',
'SpotFinder needs location access to show nearby places.',
[{ text: 'OK' }]
);
setLoading(false);
return;
}
try {
const location = await getCurrentLocation();
setRegion(location);
} catch (error) {
Alert.alert('Error', 'Failed to get your location. Please try again.');
} finally {
setLoading(false);
}
};
if (loading || !region) {
return <View style={styles.container} />;
}
return (
<View style={styles.container}>
<MapView
style={styles.map}
initialRegion={region}
showsUserLocation
showsMyLocationButton
>
<Marker coordinate={region} title="You are here" />
</MapView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
map: {
width: '100%',
height: '100%',
},
});
Goal: Implement Places API integration and search functionality
Create the service to interact with Google Places API.
// src/services/placesAPI.js
const GOOGLE_PLACES_API_KEY = 'YOUR_API_KEY';
const PLACES_API_BASE = 'https://maps.googleapis.com/maps/api/place';
export const searchNearbyPlaces = async (latitude, longitude, type = 'restaurant', radius = 1500) => {
try {
const url = `${PLACES_API_BASE}/nearbysearch/json?location=${latitude},${longitude}&radius=${radius}&type=${type}&key=${GOOGLE_PLACES_API_KEY}`;
const response = await fetch(url);
const data = await response.json();
if (data.status === 'OK') {
return data.results.map(place => ({
id: place.place_id,
name: place.name,
vicinity: place.vicinity,
rating: place.rating,
latitude: place.geometry.location.lat,
longitude: place.geometry.location.lng,
types: place.types,
photos: place.photos,
}));
}
throw new Error(data.status);
} catch (error) {
console.error('Places API error:', error);
throw error;
}
};
export const getPlaceDetails = async (placeId) => {
try {
const url = `${PLACES_API_BASE}/details/json?place_id=${placeId}&fields=name,rating,formatted_phone_number,opening_hours,website,photos,reviews&key=${GOOGLE_PLACES_API_KEY}`;
const response = await fetch(url);
const data = await response.json();
if (data.status === 'OK') {
return data.result;
}
throw new Error(data.status);
} catch (error) {
console.error('Place details error:', error);
throw error;
}
};
export const getPhotoUrl = (photoReference, maxWidth = 400) => {
return `${PLACES_API_BASE}/photo?maxwidth=${maxWidth}&photoreference=${photoReference}&key=${GOOGLE_PLACES_API_KEY}`;
};
Add search functionality with category filters.
// src/components/SearchBar.js
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, StyleSheet, ScrollView, Text } from 'react-native';
const CATEGORIES = [
{ id: 'restaurant', label: 'Restaurants', icon: '🍽️' },
{ id: 'cafe', label: 'Cafes', icon: '☕' },
{ id: 'park', label: 'Parks', icon: '🌳' },
{ id: 'gym', label: 'Gyms', icon: '💪' },
{ id: 'shopping_mall', label: 'Shopping', icon: '🛍️' },
];
export default function SearchBar({ onSearch, onCategorySelect }) {
const [selectedCategory, setSelectedCategory] = useState('restaurant');
const handleCategoryPress = (categoryId) => {
setSelectedCategory(categoryId);
onCategorySelect(categoryId);
};
return (
<View style={styles.container}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.categories}
>
{CATEGORIES.map((category) => (
<TouchableOpacity
key={category.id}
style={[
styles.categoryButton,
selectedCategory === category.id && styles.categoryButtonActive
]}
onPress={() => handleCategoryPress(category.id)}
>
<Text style={styles.categoryIcon}>{category.icon}</Text>
<Text style={styles.categoryLabel}>{category.label}</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 60,
left: 0,
right: 0,
backgroundColor: 'white',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
categories: {
paddingHorizontal: 10,
paddingVertical: 10,
},
categoryButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 15,
paddingVertical: 8,
marginRight: 10,
borderRadius: 20,
backgroundColor: '#f5f5f5',
},
categoryButtonActive: {
backgroundColor: '#4285F4',
},
categoryIcon: {
fontSize: 18,
marginRight: 5,
},
categoryLabel: {
fontSize: 14,
fontWeight: '600',
},
});
Display search results as interactive markers.
// src/components/PlaceMarker.js
import React from 'react';
import { Marker } from 'react-native-maps';
import { View, Text, StyleSheet } from 'react-native';
const getMarkerColor = (types) => {
if (types.includes('restaurant')) return '#FF5722';
if (types.includes('cafe')) return '#8D6E63';
if (types.includes('park')) return '#4CAF50';
if (types.includes('gym')) return '#FF9800';
return '#2196F3';
};
export default function PlaceMarker({ place, onPress }) {
const markerColor = getMarkerColor(place.types);
return (
<Marker
coordinate={{
latitude: place.latitude,
longitude: place.longitude,
}}
onPress={() => onPress(place)}
pinColor={markerColor}
>
<View style={[styles.marker, { backgroundColor: markerColor }]}>
<Text style={styles.markerText}>{place.name[0]}</Text>
</View>
</Marker>
);
}
const styles = StyleSheet.create({
marker: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: 'white',
},
markerText: {
color: 'white',
fontWeight: 'bold',
fontSize: 16,
},
});
Goal: Connect favorites system and navigation
Implement persistent favorites storage.
// src/services/favoritesService.js
import AsyncStorage from '@react-native-async-storage/async-storage';
const FAVORITES_KEY = '@spotfinder_favorites';
export const getFavorites = async () => {
try {
const favoritesJson = await AsyncStorage.getItem(FAVORITES_KEY);
return favoritesJson ? JSON.parse(favoritesJson) : [];
} catch (error) {
console.error('Error loading favorites:', error);
return [];
}
};
export const addFavorite = async (place) => {
try {
const favorites = await getFavorites();
const newFavorites = [...favorites, { ...place, savedAt: new Date().toISOString() }];
await AsyncStorage.setItem(FAVORITES_KEY, JSON.stringify(newFavorites));
return newFavorites;
} catch (error) {
console.error('Error adding favorite:', error);
throw error;
}
};
export const removeFavorite = async (placeId) => {
try {
const favorites = await getFavorites();
const newFavorites = favorites.filter(fav => fav.id !== placeId);
await AsyncStorage.setItem(FAVORITES_KEY, JSON.stringify(newFavorites));
return newFavorites;
} catch (error) {
console.error('Error removing favorite:', error);
throw error;
}
};
export const isFavorite = async (placeId) => {
const favorites = await getFavorites();
return favorites.some(fav => fav.id === placeId);
};
Create detailed view with favorites toggle and directions.
// src/screens/PlaceDetailsScreen.js
import React, { useState, useEffect } from 'react';
import { View, Text, Image, TouchableOpacity, StyleSheet, ScrollView, Linking } from 'react-native';
import { getPlaceDetails, getPhotoUrl } from '../services/placesAPI';
import { addFavorite, removeFavorite, isFavorite } from '../services/favoritesService';
export default function PlaceDetailsScreen({ route, navigation }) {
const { place } = route.params;
const [details, setDetails] = useState(null);
const [favorite, setFavorite] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadPlaceDetails();
checkFavoriteStatus();
}, []);
const loadPlaceDetails = async () => {
try {
const data = await getPlaceDetails(place.id);
setDetails(data);
} catch (error) {
console.error('Failed to load details:', error);
} finally {
setLoading(false);
}
};
const checkFavoriteStatus = async () => {
const status = await isFavorite(place.id);
setFavorite(status);
};
const toggleFavorite = async () => {
try {
if (favorite) {
await removeFavorite(place.id);
setFavorite(false);
} else {
await addFavorite(place);
setFavorite(true);
}
} catch (error) {
console.error('Favorite toggle error:', error);
}
};
const openDirections = () => {
const url = `https://www.google.com/maps/dir/?api=1&destination=${place.latitude},${place.longitude}`;
Linking.openURL(url);
};
if (loading) {
return <View style={styles.container}><Text>Loading...</Text></View>;
}
return (
<ScrollView style={styles.container}>
{details?.photos && (
<Image
source={{ uri: getPhotoUrl(details.photos[0].photo_reference, 800) }}
style={styles.headerImage}
/>
)}
<View style={styles.content}>
<View style={styles.header}>
<Text style={styles.name}>{place.name}</Text>
<TouchableOpacity onPress={toggleFavorite}>
<Text style={styles.favoriteIcon}>{favorite ? '❤️' : '🤍'}</Text>
</TouchableOpacity>
</View>
{place.rating && (
<Text style={styles.rating}>⭐ {place.rating} / 5.0</Text>
)}
<Text style={styles.address}>{place.vicinity}</Text>
{details?.formatted_phone_number && (
<TouchableOpacity onPress={() => Linking.openURL(`tel:${details.formatted_phone_number}`)}>
<Text style={styles.phone}>📞 {details.formatted_phone_number}</Text>
</TouchableOpacity>
)}
{details?.website && (
<TouchableOpacity onPress={() => Linking.openURL(details.website)}>
<Text style={styles.website}>🌐 Visit Website</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.directionsButton} onPress={openDirections}>
<Text style={styles.directionsText}>🗺️ Get Directions</Text>
</TouchableOpacity>
{details?.opening_hours && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Hours</Text>
{details.opening_hours.weekday_text.map((day, index) => (
<Text key={index} style={styles.hours}>{day}</Text>
))}
</View>
)}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
headerImage: {
width: '100%',
height: 250,
},
content: {
padding: 20,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
name: {
fontSize: 24,
fontWeight: 'bold',
flex: 1,
},
favoriteIcon: {
fontSize: 32,
},
rating: {
fontSize: 16,
marginTop: 5,
color: '#666',
},
address: {
fontSize: 14,
marginTop: 10,
color: '#888',
},
phone: {
fontSize: 16,
marginTop: 15,
color: '#4285F4',
},
website: {
fontSize: 16,
marginTop: 10,
color: '#4285F4',
},
directionsButton: {
backgroundColor: '#4285F4',
padding: 15,
borderRadius: 8,
marginTop: 20,
alignItems: 'center',
},
directionsText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
section: {
marginTop: 20,
},
sectionTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 10,
},
hours: {
fontSize: 14,
color: '#666',
marginBottom: 5,
},
});
Goal: Testing, optimization, and deployment
Build the favorites management screen.
// src/screens/FavoritesScreen.js
import React, { useState, useCallback } from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { getFavorites } from '../services/favoritesService';
export default function FavoritesScreen({ navigation }) {
const [favorites, setFavorites] = useState([]);
const [loading, setLoading] = useState(true);
useFocusEffect(
useCallback(() => {
loadFavorites();
}, [])
);
const loadFavorites = async () => {
try {
const data = await getFavorites();
setFavorites(data);
} catch (error) {
console.error('Failed to load favorites:', error);
} finally {
setLoading(false);
}
};
const renderFavorite = ({ item }) => (
<TouchableOpacity
style={styles.favoriteItem}
onPress={() => navigation.navigate('PlaceDetails', { place: item })}
>
<View style={styles.favoriteInfo}>
<Text style={styles.favoriteName}>{item.name}</Text>
<Text style={styles.favoriteAddress}>{item.vicinity}</Text>
{item.rating && (
<Text style={styles.favoriteRating}>⭐ {item.rating}</Text>
)}
</View>
<Text style={styles.arrow}>›</Text>
</TouchableOpacity>
);
if (loading) {
return (
<View style={styles.container}>
<Text>Loading favorites...</Text>
</View>
);
}
if (favorites.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No favorites yet</Text>
<Text style={styles.emptySubtext}>Start exploring and save your favorite places!</Text>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={favorites}
renderItem={renderFavorite}
keyExtractor={item => item.id}
contentContainerStyle={styles.list}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
list: {
padding: 10,
},
favoriteItem: {
flexDirection: 'row',
backgroundColor: 'white',
padding: 15,
marginBottom: 10,
borderRadius: 8,
alignItems: 'center',
},
favoriteInfo: {
flex: 1,
},
favoriteName: {
fontSize: 18,
fontWeight: 'bold',
},
favoriteAddress: {
fontSize: 14,
color: '#666',
marginTop: 5,
},
favoriteRating: {
fontSize: 14,
marginTop: 5,
},
arrow: {
fontSize: 24,
color: '#ccc',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
emptyText: {
fontSize: 20,
fontWeight: 'bold',
color: '#666',
},
emptySubtext: {
fontSize: 16,
color: '#999',
marginTop: 10,
textAlign: 'center',
},
});
💡 Tip: Test your app on both iOS and Android devices to ensure map rendering and location services work correctly on both platforms.
| Criteria | Points | Description |
|---|---|---|
| Functionality | 40 | All features work: map display, search, markers, details, favorites, directions |
| Code Quality | 25 | Clean code, proper error handling, efficient API usage, good component structure |
| Design/UX | 20 | Intuitive navigation, responsive layout, smooth animations, attractive UI |
| Documentation | 10 | Clear README, code comments, setup instructions, API key configuration guide |
| Creativity | 5 | Extra features, unique design touches, performance optimizations |
| Total | 100 |
| Week | Milestone | Deliverable |
|---|---|---|
| 1 | Phase 1-2 Complete | Working map with location, search, and markers displaying |
| 2 | Phase 3-4 Complete | Full app with favorites, details, and deployed to Expo |
react-native-maps/react-native-mapsFor students who finish early:
eas build or publish with expo publishAfter completing this project: