WIP: update trip cards and Spotify handling

This commit is contained in:
2026-05-21 22:17:48 +01:00
parent a587b3a1bd
commit dcfc8d4a54
4 changed files with 654 additions and 85 deletions

View File

@@ -4,7 +4,7 @@ import { Car, Music } from 'lucide-react-native';
import { colors } from '../../utils/colors'; import { colors } from '../../utils/colors';
import { supabase } from '../../services/supabase'; import { supabase } from '../../services/supabase';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import { makeRedirectUri, useAuthRequest, exchangeCodeAsync, DiscoveryDocument } from 'expo-auth-session'; import { makeRedirectUri, useAuthRequest, DiscoveryDocument } from 'expo-auth-session';
import { clearSpotifyTokens, setSpotifyToken, setSpotifyRefreshToken } from '../../auth/spotifyToken'; import { clearSpotifyTokens, setSpotifyToken, setSpotifyRefreshToken } from '../../auth/spotifyToken';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
@@ -42,60 +42,137 @@ export default function LoginScreen({ navigation }) {
discovery discovery
); );
const showSpotifyLoginFailure = () => {
Alert.alert('Erro de Autenticação', 'Não foi possível iniciar sessão com Spotify. Tenta novamente ou usa login normal.');
};
// Handle Spotify OAuth Response // Handle Spotify OAuth Response
useEffect(() => { useEffect(() => {
if (response) { if (response) {
console.log("SPOTIFY_AUTH_RESULT:", response); console.log("SPOTIFY_AUTH_RESULT:", response);
if (response.type === 'success') { if (response.type === 'success') {
const { code } = response.params; const { code } = response.params;
console.log("SPOTIFY_CODE_EXISTS:", !!code); console.log("[LoginScreen] Spotify auth success:", {
redirectUri,
clientIdExists: Boolean(SPOTIFY_CLIENT_ID),
authorizationCodeExists: Boolean(code),
codeVerifierExists: Boolean(request?.codeVerifier),
scopes: ['user-read-email', 'user-read-private', 'playlist-modify-public', 'playlist-modify-private'],
flow: 'authorization_code_pkce',
});
exchangeCodeForTokens(code); exchangeCodeForTokens(code);
} else if (response.type === 'error') { } else if (response.type === 'error') {
console.warn('[LoginScreen] Spotify OAuth response error:', response.error?.message || response.error);
Alert.alert('Erro de Autenticação', response.error?.message || 'Falha ao logar com o Spotify'); Alert.alert('Erro de Autenticação', response.error?.message || 'Falha ao logar com o Spotify');
} }
} }
}, [response]); }, [response]);
const exchangeCodeForTokens = async (code: string) => { const exchangeCodeForTokens = async (code: string) => {
if (!request?.codeVerifier) return; if (!request?.codeVerifier) {
console.warn('[LoginScreen] Missing Spotify PKCE code verifier:', {
redirectUri,
clientIdExists: Boolean(SPOTIFY_CLIENT_ID),
authorizationCodeExists: Boolean(code),
codeVerifierExists: false,
});
showSpotifyLoginFailure();
return;
}
try { try {
setLoading(true); setLoading(true);
const tokenResult = await exchangeCodeAsync( console.log('[LoginScreen] Spotify token exchange start:', {
{
clientId: SPOTIFY_CLIENT_ID,
code,
redirectUri, redirectUri,
extraParams: { clientIdExists: Boolean(SPOTIFY_CLIENT_ID),
authorizationCodeExists: Boolean(code),
codeVerifierExists: Boolean(request.codeVerifier),
flow: 'authorization_code_pkce',
});
const tokenBody = new URLSearchParams({
client_id: SPOTIFY_CLIENT_ID,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: request.codeVerifier, code_verifier: request.codeVerifier,
}, }).toString();
},
discovery
);
console.log("SPOTIFY_TOKEN_RECEIVED:", !!tokenResult.accessToken); const tokenResponse = await fetch(discovery.tokenEndpoint as string, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: tokenBody,
});
const tokenResponseText = await tokenResponse.text();
if (tokenResult.accessToken) { console.log('[LoginScreen] Spotify token exchange response:', {
await setSpotifyToken(tokenResult.accessToken); httpStatus: tokenResponse.status,
if (tokenResult.refreshToken) { ok: tokenResponse.ok,
await setSpotifyRefreshToken(tokenResult.refreshToken); responseBodyIfFailed: tokenResponse.ok ? null : tokenResponseText,
});
if (!tokenResponse.ok) {
console.warn('[LoginScreen] Spotify token exchange failed:', {
httpStatus: tokenResponse.status,
responseBody: tokenResponseText,
});
showSpotifyLoginFailure();
return;
}
let tokenResult: any;
try {
tokenResult = JSON.parse(tokenResponseText);
} catch (parseError) {
console.warn('[LoginScreen] Spotify token response JSON parse failed:', parseError);
showSpotifyLoginFailure();
return;
}
console.log("SPOTIFY_TOKEN_RECEIVED:", !!tokenResult.access_token);
if (tokenResult.access_token) {
await setSpotifyToken(tokenResult.access_token);
if (tokenResult.refresh_token) {
await setSpotifyRefreshToken(tokenResult.refresh_token);
} }
// Fetch real Spotify Profile
console.log("SPOTIFY_PROFILE_FETCH_START"); console.log("SPOTIFY_PROFILE_FETCH_START");
const profileRes = await fetch('https://api.spotify.com/v1/me', { const profileRes = await fetch('https://api.spotify.com/v1/me', {
headers: { Authorization: `Bearer ${tokenResult.accessToken}` } headers: { Authorization: `Bearer ${tokenResult.access_token}` }
});
const profileText = await profileRes.text();
console.log('[LoginScreen] Spotify profile fetch response:', {
httpStatus: profileRes.status,
ok: profileRes.ok,
responseBodyIfFailed: profileRes.ok ? null : profileText,
}); });
if (!profileRes.ok) { if (!profileRes.ok) {
throw new Error(`Failed to fetch Spotify profile: ${profileRes.statusText}`); console.warn('[LoginScreen] Spotify profile fetch failed:', {
httpStatus: profileRes.status,
responseBody: profileText,
});
showSpotifyLoginFailure();
return;
}
let profile: any;
try {
profile = JSON.parse(profileText);
} catch (parseError) {
console.warn('[LoginScreen] Spotify profile JSON parse failed:', parseError);
showSpotifyLoginFailure();
return;
} }
const profile = await profileRes.json();
console.log("SPOTIFY_PROFILE_NAME:", profile.display_name); console.log("SPOTIFY_PROFILE_NAME:", profile.display_name);
console.log("SPOTIFY_PROFILE_EMAIL_EXISTS:", !!profile.email); console.log("SPOTIFY_PROFILE_EMAIL_EXISTS:", !!profile.email);
console.log("SPOTIFY_USER_ID:", profile.id); console.log("SPOTIFY_USER_ID:", profile.id);
console.log("SPOTIFY_ACCESS_TOKEN_EXISTS:", !!tokenResult.accessToken); console.log("SPOTIFY_ACCESS_TOKEN_EXISTS:", !!tokenResult.access_token);
const email = profile.email || `spotify_${profile.id}@roadtripdj.local`; const email = profile.email || `spotify_${profile.id}@roadtripdj.local`;
const password = `SpotifySecure_${profile.id}`; const password = `SpotifySecure_${profile.id}`;
@@ -125,7 +202,7 @@ export default function LoginScreen({ navigation }) {
}); });
if (signUpError) { if (signUpError) {
console.error("SignUp error:", signUpError); console.warn("SignUp error:", signUpError);
throw signUpError; throw signUpError;
} }
@@ -134,7 +211,10 @@ export default function LoginScreen({ navigation }) {
email, email,
password, password,
}); });
if (newSignInError) throw newSignInError; if (newSignInError) {
console.warn("Spotify user sign-in after sign-up failed:", newSignInError.message);
throw newSignInError;
}
sessionData = newSession; sessionData = newSession;
} else { } else {
// Update metadata with latest Spotify data // Update metadata with latest Spotify data
@@ -158,11 +238,12 @@ export default function LoginScreen({ navigation }) {
// Trigger app main flow // Trigger app main flow
enableSpotifyMode(); enableSpotifyMode();
} else { } else {
console.warn('[LoginScreen] Spotify token response missing access token');
Alert.alert('Erro de Autenticação', 'Não foi possível obter o token de acesso do Spotify.'); Alert.alert('Erro de Autenticação', 'Não foi possível obter o token de acesso do Spotify.');
} }
} catch (e: any) { } catch (e: any) {
console.error('🚀 [LoginScreen] Token Exchange Error:', e); console.warn('🚀 [LoginScreen] Token Exchange Error:', e?.message || e);
Alert.alert('Erro de Autenticação', e.message || 'Não foi possível trocar o código pelo token.'); showSpotifyLoginFailure();
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -189,17 +270,24 @@ export default function LoginScreen({ navigation }) {
const handleResetAuth = async () => { const handleResetAuth = async () => {
await supabase.auth.signOut(); await supabase.auth.signOut();
await clearSpotifyTokens(); await clearSpotifyTokens();
setEmail('');
setPassword('');
Alert.alert('Reset', 'Auth state cleared.'); Alert.alert('Reset', 'Auth state cleared.');
}; };
const handleSpotifyLogin = async () => { const handleSpotifyLogin = async () => {
try { try {
console.log("SPOTIFY_CLIENT_ID_EXISTS:", !!SPOTIFY_CLIENT_ID); console.log("[LoginScreen] Spotify login start:", {
console.log("SPOTIFY_REDIRECT_URI:", redirectUri); redirectUri,
clientIdExists: Boolean(SPOTIFY_CLIENT_ID),
codeVerifierExists: Boolean(request?.codeVerifier),
scopes: ['user-read-email', 'user-read-private', 'playlist-modify-public', 'playlist-modify-private'],
flow: 'authorization_code_pkce',
});
await promptAsync(); await promptAsync();
} catch (e: any) { } catch (e: any) {
console.error('🚀 [LoginScreen] OAuth Error:', e); console.warn('🚀 [LoginScreen] OAuth Error:', e?.message || e);
Alert.alert('Erro de Autenticação', e.message); showSpotifyLoginFailure();
} }
}; };

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ImageBackground, Alert, ActivityIndicator } from 'react-native'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ImageBackground, Alert, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { NavigationProp, useFocusEffect } from '@react-navigation/native'; import { NavigationProp, useFocusEffect } from '@react-navigation/native';
@@ -6,11 +6,119 @@ import { Navigation, Clock, MapPin } from 'lucide-react-native';
import { colors } from '../../utils/colors'; import { colors } from '../../utils/colors';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { supabase } from '../../services/supabase'; import { supabase } from '../../services/supabase';
import { getDestinationLandmarkImage } from '../../services/destinationImage';
interface Props { interface Props {
navigation: NavigationProp<any>; navigation: NavigationProp<any>;
} }
type TripCardProps = {
trip: any;
};
const maskGoogleKeyInImageUrl = (url: string | null) => url?.replace(/([?&]key=)[^&]+/, '$1[REDACTED]') || null;
function TripCard({ trip }: TripCardProps) {
const [imageUrl, setImageUrl] = useState<string | null>(trip.destination_image_url || null);
const [landmarkName, setLandmarkName] = useState<string | null>(trip.destination_landmark_name || null);
const [placeId, setPlaceId] = useState<string | null>(trip.destination_place_id || trip.place_id || null);
const tripTitle = trip.title || trip.destination || 'Viagem';
useEffect(() => {
let isMounted = true;
console.log('[TripCardImageDebug] initial', {
tripName: tripTitle,
destination: trip.destination,
placeId: trip.destination_place_id || trip.place_id || null,
imageUrl: trip.destination_image_url || null,
});
if (trip.destination_image_url || !trip.destination) return;
getDestinationLandmarkImage(trip.destination, { tripTitle }).then(result => {
if (!isMounted) return;
setImageUrl(result.imageUrl);
setLandmarkName(result.landmarkName);
setPlaceId(result.placeId);
console.log('[TripCardImageDebug] fetched', {
tripName: tripTitle,
destination: trip.destination,
placeId: result.placeId,
imageUrl: maskGoogleKeyInImageUrl(result.imageUrl),
source: result.source,
fallbackReason: result.fallbackReason,
});
});
return () => {
isMounted = false;
};
}, [trip.destination, trip.destination_image_url, trip.destination_place_id, trip.place_id, tripTitle]);
useEffect(() => {
console.log('[TripCardImageDebug] render_image_uri', {
tripName: tripTitle,
destination: trip.destination,
placeId,
imageUrl: maskGoogleKeyInImageUrl(imageUrl),
hasValidRemoteUri: Boolean(imageUrl?.startsWith('https://')),
fallbackReason: imageUrl ? null : 'missing_image_url',
});
}, [trip.destination, tripTitle, placeId, imageUrl]);
const imageContent = (
<View style={styles.imageOverlay}>
<Text style={styles.tripImageTitle} numberOfLines={2}>{tripTitle}</Text>
<Text style={styles.tripImageSubtitle} numberOfLines={1}>
{landmarkName || trip.destination}
</Text>
</View>
);
return (
<View style={styles.mainTripCard}>
{imageUrl ? (
<ImageBackground source={{ uri: imageUrl }} style={styles.tripImage} imageStyle={styles.tripImageStyle}>
{imageContent}
</ImageBackground>
) : (
<View style={[styles.tripImage, styles.tripImageFallback]}>
<View style={styles.tripImageFallbackGlow} />
<View style={styles.tripImageFallbackAccent} />
{imageContent}
</View>
)}
<View style={styles.tripContent}>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Navigation color={colors.primary} size={16} />
<Text style={styles.statText}>{trip.distance}</Text>
</View>
<View style={styles.statDot} />
<View style={styles.statItem}>
<Clock color={colors.primary} size={16} />
<Text style={styles.statText}>{trip.duration}</Text>
</View>
</View>
<View style={styles.itinerarySnippet}>
<View style={styles.timeline}>
<View style={styles.dot} />
<View style={styles.line} />
<View style={[styles.dot, styles.dotEmpty]} />
</View>
<View style={styles.locations}>
<Text style={styles.locationText}>{trip.origin}</Text>
<Text style={styles.locationText}>{trip.destination}</Text>
</View>
</View>
</View>
</View>
);
}
export default function HomeScreen({ navigation }: Props) { export default function HomeScreen({ navigation }: Props) {
const { user } = useAuth(); const { user } = useAuth();
const userName = user?.user_metadata?.display_name || user?.user_metadata?.name || user?.email || user?.user_metadata?.email || 'Viajante'; const userName = user?.user_metadata?.display_name || user?.user_metadata?.name || user?.email || user?.user_metadata?.email || 'Viajante';
@@ -68,38 +176,7 @@ export default function HomeScreen({ navigation }: Props) {
{loading ? ( {loading ? (
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} /> <ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
) : trips.length > 0 ? ( ) : trips.length > 0 ? (
trips.map(trip => ( trips.map(trip => <TripCard key={trip.id} trip={trip} />)
<View key={trip.id} style={styles.mainTripCard}>
<View style={[styles.tripImage, { backgroundColor: colors.inputBackground, borderTopLeftRadius: 24, borderTopRightRadius: 24 }]} />
<View style={styles.tripContent}>
<Text style={styles.tripTitle}>{trip.title}</Text>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Navigation color={colors.primary} size={16} />
<Text style={styles.statText}>{trip.distance}</Text>
</View>
<View style={styles.statDot} />
<View style={styles.statItem}>
<Clock color={colors.primary} size={16} />
<Text style={styles.statText}>{trip.duration}</Text>
</View>
</View>
<View style={styles.itinerarySnippet}>
<View style={styles.timeline}>
<View style={styles.dot} />
<View style={styles.line} />
<View style={[styles.dot, styles.dotEmpty]} />
</View>
<View style={styles.locations}>
<Text style={styles.locationText}>{trip.origin}</Text>
<Text style={styles.locationText}>{trip.destination}</Text>
</View>
</View>
</View>
</View>
))
) : ( ) : (
<View style={styles.promptCard}> <View style={styles.promptCard}>
<Text style={styles.promptTitle}>Pronto para a próxima?</Text> <Text style={styles.promptTitle}>Pronto para a próxima?</Text>
@@ -167,23 +244,54 @@ const styles = StyleSheet.create({
tripImage: { tripImage: {
height: 180, height: 180,
justifyContent: 'flex-end', justifyContent: 'flex-end',
}, overflow: 'hidden',
imageOverlay: {
padding: 20,
backgroundColor: 'rgba(0,0,0,0.3)',
borderTopLeftRadius: 24, borderTopLeftRadius: 24,
borderTopRightRadius: 24, borderTopRightRadius: 24,
}, },
tripTitle: { tripImageStyle: {
color: colors.white, borderTopLeftRadius: 24,
fontSize: 22, borderTopRightRadius: 24,
fontWeight: 'bold',
marginBottom: 4,
}, },
tripDate: { tripImageFallback: {
backgroundColor: '#2B2018',
position: 'relative',
},
tripImageFallbackGlow: {
position: 'absolute',
top: -50,
right: -40,
width: 180,
height: 180,
borderRadius: 90,
backgroundColor: 'rgba(255, 122, 0, 0.35)',
},
tripImageFallbackAccent: {
position: 'absolute',
left: -30,
bottom: -60,
width: 190,
height: 190,
borderRadius: 95,
backgroundColor: 'rgba(255, 214, 180, 0.16)',
},
imageOverlay: {
padding: 20,
paddingTop: 80,
backgroundColor: 'rgba(0,0,0,0.42)',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
},
tripImageTitle: {
color: colors.white, color: colors.white,
fontSize: 14, fontSize: 24,
fontWeight: '800',
marginBottom: 6,
},
tripImageSubtitle: {
color: colors.white,
fontSize: 15,
fontWeight: '600', fontWeight: '600',
opacity: 0.92,
}, },
tripContent: { tripContent: {
padding: 20, padding: 20,

View File

@@ -45,6 +45,7 @@ export default function NewTripScreen({ navigation }) {
setDuration(finalDuration); setDuration(finalDuration);
let generatedPlaylistUrl = null; let generatedPlaylistUrl = null;
let spotifyPremiumRequired = false;
try { try {
console.log("GENERATING_PLAYLIST_FOR_TRIP:", tripName); console.log("GENERATING_PLAYLIST_FOR_TRIP:", tripName);
@@ -95,14 +96,21 @@ export default function NewTripScreen({ navigation }) {
} }
} else if (!testRes.ok) { } else if (!testRes.ok) {
const testErr = await testRes.text(); const testErr = await testRes.text();
console.error("Spotify validation request failed:", testRes.status, testErr); if (testRes.status === 403 || testErr.toLowerCase().includes('active premium subscription required')) {
spotifyPremiumRequired = true;
console.warn("Spotify validation skipped due to expected Premium limitation:", testRes.status, testErr);
} else {
console.warn("Spotify validation request failed:", testRes.status, testErr);
}
providerToken = null; providerToken = null;
} }
} }
if (!providerToken) { if (!providerToken) {
console.log("Spotify token missing or expired, skipping playlist generation."); console.log("Spotify token missing or expired, skipping playlist generation.");
if (!spotifyPremiumRequired) {
Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.'); Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.');
}
} else { } else {
// B. Fetch Spotify User ID // B. Fetch Spotify User ID
const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', { const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', {
@@ -182,8 +190,13 @@ export default function NewTripScreen({ navigation }) {
if (!searchRes.ok) { if (!searchRes.ok) {
const errText = await searchRes.text(); const errText = await searchRes.text();
console.error("Search failed:", errText); if (searchRes.status === 403 || errText.toLowerCase().includes('active premium subscription required')) {
spotifyPremiumRequired = true;
console.warn("Spotify search skipped due to expected Premium limitation:", searchRes.status, errText);
} else {
console.warn("Search failed:", errText);
Alert.alert('Erro Spotify', `Aviso ao adicionar músicas: ${errText.substring(0, 100)}`); Alert.alert('Erro Spotify', `Aviso ao adicionar músicas: ${errText.substring(0, 100)}`);
}
break; break;
} }
@@ -212,7 +225,7 @@ export default function NewTripScreen({ navigation }) {
const chunkSize = 100; const chunkSize = 100;
for (let i = 0; i < trackUris.length; i += chunkSize) { for (let i = 0; i < trackUris.length; i += chunkSize) {
const chunk = trackUris.slice(i, i + chunkSize); const chunk = trackUris.slice(i, i + chunkSize);
await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { const addTracksRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${providerToken}`, 'Authorization': `Bearer ${providerToken}`,
@@ -220,15 +233,30 @@ export default function NewTripScreen({ navigation }) {
}, },
body: JSON.stringify({ uris: chunk }) body: JSON.stringify({ uris: chunk })
}); });
if (!addTracksRes.ok) {
const addTracksErr = await addTracksRes.text();
if (addTracksRes.status === 403 || addTracksErr.toLowerCase().includes('active premium subscription required')) {
spotifyPremiumRequired = true;
console.warn("Spotify add tracks skipped due to expected Premium limitation:", addTracksRes.status, addTracksErr);
break;
}
throw new Error(`Spotify API returned status ${addTracksRes.status} while adding tracks: ${addTracksErr.substring(0, 150)}`);
}
} }
console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl); console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl);
} else { } else {
console.error("No tracks found for genres:", seed_genres); console.warn("No tracks found for genres:", seed_genres);
} }
} }
} catch (playlistError: any) { } catch (playlistError: any) {
console.warn("Expected failure generating playlist:", playlistError.message || playlistError); const playlistErrorMessage = String(playlistError?.message || playlistError || '');
Alert.alert('Erro Playlist', `A viagem foi calculada, mas a playlist falhou: ${playlistError.message?.substring(0, 50) || 'Erro Desconhecido'}`); if (playlistErrorMessage.includes('403') || playlistErrorMessage.toLowerCase().includes('active premium subscription required')) {
spotifyPremiumRequired = true;
console.warn("Spotify playlist skipped due to expected Premium limitation:", playlistErrorMessage);
} else {
console.warn("Expected failure generating playlist:", playlistErrorMessage);
Alert.alert('Erro Playlist', `A viagem foi calculada, mas a playlist falhou: ${playlistErrorMessage.substring(0, 50) || 'Erro Desconhecido'}`);
}
} }
// G. Save to Supabase unconditionally if route is valid // G. Save to Supabase unconditionally if route is valid
@@ -250,7 +278,13 @@ export default function NewTripScreen({ navigation }) {
console.error("DB Insert error:", dbError); console.error("DB Insert error:", dbError);
Alert.alert('Erro ao Guardar', 'Não foi possível guardar a viagem na base de dados: ' + dbError.message); Alert.alert('Erro ao Guardar', 'Não foi possível guardar a viagem na base de dados: ' + dbError.message);
} else { } else {
Alert.alert('Sucesso!', 'Viagem calculada e guardada!'); Alert.alert(
'Sucesso!',
spotifyPremiumRequired
? 'Viagem criada, mas a playlist do Spotify não pôde ser criada porque a app Spotify precisa de Premium.'
: 'Viagem calculada e guardada!'
);
navigation.goBack();
} }
} catch (dbEx) { } catch (dbEx) {
console.error("Exception during DB save:", dbEx); console.error("Exception during DB save:", dbEx);

View File

@@ -0,0 +1,339 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
type DestinationImageResult = {
imageUrl: string | null;
landmarkName: string | null;
placeId: string | null;
source: 'google_places' | 'wikimedia' | 'cache' | 'fallback';
fallbackReason: string | null;
};
type PlacesSearchResponse = {
status?: string;
error_message?: string;
candidates?: Array<{
name?: string;
place_id?: string;
photos?: Array<{
photo_reference?: string;
}>;
}>;
};
const imageCache = new Map<string, Promise<DestinationImageResult>>();
const GOOGLE_MAPS_API_KEY = process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY;
const DESTINATION_IMAGE_CACHE_PREFIX = 'destination_image_cache:';
const maskGoogleKeyInUrl = (url: string) => url.replace(/([?&]key=)[^&]+/, '$1[REDACTED]');
const buildPhotoUrl = (photoReference: string) => {
if (!GOOGLE_MAPS_API_KEY) return null;
return `https://maps.googleapis.com/maps/api/place/photo?maxwidth=900&photo_reference=${photoReference}&key=${encodeURIComponent(GOOGLE_MAPS_API_KEY)}`;
};
type WikimediaSearchResponse = {
query?: {
search?: Array<{
title?: string;
}>;
};
};
type DestinationImageDebugContext = {
tripTitle?: string;
};
type WikimediaPageSummary = {
title?: string;
originalimage?: {
source?: string;
};
thumbnail?: {
source?: string;
};
};
const fallbackResult = (fallbackReason: string): DestinationImageResult => ({
imageUrl: null,
landmarkName: null,
placeId: null,
source: 'fallback',
fallbackReason,
});
const readCachedImage = async (cacheKey: string): Promise<DestinationImageResult | null> => {
try {
const cached = await AsyncStorage.getItem(`${DESTINATION_IMAGE_CACHE_PREFIX}${cacheKey}`);
if (!cached) return null;
const parsed = JSON.parse(cached) as DestinationImageResult;
if (!parsed.imageUrl) return null;
return {
...parsed,
source: 'cache',
fallbackReason: null,
};
} catch (error) {
console.warn('[DestinationImage] Failed to read cached image:', error);
return null;
}
};
const writeCachedImage = async (cacheKey: string, result: DestinationImageResult) => {
if (!result.imageUrl) return;
try {
await AsyncStorage.setItem(`${DESTINATION_IMAGE_CACHE_PREFIX}${cacheKey}`, JSON.stringify(result));
} catch (error) {
console.warn('[DestinationImage] Failed to write cached image:', error);
}
};
const getGooglePlacesImage = async (normalizedDestination: string, debugContext?: DestinationImageDebugContext): Promise<DestinationImageResult | null> => {
console.log('[DestinationImage] Google API key status:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
apiKeyExists: Boolean(GOOGLE_MAPS_API_KEY),
});
if (!GOOGLE_MAPS_API_KEY) {
console.warn('[DestinationImage] Google Places skipped: missing API key');
return null;
}
const inputs = [
normalizedDestination,
`famous landmark or monument in ${normalizedDestination}`,
];
for (const input of inputs) {
const url = `https://maps.googleapis.com/maps/api/place/findplacefromtext/json?input=${encodeURIComponent(input)}&inputtype=textquery&fields=name,place_id,photos&key=${encodeURIComponent(GOOGLE_MAPS_API_KEY)}`;
console.log('[DestinationImage] Google Places search:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
input,
endpointType: 'legacy_places_find_place',
url: maskGoogleKeyInUrl(url).replace('[REDACTED]', '***'),
});
const response = await fetch(url);
const data = (await response.json()) as PlacesSearchResponse;
console.log('[DestinationImage] Google Places result:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
input,
httpStatus: response.status,
placesStatus: data.status,
candidateCount: data.candidates?.length || 0,
firstPlaceId: data.candidates?.[0]?.place_id || null,
firstName: data.candidates?.[0]?.name || null,
firstHasPhoto: Boolean(data.candidates?.[0]?.photos?.[0]?.photo_reference),
photoReferenceFound: data.candidates?.[0]?.photos?.[0]?.photo_reference || null,
errorMessage: data.error_message || null,
fullBody: data,
});
if (!response.ok || data.status === 'REQUEST_DENIED') {
console.warn('[DestinationImage] Google Places request failed:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
input,
httpStatus: response.status,
placesStatus: data.status,
errorMessage: data.error_message || null,
fullBody: data,
});
continue;
}
const candidate = data.candidates?.find(item => item.photos?.[0]?.photo_reference);
const photoReference = candidate?.photos?.[0]?.photo_reference;
const imageUrl = photoReference ? buildPhotoUrl(photoReference) : null;
if (!imageUrl) {
console.log('[DestinationImage] Google Places no usable photo for query:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
input,
fallbackReason: 'google_place_found_without_photo',
});
}
if (imageUrl) {
console.log('[DestinationImage] Google Places photo URL generated:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
endpointType: 'legacy_places_photo',
placeId: candidate?.place_id || null,
landmarkName: candidate?.name || null,
photoReferenceFound: photoReference,
imageUrl: maskGoogleKeyInUrl(imageUrl).replace('[REDACTED]', '***'),
});
return {
imageUrl,
landmarkName: candidate?.name || null,
placeId: candidate?.place_id || null,
source: 'google_places',
fallbackReason: null,
};
}
}
return null;
};
const getWikimediaImage = async (normalizedDestination: string, debugContext?: DestinationImageDebugContext): Promise<DestinationImageResult | null> => {
const languageHosts = ['pt.wikipedia.org', 'en.wikipedia.org'];
for (const host of languageHosts) {
const searchUrl = `https://${host}/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(normalizedDestination)}&format=json&origin=*&srlimit=1`;
console.log('[DestinationImage] Wikimedia search:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
host,
url: searchUrl,
});
const searchResponse = await fetch(searchUrl);
const searchData = (await searchResponse.json()) as WikimediaSearchResponse;
const title = searchData.query?.search?.[0]?.title;
console.log('[DestinationImage] Wikimedia search result:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
host,
httpStatus: searchResponse.status,
title: title || null,
body: searchData,
});
if (!searchResponse.ok || !title) {
continue;
}
const summaryUrl = `https://${host}/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
const summaryResponse = await fetch(summaryUrl);
const summaryData = (await summaryResponse.json()) as WikimediaPageSummary;
const imageUrl = summaryData.originalimage?.source || summaryData.thumbnail?.source || null;
console.log('[DestinationImage] Wikimedia image result:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
host,
title: summaryData.title || title,
httpStatus: summaryResponse.status,
imageUrl,
fallbackReason: imageUrl ? null : 'wikimedia_page_without_image',
});
if (!summaryResponse.ok || !imageUrl?.startsWith('https://')) {
continue;
}
return {
imageUrl,
landmarkName: summaryData.title || title,
placeId: null,
source: 'wikimedia',
fallbackReason: null,
};
}
return null;
};
const fetchDestinationLandmarkImage = async (destination: string, cacheKey: string, debugContext?: DestinationImageDebugContext): Promise<DestinationImageResult> => {
const normalizedDestination = destination.trim();
if (!normalizedDestination) {
console.log('[DestinationImage] Fallback reason:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
reason: 'missing_destination',
});
return fallbackResult('missing_destination');
}
try {
const cached = await readCachedImage(cacheKey);
if (cached) {
console.log('[DestinationImage] Final image URL from cache:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
imageUrl: cached.imageUrl,
fallbackReason: null,
});
return cached;
}
const googleResult = await getGooglePlacesImage(normalizedDestination, debugContext);
if (googleResult?.imageUrl) {
console.log('[DestinationImage] Final image URL:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
source: googleResult.source,
placeId: googleResult.placeId,
landmarkName: googleResult.landmarkName,
imageUrl: googleResult.imageUrl ? maskGoogleKeyInUrl(googleResult.imageUrl).replace('[REDACTED]', '***') : null,
fallbackReason: null,
});
await writeCachedImage(cacheKey, googleResult);
return googleResult;
}
console.log('[DestinationImage] Google fallback reason:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
reason: 'google_places_no_image_or_failed',
});
const wikimediaResult = await getWikimediaImage(normalizedDestination, debugContext);
if (wikimediaResult?.imageUrl) {
console.log('[DestinationImage] Final image URL:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
source: wikimediaResult.source,
landmarkName: wikimediaResult.landmarkName,
imageUrl: wikimediaResult.imageUrl,
fallbackReason: null,
});
await writeCachedImage(cacheKey, wikimediaResult);
return wikimediaResult;
}
console.log('[DestinationImage] Fallback reason:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
reason: 'no_google_or_wikimedia_image',
});
return fallbackResult('no_google_or_wikimedia_image');
} catch (error) {
console.warn('[DestinationImage] Failed to fetch destination image:', {
tripTitle: debugContext?.tripTitle || null,
destination: normalizedDestination,
error,
fallbackReason: 'fetch_error',
});
return fallbackResult('fetch_error');
}
};
export const getDestinationLandmarkImage = (destination: string, debugContext?: DestinationImageDebugContext): Promise<DestinationImageResult> => {
const cacheKey = destination.trim().toLowerCase();
if (!cacheKey) {
return Promise.resolve(fallbackResult('missing_destination'));
}
const cached = imageCache.get(cacheKey);
if (cached) return cached;
const request = fetchDestinationLandmarkImage(destination, cacheKey, debugContext);
imageCache.set(cacheKey, request);
return request;
};