Files
RoadtripDJ/src/screens/trip/NewTripScreen.tsx
2026-06-12 12:10:05 +01:00

1177 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, KeyboardAvoidingView, ScrollView, Platform, ActivityIndicator, Alert, Linking, StatusBar } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { X, MapPin, ArrowRight, Navigation } from 'lucide-react-native';
import { colors } from '../../utils/colors';
import { supabase } from '../../services/supabase';
import { getSpotifyAccessToken, refreshSpotifyToken, clearSpotifyTokens } from '../../auth/spotifyToken';
import { OLLAMA_API_URL } from '../../services/ollama';
import AsyncStorage from '@react-native-async-storage/async-storage';
// ── Trip Stop types & route helpers ──────────────────────────────────────────
interface TripStop {
name: string;
category: string;
address: string;
latitude: number;
longitude: number;
place_id: string;
index: number;
}
type SpotifyArtist = {
id?: string | null;
name?: string | null;
};
type SpotifySearchTrack = {
id?: string | null;
uri?: string | null;
duration_ms?: number | null;
is_local?: boolean | null;
is_playable?: boolean | null;
artists?: SpotifyArtist[] | null;
};
type SelectedSpotifyTrack = {
id: string;
uri: string;
duration_ms: number;
};
/** Maps trip duration (seconds) to the target number of stops. */
function getStopCount(durationSeconds: number): number {
const minutes = durationSeconds / 60;
if (minutes < 60) return 0;
if (minutes < 180) return 1;
if (minutes < 360) return 3;
if (minutes < 720) return 5;
if (minutes < 1200) return 7;
return 8;
}
/**
* Returns N {lat, lng} points distributed at equal time intervals along the
* route, using the step list from the Google Directions API leg.
*/
function getRouteWaypoints(
steps: any[],
totalDurationSeconds: number,
count: number
): Array<{ lat: number; lng: number }> {
if (count === 0 || steps.length === 0) return [];
const waypoints: Array<{ lat: number; lng: number }> = [];
let cumulative = 0;
let nextTarget = 1;
for (const step of steps) {
cumulative += (step.duration?.value ?? 0);
while (nextTarget <= count) {
const target = (nextTarget / (count + 1)) * totalDurationSeconds;
if (cumulative >= target) {
waypoints.push({ lat: step.end_location.lat, lng: step.end_location.lng });
nextTarget++;
} else {
break;
}
}
if (nextTarget > count) break;
}
// Pad with last step coordinates if the route ran short
const last = steps[steps.length - 1];
while (waypoints.length < count && last) {
waypoints.push({ lat: last.end_location?.lat ?? 0, lng: last.end_location?.lng ?? 0 });
}
return waypoints;
}
const artistGenreCache = new Map<string, string[]>();
async function getArtistGenres(artistId: string, token: string): Promise<string[]> {
if (!artistId) return [];
if (artistGenreCache.has(artistId)) {
return artistGenreCache.get(artistId) || [];
}
try {
const res = await fetch(`https://api.spotify.com/v1/artists/${artistId}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
const genres = data.genres || [];
artistGenreCache.set(artistId, genres);
return genres;
} else {
console.warn(`[SpotifyPlaylists] Failed to fetch artist ${artistId}: status ${res.status}`);
}
} catch (err) {
console.warn(`[SpotifyPlaylists] Error fetching artist ${artistId}:`, err);
}
return [];
}
function genreMatches(artistGenres: string[], favoriteGenre: string): boolean {
const normalizedFav = favoriteGenre.toLowerCase().trim();
const synonymMap: Record<string, string[]> = {
'fado': ['fado', 'portuguese fado'],
'rock': ['rock', 'classic rock', 'alternative rock'],
'rap': ['rap', 'hip hop', 'portuguese hip hop', 'hip-hop'],
'hip hop': ['hip hop', 'rap', 'hip-hop', 'portuguese hip hop'],
'hip-hop': ['hip hop', 'rap', 'hip-hop', 'portuguese hip hop'],
'pop': ['pop', 'portuguese pop'],
'funk': ['funk', 'baile funk', 'funk carioca'],
'electronic': ['electronic', 'edm', 'house', 'techno', 'electro']
};
const allowedSynonyms = [
normalizedFav,
...(synonymMap[normalizedFav] || [])
];
for (const genre of artistGenres) {
const normalizedGenre = genre.toLowerCase();
for (const syn of allowedSynonyms) {
if (normalizedGenre === syn || normalizedGenre.includes(syn)) {
return true;
}
}
}
return false;
}
// ─────────────────────────────────────────────────────────────────────────────
// @ts-ignore
export default function NewTripScreen({ navigation }) {
const [tripName, setTripName] = useState('');
const [origin, setOrigin] = useState('');
const [destination, setDestination] = useState('');
const [distance, setDistance] = useState('');
const [duration, setDuration] = useState('');
const [loading, setLoading] = useState(false);
const handleCalculateTrip = async () => {
if (!origin || !destination) {
Alert.alert('Erro', 'Por favor preenche ambos os campos de partida e destino.');
return;
}
if (!tripName) {
Alert.alert('Erro', 'Por favor dá um nome à tua viagem.');
return;
}
setLoading(true);
try {
const apiKey = 'AIzaSyDocu-PEHAyrdV8OUEPMXye9A_rpYzOA34';
const url = `https://maps.googleapis.com/maps/api/directions/json?origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(destination)}&key=${apiKey}`;
const response = await fetch(url);
const data = await response.json();
if (data.status === 'OK') {
const leg = data.routes[0].legs[0];
const finalDistance = leg.distance.text;
const finalDuration = leg.duration.text;
const tripDurationMs = leg.duration.value * 1000;
setDistance(finalDistance);
setDuration(finalDuration);
let generatedPlaylistUrl = null;
let playlistCreationFailed = false;
let playlistFailureReason = '';
let playlistSuccessMessage = 'Viagem e playlist criadas com sucesso.';
let hasGenre = false;
let accumulatedDurationMs = 0;
try {
console.log("GENERATING_PLAYLIST_FOR_TRIP:", tripName);
console.log("PLAYLIST_CREATE_START");
// Helper for robust parsing
const safeParseJson = async (res: Response, label: string) => {
const rawText = await res.text();
console.log(`PLAYLIST_API_STATUS [${label}]:`, res.status);
console.log(`PLAYLIST_API_CONTENT_TYPE [${label}]:`, res.headers.get("content-type"));
console.log(`PLAYLIST_API_RAW_RESPONSE [${label}]:`, rawText.substring(0, 300) + (rawText.length > 300 ? "..." : ""));
if (!res.ok) {
throw new Error(`Spotify API returned status ${res.status} for [${label}]: ${rawText.substring(0, 150)}`);
}
const contentType = res.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
throw new Error(`Playlist API returned non-JSON response for [${label}]: ${rawText.substring(0, 150)}`);
}
try {
return JSON.parse(rawText);
} catch (e) {
throw new Error(`Failed to parse JSON response for [${label}]: ${rawText.substring(0, 150)}`);
}
};
// A. Get provider token
let providerToken = await getSpotifyAccessToken();
console.log("SPOTIFY_ACCESS_TOKEN_EXISTS:", !!providerToken);
if (providerToken) {
// Validate token via GET /v1/me (free endpoint, no Premium required)
let meRes = await fetch('https://api.spotify.com/v1/me', {
headers: { Authorization: `Bearer ${providerToken}` }
});
if (meRes.status === 401) {
console.log("Spotify token is invalid/expired (401), attempting to refresh...");
const newToken = await refreshSpotifyToken();
if (newToken) {
providerToken = newToken;
console.log("Spotify token refreshed successfully!");
} else {
console.log("Failed to refresh Spotify token.");
providerToken = null;
}
} else if (!meRes.ok) {
const meErr = await meRes.text();
console.warn("Spotify GET /v1/me failed:", meRes.status, meErr);
providerToken = null;
} else {
console.log("Spotify token valid (GET /v1/me returned 200 OK).");
}
}
if (!providerToken) {
console.log("Spotify token missing or expired, skipping playlist generation.");
playlistCreationFailed = true;
playlistFailureReason = 'token';
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 (reuse /v1/me — already validated above)
const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', {
headers: {
'Authorization': `Bearer ${providerToken}`,
'Content-Type': 'application/json'
}
});
const spotifyUserData = await safeParseJson(spotifyUserRes, 'SpotifyUser');
const spotifyUserId = spotifyUserData?.id ?? null;
const spotifyUserCountry = spotifyUserData?.country || 'PT';
console.log("SPOTIFY_USER_ID_EXISTS:", !!spotifyUserId);
if (!spotifyUserId) throw new Error('Could not fetch Spotify User ID from /v1/me');
// C. Build varied music queries using AI + favorite genre
function shuffleArray<T>(array: T[]): T[] {
const copy = [...array];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
function cleanSearchQuery(query: string): string {
return query
.replace(/["[\]]/g, "")
.replace(/\s+/g, " ")
.trim();
}
function getRandomSpotifyOffset(): number {
const offsets = [0, 10, 20, 30, 40, 50];
return offsets[Math.floor(Math.random() * offsets.length)];
}
function getMainArtistKey(track: any): string {
return (
track?.artists?.[0]?.id ||
track?.artists?.[0]?.name ||
"unknown_artist"
);
}
async function readFavoriteGenreForPlaylist(): Promise<string> {
try {
const {
data: { session },
} = await supabase.auth.getSession();
const appUserId = session?.user?.id ?? null;
const possibleKeys = [
appUserId ? `favoriteGenre:${appUserId}` : "",
appUserId ? `userFavoriteGenre:${appUserId}` : "",
appUserId ? `@roadtripdj:favoriteGenre:${appUserId}` : "",
"favoriteGenre",
"userFavoriteGenre",
"@roadtripdj:favoriteGenre",
].filter(Boolean);
for (const key of possibleKeys) {
const value = await AsyncStorage.getItem(key);
if (value && value.trim().length > 0) {
return value.trim();
}
}
if (appUserId) {
try {
const { data } = await supabase
.from("profiles")
.select("favorite_genre")
.eq("id", appUserId)
.maybeSingle();
const genre = (data as any)?.favorite_genre;
if (genre && String(genre).trim().length > 0) {
return String(genre).trim();
}
} catch {
// Ignore profile lookup errors. Favorite genre is optional.
}
try {
const { data } = await supabase
.from("profiles")
.select("favoriteGenre")
.eq("id", appUserId)
.maybeSingle();
const genre = (data as any)?.favoriteGenre;
if (genre && String(genre).trim().length > 0) {
return String(genre).trim();
}
} catch {
// Ignore profile lookup errors. Favorite genre is optional.
}
}
} catch (error) {
console.warn("Failed to read favorite genre:", error);
}
return "";
}
const favoriteGenre = await readFavoriteGenreForPlaylist();
console.log("PLAYLIST_FAVORITE_GENRE_USED:", favoriteGenre || "none");
hasGenre = Boolean(favoriteGenre && favoriteGenre.trim().length > 0);
const cleanGenre = hasGenre ? favoriteGenre.trim().toLowerCase() : "";
console.log('[Playlist] favoriteMusicStyle:', favoriteGenre || '(empty)');
// ── Style → curated query map (artist-based; genre: filter unreliable for tracks)
const STYLE_QUERY_MAP: Record<string, string[]> = {
fado: [
'Amália Rodrigues', 'Mariza fado', 'Carlos do Carmo', 'Ana Moura',
'Dulce Pontes', 'Camané', 'Carminho fado', 'Mísia fado',
'Madredeus fado', 'João Braga fado', 'Rodrigo fado', 'Celeste Rodrigues',
'fado português', 'fado clássico', 'fado moderno', 'fado novo',
'músicas de fado', 'fado Lisboa', 'fado Coimbra',
],
rock: [
'classic rock hits', 'rock road trip', 'alternative rock',
'rock classics', 'rock driving', 'hard rock hits', 'rock anthems',
'indie rock', 'portuguese rock', 'rock nacional',
],
rap: [
'rap português', 'portuguese hip hop', 'rap viagem', 'hip hop road trip',
'trap hits', 'rap nacional', 'rap clássico', 'rap popular',
],
'hip hop': [
'hip hop road trip', 'hip hop hits', 'rap viagem',
'portuguese hip hop', 'trap music', 'hip hop classics',
],
pop: [
'pop português', 'pop hits', 'pop road trip', 'pop viagem',
'pop clássico', 'pop moderno', 'pop nacional',
],
funk: [
'funk hits', 'baile funk', 'funk road trip', 'funk clássico',
'funk carioca', 'funk nacional',
],
electronic: [
'electronic road trip', 'edm hits', 'house music driving',
'techno', 'electronic dance', 'electro hits',
],
};
// Resolve style queries: use STYLE_QUERY_MAP if we recognise the genre, else build generic ones
const styleQueries: string[] = hasGenre
? (
STYLE_QUERY_MAP[cleanGenre] ??
[
cleanGenre,
`${cleanGenre} hits`,
`${cleanGenre} popular`,
`${cleanGenre} viagem`,
`${cleanGenre} clássico`,
`${cleanGenre} moderno`,
`músicas de ${cleanGenre}`,
]
)
: [];
// AI supplement: ask Ollama for artists/songs of the style only when genre is set
const ollamaPrompt = hasGenre
? `The user's favorite music genre is: "${cleanGenre}".
Reply ONLY with a JSON array of up to 10 well-known song titles or artist names that belong strictly to the "${cleanGenre}" genre.
Do NOT include songs from other genres.
Do NOT include explanation or markdown.
Example for fado: ["Amália Rodrigues", "Mariza", "Ana Moura fado", "Carlos do Carmo"].`
: `I am taking a roadtrip from ${origin} to ${destination} ("${tripName}", ${duration}).
Reply ONLY with a JSON array of up to 10 varied Spotify search queries for a road trip playlist.
Do NOT return song names. Do NOT return explanations.
Example: ["feel good road trip", "summer hits", "driving pop hits"].`;
let aiArtistQueries: string[] = [];
try {
const ollamaRes = await fetch(`${OLLAMA_API_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'qwen3-coder:30b',
messages: [{ role: 'user', content: ollamaPrompt }],
stream: false,
}),
});
const ollamaData = await safeParseJson(ollamaRes, 'Ollama');
let rawAiText = (ollamaData?.message?.content || '')
.replace(/```json/g, '').replace(/```/g, '').trim();
if (rawAiText.startsWith('[')) {
const parsed = JSON.parse(rawAiText);
if (Array.isArray(parsed)) {
aiArtistQueries = parsed.map(String).map(cleanSearchQuery).filter(Boolean).slice(0, 10);
}
}
} catch {
console.log('[Playlist] AI query generation failed; continuing without it.');
}
// Generic roadtrip filler queries (only used in Phase 2 when no genre, or to pad when genre exhausted)
const genericFillerQueries = [
`${destination} road trip`, `${origin} to ${destination} music`,
'road trip hits', 'travel songs', 'driving music',
'summer hits', 'feel good road trip', 'top hits Portugal',
].map(cleanSearchQuery).filter(Boolean);
// ── Target track count (based on trip duration, ~4 min per song)
const tripDurationMinutes = tripDurationMs / 60000;
const TARGET_TRACK_COUNT = Math.max(10, Math.ceil(tripDurationMinutes / 4));
const STYLE_RATIO = 0.80;
const styleTargetCount = hasGenre ? Math.ceil(TARGET_TRACK_COUNT * STYLE_RATIO) : 0;
console.log('[Playlist] targetTrackCount:', TARGET_TRACK_COUNT);
console.log('[Playlist] styleTargetCount:', styleTargetCount);
console.log('[Playlist] styleQueries:', styleQueries);
const MAX_TRACKS_PER_ARTIST = 3;
// Helper to run Spotify track search for a given query and return accepted tracks
const selectedTrackIds = new Set<string>();
const artistCount = new Map<string, number>();
let totalRawResultsCount = 0;
let tracksRejectedCount = 0;
let searchRequestsCount = 0;
const searchAndFilter = async (
query: string,
validateGenre: boolean,
): Promise<SelectedSpotifyTrack[]> => {
const results: SelectedSpotifyTrack[] = [];
const offsets = [0, 10, 20, 30];
for (const offset of offsets) {
if (accumulatedDurationMs >= tripDurationMs) break;
if (searchRequestsCount >= 60) break;
const queryEncoded = encodeURIComponent(query);
const searchUrl =
`https://api.spotify.com/v1/search?type=track` +
`&q=${queryEncoded}&limit=20&market=${spotifyUserCountry}&offset=${offset}`;
console.log('TRACK_SEARCH_QUERY:', query, '| offset:', offset);
const searchRes = await fetch(searchUrl, {
headers: { Authorization: `Bearer ${providerToken}`, 'Content-Type': 'application/json' },
});
searchRequestsCount++;
if (!searchRes.ok) {
const errText = await searchRes.text();
console.warn('[Playlist] Spotify search failed:', searchRes.status, errText.substring(0, 150));
break;
}
const searchData = (await safeParseJson(searchRes, 'SearchTracks')) as any;
const rawTracks: SpotifySearchTrack[] = Array.isArray(searchData?.tracks?.items)
? searchData.tracks.items : [];
totalRawResultsCount += rawTracks.length;
console.log(`[Playlist] Query "${query}" offset ${offset}: ${rawTracks.length} raw tracks`);
for (const track of rawTracks) {
if (accumulatedDurationMs >= tripDurationMs) break;
const trackId = track.id;
const trackUri = track.uri;
const trackDurationMs = track.duration_ms;
if (!trackId || !trackUri || !trackDurationMs) continue;
if (track.is_local === true) continue;
if (track.is_playable === false) continue;
if (selectedTrackIds.has(trackId)) continue;
if (validateGenre && hasGenre) {
const mainArtistId = track.artists?.[0]?.id;
if (!mainArtistId) { tracksRejectedCount++; continue; }
const artistGenres = await getArtistGenres(mainArtistId, providerToken);
if (!genreMatches(artistGenres, cleanGenre)) {
console.log(`[Playlist] REJECTED: "${track.artists?.[0]?.name}" genres=[${artistGenres.join(', ')}]`);
tracksRejectedCount++;
continue;
}
}
const artistKey = getMainArtistKey(track);
if ((artistCount.get(artistKey) ?? 0) >= MAX_TRACKS_PER_ARTIST) continue;
results.push({ id: trackId, uri: trackUri, duration_ms: trackDurationMs });
}
// Don't paginate if we already have plenty of tracks from this query
if (rawTracks.length < 20) break;
}
return results;
};
// D. Create empty Spotify playlist
const createPlaylistUrl = 'https://api.spotify.com/v1/me/playlists';
const createPlaylistBody = JSON.stringify({
name: tripName,
description: `Roadtrip from ${origin} to ${destination}${cleanGenre ? ` · ${cleanGenre} vibes` : ''}.`,
public: false,
});
console.log('CREATE_PLAYLIST_URL:', createPlaylistUrl);
const createPlaylistRes = await fetch(createPlaylistUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${providerToken}`,
'Content-Type': 'application/json',
},
body: createPlaylistBody,
});
console.log('CREATE_PLAYLIST_HTTP_STATUS:', createPlaylistRes.status);
const createPlaylistResText = await createPlaylistRes.text();
if (!createPlaylistRes.ok) {
console.log('CREATE_PLAYLIST_RESPONSE_BODY_IF_FAILED:', createPlaylistResText.substring(0, 300));
if (createPlaylistRes.status === 403) {
await clearSpotifyTokens();
console.warn('CREATE_PLAYLIST_403: Cleared stale Spotify tokens. User must reconnect.');
Alert.alert('Permissão Spotify Necessária', 'Reconecta o Spotify para dar permissão de criar playlists.');
playlistCreationFailed = true;
playlistFailureReason = 'scope';
throw new Error('CREATE_PLAYLIST_SCOPE_ERROR: Tokens cleared, re-auth required.');
}
throw new Error(
`Spotify API returned status ${createPlaylistRes.status} for CreatePlaylist: ${createPlaylistResText.substring(0, 150)}`
);
}
let playlistData: any;
try {
playlistData = JSON.parse(createPlaylistResText);
} catch {
throw new Error(`Failed to parse JSON for CreatePlaylist: ${createPlaylistResText.substring(0, 150)}`);
}
if (!playlistData.id) throw new Error('Could not create playlist');
const playlistId = playlistData.id;
generatedPlaylistUrl = playlistData.external_urls.spotify;
console.log('TARGET_PLAYLIST_DURATION_MS:', tripDurationMs);
// ─── PHASE 1: Collect style tracks (80% of target, genre-validated) ─────────
const selectedTracks: SelectedSpotifyTrack[] = [];
const allStyleQueries = [...styleQueries, ...aiArtistQueries];
console.log('[Playlist] Phase 1 style queries:', allStyleQueries);
for (const query of allStyleQueries) {
if (selectedTracks.length >= styleTargetCount) break;
if (accumulatedDurationMs >= tripDurationMs) break;
if (searchRequestsCount >= 60) break;
const tracks = await searchAndFilter(query, true);
for (const t of tracks) {
if (selectedTracks.length >= styleTargetCount) break;
if (accumulatedDurationMs >= tripDurationMs) break;
if (selectedTrackIds.has(t.id)) continue;
const artistKey = getMainArtistKey(t as any);
if ((artistCount.get(artistKey) ?? 0) >= MAX_TRACKS_PER_ARTIST) continue;
selectedTracks.push(t);
selectedTrackIds.add(t.id);
artistCount.set(artistKey, (artistCount.get(artistKey) ?? 0) + 1);
accumulatedDurationMs += t.duration_ms;
}
}
console.log('[Playlist] After Phase 1 styleTracks count:', selectedTracks.length, '| duration (ms):', accumulatedDurationMs);
// ─── PHASE 2: Fill remaining duration with roadtrip filler (no genre check) ─
if (!hasGenre && accumulatedDurationMs < tripDurationMs) {
const fillerQueries = [...aiArtistQueries, ...genericFillerQueries];
console.log('[Playlist] Phase 2 filler queries:', fillerQueries);
for (const query of fillerQueries) {
if (accumulatedDurationMs >= tripDurationMs) break;
if (searchRequestsCount >= 60) break;
const tracks = await searchAndFilter(query, false);
for (const t of tracks) {
if (accumulatedDurationMs >= tripDurationMs) break;
if (selectedTrackIds.has(t.id)) continue;
const artistKey = getMainArtistKey(t as any);
if ((artistCount.get(artistKey) ?? 0) >= MAX_TRACKS_PER_ARTIST) continue;
selectedTracks.push(t);
selectedTrackIds.add(t.id);
artistCount.set(artistKey, (artistCount.get(artistKey) ?? 0) + 1);
accumulatedDurationMs += t.duration_ms;
}
}
console.log('[Playlist] After Phase 2 total tracks:', selectedTracks.length, '| duration (ms):', accumulatedDurationMs);
}
console.log('[Playlist] finalTracks:', selectedTracks.length);
console.log('[SpotifyPlaylistsDebug] Favorite Genre Received:', favoriteGenre || '(empty)');
console.log('[SpotifyPlaylistsDebug] Normalized Genre:', cleanGenre || '(empty)');
console.log('[SpotifyPlaylistsDebug] Target Duration (ms):', tripDurationMs);
console.log('[SpotifyPlaylistsDebug] Raw Tracks Found (Accumulated):', totalRawResultsCount);
console.log('[SpotifyPlaylistsDebug] Tracks Rejected by Genre:', tracksRejectedCount);
console.log('[SpotifyPlaylistsDebug] Final Tracks Added:', selectedTracks.length);
console.log('[SpotifyPlaylistsDebug] Final Playlist Duration (ms):', accumulatedDurationMs);
if (selectedTracks.length > 0) {
// F. Add tracks to playlist in chunks
const trackUris = selectedTracks.map((track) => track.uri);
const chunkSize = 100;
let tracksAddedSuccessfully = true;
for (let i = 0; i < trackUris.length; i += chunkSize) {
const chunk = trackUris.slice(i, i + chunkSize);
const addTracksRes = await fetch(
`https://api.spotify.com/v1/playlists/${playlistId}/items`,
{
method: "POST",
headers: {
Authorization: `Bearer ${providerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ uris: chunk }),
}
);
console.log("ADD_TRACKS_HTTP_STATUS:", addTracksRes.status);
if (!addTracksRes.ok) {
const addTracksErr = await addTracksRes.text();
console.log(
"ADD_TRACKS_RESPONSE_BODY_IF_FAILED:",
addTracksErr.substring(0, 300)
);
tracksAddedSuccessfully = false;
throw new Error(
`Spotify API returned status ${addTracksRes.status
} while adding tracks: ${addTracksErr.substring(0, 150)}`
);
}
}
if (tracksAddedSuccessfully) {
console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl);
playlistCreationFailed = false;
if (hasGenre) {
// Accept a tolerance of about 5-10 minutes (300,000 to 600,000 ms) below trip duration
if (accumulatedDurationMs < tripDurationMs - 300000) {
playlistSuccessMessage = "A playlist foi criada apenas com músicas do estilo escolhido, mas ficou mais curta porque não foram encontradas músicas suficientes.";
} else {
playlistSuccessMessage = "Viagem e playlist criadas com sucesso apenas com músicas do estilo escolhido!";
}
} else {
if (
accumulatedDurationMs < tripDurationMs - 60000 &&
(selectedTracks.length >= MAX_TRACKS ||
searchRequestsCount >= MAX_SEARCH_REQUESTS)
) {
const hours = Math.round((accumulatedDurationMs / 3600000) * 10) / 10;
if (hours >= 1) {
playlistSuccessMessage = `Viagem guardada! Playlist criada com ${hours} horas de música.`;
} else {
const minutes = Math.round(accumulatedDurationMs / 60000);
playlistSuccessMessage = `Viagem guardada! Playlist criada com ${minutes} minutos de música.`;
}
}
}
}
} else {
console.warn("No tracks found for queries:", searchQueries);
playlistCreationFailed = true;
playlistFailureReason = "notracks";
}
}
} catch (playlistError: any) {
const playlistErrorMessage = String(playlistError?.message || playlistError || '');
playlistCreationFailed = true;
playlistFailureReason = 'error';
console.warn("Playlist generation failed:", playlistErrorMessage);
Alert.alert('Erro Playlist', `A viagem foi calculada, mas a playlist falhou: ${playlistErrorMessage.substring(0, 80) || 'Erro Desconhecido'}`);
}
// G. Save to Supabase unconditionally if route is valid
try {
const { data: { session } } = await supabase.auth.getSession();
const userId = session?.user?.id || null;
const { error: dbError } = await supabase.from('trips').insert({
user_id: userId,
title: tripName,
origin,
destination,
distance: finalDistance,
duration: finalDuration,
playlist_url: generatedPlaylistUrl
});
if (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);
} else {
if (playlistCreationFailed) {
if (playlistFailureReason === 'notracks') {
if (hasGenre) {
Alert.alert('Aviso', 'Não foi possível encontrar músicas suficientes desse estilo. Tenta outro estilo musical.');
} else {
Alert.alert('Sucesso!', 'Playlist criada, mas não foram encontradas músicas para adicionar.');
}
} else {
Alert.alert('Sucesso!', 'Viagem guardada! A playlist do Spotify não pôde ser criada, mas a viagem foi guardada.');
}
} else if (generatedPlaylistUrl) {
if (hasGenre && accumulatedDurationMs < tripDurationMs - 300000) {
Alert.alert('Aviso', playlistSuccessMessage);
} else {
Alert.alert('Sucesso!', playlistSuccessMessage);
}
} else {
Alert.alert('Sucesso!', 'Viagem calculada e guardada!');
}
navigation.goBack();
}
} catch (dbEx) {
console.error("Exception during DB save:", dbEx);
}
} else {
// O NOSSO DETETIVE ENTRA AQUI!
console.log("ERRO DA GOOGLE:", data);
Alert.alert(
'Culpado Encontrado',
`Motivo: ${data.status}\nDetalhe: ${data.error_message || 'Vê o terminal preto do PC'}`
);
}
} catch (error) {
Alert.alert('Erro', 'Ocorreu um erro ao comunicar com a API da Google.');
console.log("Erro de código:", error);
} finally {
setLoading(false);
}
};
const handleOpenGoogleMaps = () => {
if (!origin || !destination) {
Alert.alert('Erro', 'Por favor preenche ambos os campos de partida e destino.');
return;
}
const url = `https://www.google.com/maps/dir/?api=1&origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(destination)}`;
Linking.openURL(url);
};
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Nova Viagem</Text>
<TouchableOpacity
style={styles.closeButton}
onPress={() => navigation.goBack()}
>
<X color={colors.textMain} size={20} />
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={styles.scrollContent}>
{/* Map Area Placeholder */}
<View style={styles.mapArea}>
{/* Using a solid light gray color instead of a complex map image to keep it clean */}
<View style={styles.mockRouteVisual}>
<View style={styles.routeDotLarge} />
<View style={styles.routeLineDashed} />
<View style={styles.routePinLarge}>
<MapPin color={colors.white} size={14} />
</View>
</View>
</View>
{/* Form Card */}
<View style={styles.formCard}>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>NOME DA VIAGEM</Text>
<TextInput
style={styles.textInput}
placeholder="Ex: Fim de semana no Algarve"
placeholderTextColor={colors.textSecondary}
value={tripName}
onChangeText={setTripName}
/>
</View>
<View style={styles.routeInputContainer}>
{/* Visual timeline on the left */}
<View style={styles.routeTimeline}>
<View style={styles.timelineDot} />
<View style={styles.timelineLine} />
<MapPin color={colors.textSecondary} size={16} style={styles.timelinePin} />
</View>
<View style={styles.routeInputs}>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>PARTIDA</Text>
<TextInput
style={[styles.textInput, styles.routeTextInput]}
placeholder="Ex: Lisboa, Portugal"
placeholderTextColor={colors.textSecondary}
value={origin}
onChangeText={setOrigin}
/>
</View>
<View style={[styles.inputGroup, { marginBottom: 0 }]}>
<Text style={styles.inputLabel}>DESTINO</Text>
<TextInput
style={[styles.textInput, styles.routeTextInput]}
placeholder="Ex: Porto, Portugal"
placeholderTextColor={colors.textSecondary}
value={destination}
onChangeText={setDestination}
/>
</View>
</View>
</View>
{/* Results Section */}
{(distance || duration) ? (
<View style={styles.resultsContainer}>
<View style={styles.resultItem}>
<Text style={styles.resultLabel}>Distância</Text>
<Text style={styles.resultValue}>{distance}</Text>
</View>
<View style={styles.resultDivider} />
<View style={styles.resultItem}>
<Text style={styles.resultLabel}>Duração</Text>
<Text style={styles.resultValue}>{duration}</Text>
</View>
</View>
) : null}
</View>
{/* Bottom Actions */}
<View style={styles.bottomActions}>
<TouchableOpacity
style={styles.primaryButton}
onPress={handleCalculateTrip}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={colors.white} />
) : (
<>
<Text style={styles.primaryButtonText}>Calcular Viagem</Text>
<ArrowRight color={colors.white} size={20} />
</>
)}
</TouchableOpacity>
{(distance || duration) ? (
<TouchableOpacity
style={styles.secondaryButton}
onPress={handleOpenGoogleMaps}
>
<Navigation color={colors.primary} size={20} />
<Text style={styles.secondaryButtonText}>Abrir no Google Maps</Text>
</TouchableOpacity>
) : null}
<Text style={styles.disclaimerText}>
A IA vai analisar o trajeto, pontos de interesse, clima{'\n'}e duração para criar a banda sonora perfeita.
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.white,
},
container: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 16,
backgroundColor: colors.white,
zIndex: 10,
},
title: {
fontSize: 22,
fontWeight: 'bold',
color: colors.textMain,
},
closeButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.inputBackground,
justifyContent: 'center',
alignItems: 'center',
},
mapArea: {
height: 180,
backgroundColor: '#F0F2F5', // Light map-like gray
justifyContent: 'center',
alignItems: 'center',
},
mockRouteVisual: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 40,
width: '100%',
},
routeDotLarge: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.primary,
borderWidth: 4,
borderColor: colors.white,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
zIndex: 2,
},
routeLineDashed: {
flex: 1,
height: 4,
borderWidth: 2,
borderColor: colors.primary,
borderStyle: 'dashed',
marginHorizontal: -4, // Overlap slightly
zIndex: 1,
},
routePinLarge: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#000000',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.white,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
zIndex: 2,
},
formCard: {
backgroundColor: colors.white,
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
padding: 24,
marginTop: -32, // Overlap the map
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.05,
shadowRadius: 12,
elevation: 10,
},
inputGroup: {
marginBottom: 20,
},
inputLabel: {
fontSize: 12,
fontWeight: 'bold',
color: colors.textSecondary,
marginBottom: 8,
letterSpacing: 0.5,
},
textInput: {
backgroundColor: colors.inputBackground,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 16,
fontSize: 16,
color: colors.textMain,
fontWeight: '500',
},
routeInputContainer: {
flexDirection: 'row',
marginTop: 10,
},
routeTimeline: {
alignItems: 'center',
width: 30,
marginTop: 38, // Align with inputs
marginRight: 8,
},
timelineDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.primary,
},
timelineLine: {
width: 1,
height: 60,
backgroundColor: colors.inputBorder,
marginVertical: 4,
},
timelinePin: {
marginTop: 4,
},
routeInputs: {
flex: 1,
},
routeTextInput: {
fontWeight: 'bold',
},
bottomActions: {
paddingHorizontal: 24,
paddingBottom: 40,
backgroundColor: colors.white,
},
primaryButton: {
backgroundColor: colors.primary,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 18,
borderRadius: 16,
marginBottom: 16,
shadowColor: colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
primaryButtonText: {
color: colors.white,
fontSize: 18,
fontWeight: 'bold',
marginRight: 8,
},
disclaimerText: {
textAlign: 'center',
color: colors.textSecondary,
fontSize: 12,
lineHeight: 18,
fontWeight: '500',
},
resultsContainer: {
flexDirection: 'row',
marginTop: 24,
paddingTop: 24,
borderTopWidth: 1,
borderTopColor: colors.inputBorder,
},
resultItem: {
flex: 1,
alignItems: 'center',
},
resultDivider: {
width: 1,
backgroundColor: colors.inputBorder,
marginHorizontal: 16,
},
resultLabel: {
fontSize: 12,
color: colors.textSecondary,
marginBottom: 4,
fontWeight: 'bold',
},
resultValue: {
fontSize: 18,
color: colors.textMain,
fontWeight: 'bold',
},
secondaryButton: {
backgroundColor: colors.inputBackground,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 18,
borderRadius: 16,
marginBottom: 16,
},
secondaryButtonText: {
color: colors.primary,
fontSize: 16,
fontWeight: 'bold',
marginLeft: 8,
},
});