WIP: update trip cards and Spotify handling
This commit is contained in:
@@ -4,7 +4,7 @@ import { Car, Music } from 'lucide-react-native';
|
||||
import { colors } from '../../utils/colors';
|
||||
import { supabase } from '../../services/supabase';
|
||||
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 { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
@@ -42,60 +42,137 @@ export default function LoginScreen({ navigation }) {
|
||||
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
|
||||
useEffect(() => {
|
||||
if (response) {
|
||||
console.log("SPOTIFY_AUTH_RESULT:", response);
|
||||
if (response.type === 'success') {
|
||||
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);
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
}, [response]);
|
||||
|
||||
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 {
|
||||
setLoading(true);
|
||||
const tokenResult = await exchangeCodeAsync(
|
||||
{
|
||||
clientId: SPOTIFY_CLIENT_ID,
|
||||
code,
|
||||
redirectUri,
|
||||
extraParams: {
|
||||
code_verifier: request.codeVerifier,
|
||||
},
|
||||
console.log('[LoginScreen] Spotify token exchange start:', {
|
||||
redirectUri,
|
||||
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,
|
||||
}).toString();
|
||||
|
||||
const tokenResponse = await fetch(discovery.tokenEndpoint as string, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
discovery
|
||||
);
|
||||
body: tokenBody,
|
||||
});
|
||||
const tokenResponseText = await tokenResponse.text();
|
||||
|
||||
console.log("SPOTIFY_TOKEN_RECEIVED:", !!tokenResult.accessToken);
|
||||
console.log('[LoginScreen] Spotify token exchange response:', {
|
||||
httpStatus: tokenResponse.status,
|
||||
ok: tokenResponse.ok,
|
||||
responseBodyIfFailed: tokenResponse.ok ? null : tokenResponseText,
|
||||
});
|
||||
|
||||
if (tokenResult.accessToken) {
|
||||
await setSpotifyToken(tokenResult.accessToken);
|
||||
if (tokenResult.refreshToken) {
|
||||
await setSpotifyRefreshToken(tokenResult.refreshToken);
|
||||
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");
|
||||
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) {
|
||||
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_EMAIL_EXISTS:", !!profile.email);
|
||||
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 password = `SpotifySecure_${profile.id}`;
|
||||
@@ -125,7 +202,7 @@ export default function LoginScreen({ navigation }) {
|
||||
});
|
||||
|
||||
if (signUpError) {
|
||||
console.error("SignUp error:", signUpError);
|
||||
console.warn("SignUp error:", signUpError);
|
||||
throw signUpError;
|
||||
}
|
||||
|
||||
@@ -134,7 +211,10 @@ export default function LoginScreen({ navigation }) {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (newSignInError) throw newSignInError;
|
||||
if (newSignInError) {
|
||||
console.warn("Spotify user sign-in after sign-up failed:", newSignInError.message);
|
||||
throw newSignInError;
|
||||
}
|
||||
sessionData = newSession;
|
||||
} else {
|
||||
// Update metadata with latest Spotify data
|
||||
@@ -158,11 +238,12 @@ export default function LoginScreen({ navigation }) {
|
||||
// Trigger app main flow
|
||||
enableSpotifyMode();
|
||||
} 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.');
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('🚀 [LoginScreen] Token Exchange Error:', e);
|
||||
Alert.alert('Erro de Autenticação', e.message || 'Não foi possível trocar o código pelo token.');
|
||||
console.warn('🚀 [LoginScreen] Token Exchange Error:', e?.message || e);
|
||||
showSpotifyLoginFailure();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -189,17 +270,24 @@ export default function LoginScreen({ navigation }) {
|
||||
const handleResetAuth = async () => {
|
||||
await supabase.auth.signOut();
|
||||
await clearSpotifyTokens();
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
Alert.alert('Reset', 'Auth state cleared.');
|
||||
};
|
||||
|
||||
const handleSpotifyLogin = async () => {
|
||||
try {
|
||||
console.log("SPOTIFY_CLIENT_ID_EXISTS:", !!SPOTIFY_CLIENT_ID);
|
||||
console.log("SPOTIFY_REDIRECT_URI:", redirectUri);
|
||||
console.log("[LoginScreen] Spotify login start:", {
|
||||
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();
|
||||
} catch (e: any) {
|
||||
console.error('🚀 [LoginScreen] OAuth Error:', e);
|
||||
Alert.alert('Erro de Autenticação', e.message);
|
||||
console.warn('🚀 [LoginScreen] OAuth Error:', e?.message || e);
|
||||
showSpotifyLoginFailure();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||
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 { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../services/supabase';
|
||||
import { getDestinationLandmarkImage } from '../../services/destinationImage';
|
||||
|
||||
interface Props {
|
||||
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) {
|
||||
const { user } = useAuth();
|
||||
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 ? (
|
||||
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
|
||||
) : trips.length > 0 ? (
|
||||
trips.map(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>
|
||||
))
|
||||
trips.map(trip => <TripCard key={trip.id} trip={trip} />)
|
||||
) : (
|
||||
<View style={styles.promptCard}>
|
||||
<Text style={styles.promptTitle}>Pronto para a próxima?</Text>
|
||||
@@ -167,23 +244,54 @@ const styles = StyleSheet.create({
|
||||
tripImage: {
|
||||
height: 180,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
imageOverlay: {
|
||||
padding: 20,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
overflow: 'hidden',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
},
|
||||
tripTitle: {
|
||||
color: colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
tripImageStyle: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
},
|
||||
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,
|
||||
fontSize: 14,
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
marginBottom: 6,
|
||||
},
|
||||
tripImageSubtitle: {
|
||||
color: colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
opacity: 0.92,
|
||||
},
|
||||
tripContent: {
|
||||
padding: 20,
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function NewTripScreen({ navigation }) {
|
||||
setDuration(finalDuration);
|
||||
|
||||
let generatedPlaylistUrl = null;
|
||||
let spotifyPremiumRequired = false;
|
||||
|
||||
try {
|
||||
console.log("GENERATING_PLAYLIST_FOR_TRIP:", tripName);
|
||||
@@ -95,14 +96,21 @@ export default function NewTripScreen({ navigation }) {
|
||||
}
|
||||
} else if (!testRes.ok) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (!providerToken) {
|
||||
console.log("Spotify token missing or expired, skipping playlist generation.");
|
||||
Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.');
|
||||
if (!spotifyPremiumRequired) {
|
||||
Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.');
|
||||
}
|
||||
} else {
|
||||
// B. Fetch Spotify User ID
|
||||
const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', {
|
||||
@@ -182,8 +190,13 @@ export default function NewTripScreen({ navigation }) {
|
||||
|
||||
if (!searchRes.ok) {
|
||||
const errText = await searchRes.text();
|
||||
console.error("Search failed:", errText);
|
||||
Alert.alert('Erro Spotify', `Aviso ao adicionar músicas: ${errText.substring(0, 100)}`);
|
||||
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)}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -212,7 +225,7 @@ export default function NewTripScreen({ navigation }) {
|
||||
const chunkSize = 100;
|
||||
for (let i = 0; i < trackUris.length; 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',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${providerToken}`,
|
||||
@@ -220,15 +233,30 @@ export default function NewTripScreen({ navigation }) {
|
||||
},
|
||||
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);
|
||||
} else {
|
||||
console.error("No tracks found for genres:", seed_genres);
|
||||
console.warn("No tracks found for genres:", seed_genres);
|
||||
}
|
||||
}
|
||||
} catch (playlistError: any) {
|
||||
console.warn("Expected failure generating playlist:", playlistError.message || playlistError);
|
||||
Alert.alert('Erro Playlist', `A viagem foi calculada, mas a playlist falhou: ${playlistError.message?.substring(0, 50) || 'Erro Desconhecido'}`);
|
||||
const playlistErrorMessage = String(playlistError?.message || playlistError || '');
|
||||
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
|
||||
@@ -250,7 +278,13 @@ export default function NewTripScreen({ navigation }) {
|
||||
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);
|
||||
} 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) {
|
||||
console.error("Exception during DB save:", dbEx);
|
||||
|
||||
339
src/services/destinationImage.ts
Normal file
339
src/services/destinationImage.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user