Building a Tarot Journaling App with React Native + Tarot API
Complete tutorial for building a tarot journal app from scratch - daily readings, history tracking, notes, card search, and share features. Full React Native code with Expo and RoxyAPI integration.
Building a Tarot Journaling App with React Native + Tarot API
Want to build a tarot journaling app that users will love? This complete React Native tutorial walks you through creating a production-ready wrtarot journal mobile app using React Native, Expo, and the RoxyAPI Tarot Card API. We'll implement daily tarot card readings, reading history tracking with personal notes, tarot card database search, spread selection (three-card, Celtic Cross, love, career), and social sharing features - everything you need to launch a professional tarot reading app on iOS and Android.
What we're building - Complete Tarot Reading App Features:
- ✅ Daily tarot card of the day with push notifications
- ✅ Tarot reading history with personal journal notes
- ✅ Complete 78-card tarot database with search and arcana filters
- ✅ Multiple tarot spread types (three-card Past-Present-Future, Celtic Cross 10-card, Love spread, Career spread)
- ✅ Share tarot readings via image or text to social media
- ✅ Dark mode support for better user experience
Tech stack for tarot app development:
- React Native + Expo (cross-platform iOS and Android deployment)
- AsyncStorage (local tarot reading data persistence)
- RoxyAPI Tarot Card API (professional tarot card data and spread interpretations)
- Expo Notifications (daily tarot card reminders)
- React Navigation (seamless screen transitions)
Project Setup
1. Create Expo App
npx create-expo-app tarot-journal
cd tarot-journal
2. Install Dependencies
npx expo install @react-navigation/native @react-navigation/bottom-tabs
npx expo install react-native-screens react-native-safe-area-context
npx expo install @react-native-async-storage/async-storage
npx expo install expo-notifications
npx expo install expo-sharing expo-file-system
npx expo install react-native-svg
3. Configure Environment
Create .env file:
EXPO_PUBLIC_ROXYAPI_KEY=your_api_key_here
Get your API key from RoxyAPI Pricing.
Architecture Overview
app/
├── services/
│ ├── tarotAPI.ts # API wrapper
│ ├── storage.ts # AsyncStorage helpers
│ └── notifications.ts # Push notification setup
├── screens/
│ ├── HomeScreen.tsx # Daily card
│ ├── HistoryScreen.tsx # Past readings
│ ├── NewReadingScreen.tsx # Create reading
│ ├── ReadingDetailScreen.tsx # View + edit notes
│ └── CardsScreen.tsx # Browse all cards
├── components/
│ ├── TarotCard.tsx # Reusable card UI
│ ├── SpreadLayout.tsx # Position visualization
│ └── ShareButton.tsx # Export readings
└── App.tsx # Navigation setup
Step 1: API Service Layer
Create services/tarotAPI.ts:
const API_BASE = 'https://roxyapi.com/api/v2/tarot';
const API_KEY = process.env.EXPO_PUBLIC_ROXYAPI_KEY;
export interface TarotCard {
id: string;
name: string;
arcana: 'major' | 'minor';
suit?: 'cups' | 'wands' | 'swords' | 'pentacles';
reversed: boolean;
keywords: string[];
meaning: string;
imageUrl: string;
}
export interface SpreadPosition {
position: number;
name: string;
interpretation: string;
card: TarotCard;
}
export interface Reading {
spread: string;
question?: string;
positions: SpreadPosition[];
summary?: string;
seed?: string;
}
class TarotAPI {
private async request(endpoint: string, body?: any) {
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: {
'X-API-Key': API_KEY!,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return response.json();
}
// Daily card for home screen
async getDailyCard(userId: string): Promise<{ card: TarotCard; message: string }> {
const today = new Date().toISOString().split('T')[0];
const seed = `${userId}-${today}`;
return this.request('/daily', { seed });
}
// Get all cards for browse feature
async getAllCards(): Promise<TarotCard[]> {
const response = await fetch(`${API_BASE}/cards`, {
method: 'GET',
headers: { 'X-API-Key': API_KEY! },
});
return response.json();
}
// Three-card spread
async getThreeCardReading(question: string, seed: string): Promise<Reading> {
return this.request('/spreads/three-card', { question, seed });
}
// Celtic Cross spread (premium feature)
async getCelticCrossReading(question: string, seed: string): Promise<Reading> {
return this.request('/spreads/celtic-cross', { question, seed });
}
// Love spread
async getLoveReading(question: string, seed: string): Promise<Reading> {
return this.request('/spreads/love', { question, seed });
}
// Career spread
async getCareerReading(question: string, seed: string): Promise<Reading> {
return this.request('/spreads/career', { question, seed });
}
// Recreate reading from seed (for history)
async recreateReading(
spreadType: string,
seed: string,
question?: string
): Promise<Reading> {
const endpoints = {
'three-card': '/spreads/three-card',
'celtic-cross': '/spreads/celtic-cross',
love: '/spreads/love',
career: '/spreads/career',
};
return this.request(endpoints[spreadType], { question, seed });
}
}
export const tarotAPI = new TarotAPI();
Step 2: Storage Layer
Create services/storage.ts:
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface SavedReading {
id: string;
spreadType: string;
question?: string;
seed: string;
createdAt: string;
notes?: string;
tags?: string[];
}
class StorageService {
private READINGS_KEY = '@tarot_readings';
private USER_ID_KEY = '@tarot_user_id';
private SETTINGS_KEY = '@tarot_settings';
// User ID for seeding
async getUserId(): Promise<string> {
let userId = await AsyncStorage.getItem(this.USER_ID_KEY);
if (!userId) {
userId = `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await AsyncStorage.setItem(this.USER_ID_KEY, userId);
}
return userId;
}
// Save reading metadata (not full data - recreate from seed)
async saveReading(reading: SavedReading): Promise<void> {
const readings = await this.getAllReadings();
readings.unshift(reading); // New readings first
await AsyncStorage.setItem(this.READINGS_KEY, JSON.stringify(readings));
}
// Get all saved readings
async getAllReadings(): Promise<SavedReading[]> {
const data = await AsyncStorage.getItem(this.READINGS_KEY);
return data ? JSON.parse(data) : [];
}
// Update reading notes
async updateReadingNotes(readingId: string, notes: string): Promise<void> {
const readings = await this.getAllReadings();
const index = readings.findIndex((r) => r.id === readingId);
if (index !== -1) {
readings[index].notes = notes;
await AsyncStorage.setItem(this.READINGS_KEY, JSON.stringify(readings));
}
}
// Add tags to reading
async addReadingTags(readingId: string, tags: string[]): Promise<void> {
const readings = await this.getAllReadings();
const index = readings.findIndex((r) => r.id === readingId);
if (index !== -1) {
readings[index].tags = [...(readings[index].tags || []), ...tags];
await AsyncStorage.setItem(this.READINGS_KEY, JSON.stringify(readings));
}
}
// Delete reading
async deleteReading(readingId: string): Promise<void> {
const readings = await this.getAllReadings();
const filtered = readings.filter((r) => r.id !== readingId);
await AsyncStorage.setItem(this.READINGS_KEY, JSON.stringify(filtered));
}
// Settings
async getSetting(key: string): Promise<any> {
const settings = await AsyncStorage.getItem(this.SETTINGS_KEY);
const parsed = settings ? JSON.parse(settings) : {};
return parsed[key];
}
async setSetting(key: string, value: any): Promise<void> {
const settings = await AsyncStorage.getItem(this.SETTINGS_KEY);
const parsed = settings ? JSON.parse(settings) : {};
parsed[key] = value;
await AsyncStorage.setItem(this.SETTINGS_KEY, JSON.stringify(parsed));
}
}
export const storage = new StorageService();
Step 3: Notification Service
Create services/notifications.ts:
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
// Configure notification behavior
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
class NotificationService {
async requestPermissions(): Promise<boolean> {
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('daily-tarot', {
name: 'Daily Tarot',
importance: Notifications.AndroidImportance.HIGH,
});
}
const { status } = await Notifications.requestPermissionsAsync();
return status === 'granted';
}
async scheduleDailyReminder(hour: number = 8, minute: number = 0): Promise<void> {
// Cancel existing reminders
await Notifications.cancelAllScheduledNotificationsAsync();
// Schedule daily notification
await Notifications.scheduleNotificationAsync({
content: {
title: '🔮 Your Daily Tarot Card Awaits',
body: 'Discover what the cards have to say today',
sound: true,
},
trigger: {
hour,
minute,
repeats: true,
},
});
}
async cancelDailyReminder(): Promise<void> {
await Notifications.cancelAllScheduledNotificationsAsync();
}
}
export const notifications = new NotificationService();
Step 4: Reusable Components
TarotCard Component
Create components/TarotCard.tsx:
import React from 'react';
import { View, Text, Image, StyleSheet, TouchableOpacity } from 'react-native';
import type { TarotCard as TarotCardType } from '../services/tarotAPI';
interface Props {
card: TarotCardType;
showDetails?: boolean;
onPress?: () => void;
}
export function TarotCard({ card, showDetails = false, onPress }: Props) {
const content = (
<View style={styles.container}>
{/* Card Image */}
<Image
source={{ uri: card.imageUrl }}
style={[
styles.image,
card.reversed && styles.reversed,
]}
resizeMode="contain"
/>
{/* Card Name */}
<Text style={styles.name}>
{card.name}
{card.reversed && ' (Reversed)'}
</Text>
{/* Keywords */}
{showDetails && (
<View style={styles.keywords}>
{card.keywords.slice(0, 3).map((keyword, i) => (
<View key={i} style={styles.keywordBadge}>
<Text style={styles.keywordText}>{keyword}</Text>
</View>
))}
</View>
)}
{/* Meaning */}
{showDetails && (
<Text style={styles.meaning} numberOfLines={3}>
{card.meaning}
</Text>
)}
</View>
);
return onPress ? (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
{content}
</TouchableOpacity>
) : (
content
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
image: {
width: 120,
height: 200,
marginBottom: 12,
},
reversed: {
transform: [{ rotate: '180deg' }],
},
name: {
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
marginBottom: 8,
},
keywords: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
marginTop: 8,
marginBottom: 12,
},
keywordBadge: {
backgroundColor: '#f0f0f0',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
margin: 4,
},
keywordText: {
fontSize: 12,
color: '#666',
},
meaning: {
fontSize: 14,
color: '#555',
textAlign: 'center',
lineHeight: 20,
},
});
Spread Layout Component
Create components/SpreadLayout.tsx:
import React from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import type { SpreadPosition } from '../services/tarotAPI';
import { TarotCard } from './TarotCard';
interface Props {
positions: SpreadPosition[];
spreadType: string;
}
export function SpreadLayout({ positions, spreadType }: Props) {
// Different layouts for different spreads
const getLayout = () => {
switch (spreadType) {
case 'three-card':
return renderThreeCardLayout();
case 'celtic-cross':
return renderCelticCrossLayout();
default:
return renderDefaultLayout();
}
};
const renderThreeCardLayout = () => (
<View style={styles.threeCard}>
{positions.map((position) => (
<View key={position.position} style={styles.positionContainer}>
<Text style={styles.positionName}>{position.name}</Text>
<TarotCard card={position.card} />
<Text style={styles.interpretation}>{position.interpretation}</Text>
</View>
))}
</View>
);
const renderCelticCrossLayout = () => (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.celticCross}>
{positions.map((position) => (
<View key={position.position} style={styles.celticPosition}>
<Text style={styles.positionNumber}>{position.position}</Text>
<Text style={styles.positionName}>{position.name}</Text>
<TarotCard card={position.card} />
<Text style={styles.celticInterpretation} numberOfLines={4}>
{position.interpretation}
</Text>
</View>
))}
</View>
</ScrollView>
);
const renderDefaultLayout = () => (
<View style={styles.default}>
{positions.map((position) => (
<View key={position.position} style={styles.defaultPosition}>
<View style={styles.positionHeader}>
<Text style={styles.positionNumber}>{position.position}</Text>
<Text style={styles.positionName}>{position.name}</Text>
</View>
<TarotCard card={position.card} showDetails />
<Text style={styles.interpretation}>{position.interpretation}</Text>
</View>
))}
</View>
);
return getLayout();
}
const styles = StyleSheet.create({
threeCard: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 8,
},
positionContainer: {
flex: 1,
marginHorizontal: 4,
alignItems: 'center',
},
positionName: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
textAlign: 'center',
},
interpretation: {
fontSize: 14,
color: '#555',
marginTop: 12,
textAlign: 'center',
lineHeight: 20,
},
celticCross: {
flexDirection: 'row',
paddingHorizontal: 16,
},
celticPosition: {
width: 200,
marginRight: 16,
alignItems: 'center',
},
positionNumber: {
fontSize: 14,
fontWeight: '700',
color: '#888',
marginBottom: 4,
},
celticInterpretation: {
fontSize: 13,
color: '#666',
marginTop: 8,
textAlign: 'center',
},
default: {
paddingHorizontal: 16,
},
defaultPosition: {
marginBottom: 32,
},
positionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
});
Step 5: Home Screen (Daily Card)
Create screens/HomeScreen.tsx:
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
ActivityIndicator,
TouchableOpacity,
ScrollView,
} from 'react-native';
import { tarotAPI, type TarotCard as TarotCardType } from '../services/tarotAPI';
import { storage } from '../services/storage';
import { TarotCard } from '../components/TarotCard';
export function HomeScreen({ navigation }) {
const [loading, setLoading] = useState(true);
const [dailyCard, setDailyCard] = useState<TarotCardType | null>(null);
const [message, setMessage] = useState('');
useEffect(() => {
loadDailyCard();
}, []);
const loadDailyCard = async () => {
try {
setLoading(true);
const userId = await storage.getUserId();
const data = await tarotAPI.getDailyCard(userId);
setDailyCard(data.card);
setMessage(data.message);
} catch (error) {
console.error('Failed to load daily card:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#7C3AED" />
</View>
);
}
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={styles.date}>
{new Date().toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Text>
<Text style={styles.title}>Your Daily Card</Text>
</View>
{dailyCard && (
<>
<View style={styles.cardContainer}>
<TarotCard card={dailyCard} showDetails />
</View>
<View style={styles.messageContainer}>
<Text style={styles.message}>{message}</Text>
</View>
</>
)}
<View style={styles.actions}>
<TouchableOpacity
style={styles.button}
onPress={() => navigation.navigate('NewReading')}
>
<Text style={styles.buttonText}>Get a Full Reading</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={() => navigation.navigate('History')}
>
<Text style={[styles.buttonText, styles.secondaryButtonText]}>
View Past Readings
</Text>
</TouchableOpacity>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
padding: 24,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
date: {
fontSize: 14,
color: '#6B7280',
marginBottom: 8,
},
title: {
fontSize: 28,
fontWeight: '700',
color: '#111827',
},
cardContainer: {
padding: 24,
alignItems: 'center',
},
messageContainer: {
paddingHorizontal: 24,
paddingBottom: 24,
},
message: {
fontSize: 16,
lineHeight: 24,
color: '#374151',
textAlign: 'center',
},
actions: {
padding: 24,
gap: 12,
},
button: {
backgroundColor: '#7C3AED',
paddingVertical: 16,
paddingHorizontal: 24,
borderRadius: 12,
alignItems: 'center',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
secondaryButton: {
backgroundColor: '#fff',
borderWidth: 2,
borderColor: '#7C3AED',
},
secondaryButtonText: {
color: '#7C3AED',
},
});
Step 6: New Reading Screen
Create screens/NewReadingScreen.tsx:
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
StyleSheet,
TouchableOpacity,
ScrollView,
ActivityIndicator,
} from 'react-native';
import { tarotAPI } from '../services/tarotAPI';
import { storage } from '../services/storage';
const SPREAD_TYPES = [
{ id: 'three-card', name: 'Three-Card Spread', cards: 3, description: 'Past, Present, Future' },
{ id: 'love', name: 'Love Spread', cards: 5, description: 'Relationship insights' },
{ id: 'career', name: 'Career Spread', cards: 7, description: 'Professional guidance' },
{ id: 'celtic-cross', name: 'Celtic Cross', cards: 10, description: 'Comprehensive reading' },
];
export function NewReadingScreen({ navigation }) {
const [question, setQuestion] = useState('');
const [selectedSpread, setSelectedSpread] = useState('three-card');
const [loading, setLoading] = useState(false);
const handleGetReading = async () => {
if (!question.trim()) {
alert('Please enter a question');
return;
}
try {
setLoading(true);
// Generate unique seed
const userId = await storage.getUserId();
const readingId = `${userId}-${Date.now()}`;
// Fetch reading
let reading;
switch (selectedSpread) {
case 'three-card':
reading = await tarotAPI.getThreeCardReading(question, readingId);
break;
case 'love':
reading = await tarotAPI.getLoveReading(question, readingId);
break;
case 'career':
reading = await tarotAPI.getCareerReading(question, readingId);
break;
case 'celtic-cross':
reading = await tarotAPI.getCelticCrossReading(question, readingId);
break;
}
// Save metadata
await storage.saveReading({
id: readingId,
spreadType: selectedSpread,
question,
seed: readingId,
createdAt: new Date().toISOString(),
});
// Navigate to detail screen
navigation.navigate('ReadingDetail', {
readingId,
reading,
});
} catch (error) {
console.error('Failed to get reading:', error);
alert('Failed to get reading. Please try again.');
} finally {
setLoading(false);
}
};
return (
<ScrollView style={styles.container}>
<View style={styles.content}>
<Text style={styles.label}>Your Question</Text>
<TextInput
style={styles.input}
placeholder="What do I need to know about...?"
value={question}
onChangeText={setQuestion}
multiline
numberOfLines={3}
/>
<Text style={styles.label}>Select Spread Type</Text>
{SPREAD_TYPES.map((spread) => (
<TouchableOpacity
key={spread.id}
style={[
styles.spreadOption,
selectedSpread === spread.id && styles.spreadOptionSelected,
]}
onPress={() => setSelectedSpread(spread.id)}
>
<View>
<Text style={styles.spreadName}>{spread.name}</Text>
<Text style={styles.spreadDescription}>
{spread.cards} cards • {spread.description}
</Text>
</View>
<View
style={[
styles.radio,
selectedSpread === spread.id && styles.radioSelected,
]}
/>
</TouchableOpacity>
))}
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleGetReading}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Get Reading</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
content: {
padding: 24,
},
label: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 12,
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#D1D5DB',
borderRadius: 12,
padding: 16,
fontSize: 16,
marginBottom: 24,
minHeight: 100,
textAlignVertical: 'top',
},
spreadOption: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#fff',
borderWidth: 2,
borderColor: '#E5E7EB',
borderRadius: 12,
padding: 16,
marginBottom: 12,
},
spreadOptionSelected: {
borderColor: '#7C3AED',
backgroundColor: '#F3F0FF',
},
spreadName: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 4,
},
spreadDescription: {
fontSize: 14,
color: '#6B7280',
},
radio: {
width: 24,
height: 24,
borderRadius: 12,
borderWidth: 2,
borderColor: '#D1D5DB',
},
radioSelected: {
borderColor: '#7C3AED',
backgroundColor: '#7C3AED',
},
button: {
backgroundColor: '#7C3AED',
paddingVertical: 16,
borderRadius: 12,
alignItems: 'center',
marginTop: 24,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
Step 7: History Screen
Create screens/HistoryScreen.tsx:
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { storage, type SavedReading } from '../services/storage';
import { tarotAPI } from '../services/tarotAPI';
export function HistoryScreen({ navigation }) {
const [readings, setReadings] = useState<SavedReading[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadReadings();
// Refresh when screen comes into focus
const unsubscribe = navigation.addListener('focus', loadReadings);
return unsubscribe;
}, [navigation]);
const loadReadings = async () => {
try {
setLoading(true);
const allReadings = await storage.getAllReadings();
setReadings(allReadings);
} catch (error) {
console.error('Failed to load readings:', error);
} finally {
setLoading(false);
}
};
const handleReadingPress = async (savedReading: SavedReading) => {
try {
// Recreate reading from seed
const reading = await tarotAPI.recreateReading(
savedReading.spreadType,
savedReading.seed,
savedReading.question
);
navigation.navigate('ReadingDetail', {
readingId: savedReading.id,
reading,
savedReading,
});
} catch (error) {
console.error('Failed to load reading:', error);
alert('Failed to load reading');
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const renderReading = ({ item }: { item: SavedReading }) => (
<TouchableOpacity
style={styles.readingCard}
onPress={() => handleReadingPress(item)}
>
<View style={styles.readingHeader}>
<Text style={styles.spreadType}>
{item.spreadType.split('-').map(w =>
w.charAt(0).toUpperCase() + w.slice(1)
).join(' ')}
</Text>
<Text style={styles.date}>{formatDate(item.createdAt)}</Text>
</View>
<Text style={styles.question} numberOfLines={2}>
{item.question || 'No question'}
</Text>
{item.notes && (
<Text style={styles.notes} numberOfLines={1}>
📝 {item.notes}
</Text>
)}
{item.tags && item.tags.length > 0 && (
<View style={styles.tags}>
{item.tags.slice(0, 3).map((tag, i) => (
<View key={i} style={styles.tag}>
<Text style={styles.tagText}>{tag}</Text>
</View>
))}
</View>
)}
</TouchableOpacity>
);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#7C3AED" />
</View>
);
}
if (readings.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.emptyTitle}>No Readings Yet</Text>
<Text style={styles.emptyText}>
Your tarot reading history will appear here
</Text>
<TouchableOpacity
style={styles.button}
onPress={() => navigation.navigate('NewReading')}
>
<Text style={styles.buttonText}>Get Your First Reading</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={readings}
renderItem={renderReading}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
list: {
padding: 16,
},
readingCard: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
readingHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
spreadType: {
fontSize: 14,
fontWeight: '600',
color: '#7C3AED',
},
date: {
fontSize: 12,
color: '#9CA3AF',
},
question: {
fontSize: 16,
color: '#111827',
marginBottom: 8,
},
notes: {
fontSize: 14,
color: '#6B7280',
fontStyle: 'italic',
marginBottom: 8,
},
tags: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 8,
},
tag: {
backgroundColor: '#F3F4F6',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
marginRight: 6,
marginBottom: 6,
},
tagText: {
fontSize: 12,
color: '#6B7280',
},
emptyTitle: {
fontSize: 20,
fontWeight: '600',
color: '#111827',
marginBottom: 8,
},
emptyText: {
fontSize: 16,
color: '#6B7280',
textAlign: 'center',
marginBottom: 24,
},
button: {
backgroundColor: '#7C3AED',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 12,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
Step 8: Navigation Setup
Update App.tsx:
import React, { useEffect } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { HomeScreen } from './screens/HomeScreen';
import { HistoryScreen } from './screens/HistoryScreen';
import { NewReadingScreen } from './screens/NewReadingScreen';
import { notifications } from './services/notifications';
const Tab = createBottomTabNavigator();
export default function App() {
useEffect(() => {
setupNotifications();
}, []);
const setupNotifications = async () => {
const granted = await notifications.requestPermissions();
if (granted) {
// Schedule 8 AM daily reminder
await notifications.scheduleDailyReminder(8, 0);
}
};
return (
<NavigationContainer>
<Tab.Navigator
screenOptions={{
headerStyle: { backgroundColor: '#7C3AED' },
headerTintColor: '#fff',
tabBarActiveTintColor: '#7C3AED',
}}
>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{ title: 'Daily Card' }}
/>
<Tab.Screen
name="NewReading"
component={NewReadingScreen}
options={{ title: 'New Reading' }}
/>
<Tab.Screen
name="History"
component={HistoryScreen}
options={{ title: 'History' }}
/>
</Tab.Navigator>
</NavigationContainer>
);
}
Testing the App
1. Run on iOS Simulator
npx expo start --ios
2. Run on Android Emulator
npx expo start --android
3. Test on Physical Device
npx expo start
# Scan QR code with Expo Go app
Production Features to Add
1. Reading Detail with Notes
Allow users to add personal reflections:
const [notes, setNotes] = useState(savedReading?.notes || '');
const saveNotes = async () => {
await storage.updateReadingNotes(readingId, notes);
};
2. Share Reading Feature
Export reading as image or text:
import * as Sharing from 'expo-sharing';
const shareReading = async () => {
const text = `
My Tarot Reading - ${new Date().toLocaleDateString()}
Question: ${reading.question}
${reading.positions.map(p =>
`${p.name}: ${p.card.name}\n${p.interpretation}`
).join('\n\n')}
`.trim();
await Sharing.shareAsync('data:text/plain;base64,' + btoa(text));
};
3. Card Search and Browse
Add full card database screen with search.
4. Reading Stats
Track reading frequency, common cards, mood patterns.
5. Dark Mode
Use useColorScheme hook and theme switching.
Performance Optimization
1. Image Caching
// Preload card images
import { Image } from 'react-native';
const preloadImages = async () => {
const urls = cardIds.map(id =>
`https://roxyapi.com/img/tarot/${id}.jpg`
);
await Promise.all(urls.map(url => Image.prefetch(url)));
};
2. Offline Support
// Cache API responses locally
const cachedReading = await AsyncStorage.getItem(`reading:${seed}`);
if (cachedReading) {
return JSON.parse(cachedReading);
}
3. Reduce API Calls
Store reading metadata, recreate from seed only when viewing.
Monetization Strategy
Free Tier
- Daily card feature
- 3 three-card readings per week
- Basic history (last 10 readings)
Premium ($4.99/month)
- Unlimited readings
- All spread types (Celtic Cross, Love, Career)
- Full history with search
- Export readings as PDF
- Priority API access
Next Steps
You now have a complete tarot journaling app! Here's what to do next:
- Get your API key: Visit RoxyAPI Pricing for free tier
- Deploy to stores: Use EAS Build for iOS/Android
- Add analytics: Track user behavior with Expo Analytics
- Explore other APIs: Check out all RoxyAPI products - numerology, astrology, and more
- Review documentation: See RoxyAPI Docs for complete API reference
Full code on GitHub: (Link your repository here)
Questions? The Tarot API product page has detailed documentation and support.
Happy building! 🔮✨