Compare commits

..

4 Commits

Author SHA1 Message Date
Eduardo Silva
34dd03b1f6 WIP playlist style fix 2026-06-12 12:10:05 +01:00
b502252811 Fix playlist music style preference 2026-06-12 01:48:24 +01:00
ad8042adaa Save current app updates 2026-05-28 23:55:35 +01:00
d85e327c07 Save current app progress 2026-05-28 22:55:43 +01:00
6 changed files with 1105 additions and 639 deletions

21
scratch_db_test.js Normal file
View File

@@ -0,0 +1,21 @@
const supabaseUrl = "https://qyvnryhskgmvgjajqqru.supabase.co";
const supabaseKey = "sb_publishable_fazCCLmO7XjtryY28ePR-A_CS7aU6fF";
async function run() {
try {
const res = await fetch(`${supabaseUrl}/rest/v1/profiles?limit=1`, {
method: "GET",
headers: {
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
"Content-Type": "application/json"
}
});
const data = await res.json();
console.log("PROFILES_QUERY_RESPONSE:", JSON.stringify(data, null, 2));
} catch (err) {
console.error("Error querying profiles:", err);
}
}
run();

View File

@@ -6,9 +6,7 @@ interface AuthContextType {
user: User | null; user: User | null;
session: Session | null; session: Session | null;
loading: boolean; loading: boolean;
isDemoMode: boolean;
isSpotifyAuthenticated: boolean; isSpotifyAuthenticated: boolean;
enableDemoMode: () => void;
enableSpotifyMode: () => void; enableSpotifyMode: () => void;
} }
@@ -16,9 +14,7 @@ const AuthContext = createContext<AuthContextType>({
user: null, user: null,
session: null, session: null,
loading: true, loading: true,
isDemoMode: false,
isSpotifyAuthenticated: false, isSpotifyAuthenticated: false,
enableDemoMode: () => {},
enableSpotifyMode: () => {}, enableSpotifyMode: () => {},
}); });
@@ -28,18 +24,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isDemoMode, setIsDemoMode] = useState(false);
const [isSpotifyAuthenticated, setIsSpotifyAuthenticated] = useState(false); const [isSpotifyAuthenticated, setIsSpotifyAuthenticated] = useState(false);
const enableDemoMode = () => {
setIsDemoMode(true);
setIsSpotifyAuthenticated(false);
setUser({ id: '00000000-0000-4000-8000-000000000002', email: 'demo@roadtripdj.com' } as User);
setLoading(false);
};
const enableSpotifyMode = () => { const enableSpotifyMode = () => {
setIsDemoMode(false);
setIsSpotifyAuthenticated(true); setIsSpotifyAuthenticated(true);
setLoading(false); setLoading(false);
}; };
@@ -49,12 +36,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
setSession(session); setSession(session);
setUser(session?.user ?? null); setUser(session?.user ?? null);
if (!session) { if (!session) {
setIsDemoMode(false);
setIsSpotifyAuthenticated(false); setIsSpotifyAuthenticated(false);
} else { } else {
const isSpotify = !!session.user?.user_metadata?.spotify_id; const isSpotify = !!session.user?.user_metadata?.spotify_id;
setIsSpotifyAuthenticated(isSpotify); setIsSpotifyAuthenticated(isSpotify);
setIsDemoMode(false);
} }
setLoading(false); setLoading(false);
}); });
@@ -63,12 +48,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
setSession(session); setSession(session);
setUser(session?.user ?? null); setUser(session?.user ?? null);
if (!session) { if (!session) {
setIsDemoMode(false);
setIsSpotifyAuthenticated(false); setIsSpotifyAuthenticated(false);
} else { } else {
const isSpotify = !!session.user?.user_metadata?.spotify_id; const isSpotify = !!session.user?.user_metadata?.spotify_id;
setIsSpotifyAuthenticated(isSpotify); setIsSpotifyAuthenticated(isSpotify);
setIsDemoMode(false);
} }
setLoading(false); setLoading(false);
}); });
@@ -77,7 +60,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
}, []); }, []);
return ( return (
<AuthContext.Provider value={{ user, session, loading, isDemoMode, isSpotifyAuthenticated, enableDemoMode, enableSpotifyMode }}> <AuthContext.Provider value={{ user, session, loading, isSpotifyAuthenticated, enableSpotifyMode }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -18,7 +18,7 @@ const discovery: DiscoveryDocument = {
// @ts-ignore // @ts-ignore
export default function LoginScreen({ navigation }) { export default function LoginScreen({ navigation }) {
const { enableDemoMode, enableSpotifyMode } = useAuth(); const { enableSpotifyMode } = useAuth();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -305,14 +305,6 @@ export default function LoginScreen({ navigation }) {
setLoading(false); setLoading(false);
}; };
const handleResetAuth = async () => {
await supabase.auth.signOut();
await clearSpotifyTokens();
setEmail('');
setPassword('');
Alert.alert('Reset', 'Auth state cleared.');
};
const handleSpotifyLogin = async () => { const handleSpotifyLogin = async () => {
try { try {
console.log("[SpotifyAuthDebug] 1. Exact redirectUri at login press:", redirectUri); console.log("[SpotifyAuthDebug] 1. Exact redirectUri at login press:", redirectUri);
@@ -394,22 +386,6 @@ export default function LoginScreen({ navigation }) {
<Text style={styles.spotifyButtonText}>Entrar com Spotify</Text> <Text style={styles.spotifyButtonText}>Entrar com Spotify</Text>
</TouchableOpacity> </TouchableOpacity>
{__DEV__ && (
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: '#333', marginBottom: 24 }]}
onPress={() => {
console.log("DEMO_BYPASS_PRESSED");
enableDemoMode();
}}
>
<Text style={styles.primaryButtonText}>Continuar sem Spotify (demo)</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={{ padding: 10, alignItems: 'center', marginBottom: 10 }} onPress={handleResetAuth}>
<Text style={{ color: 'red', fontWeight: '600' }}>Reset Auth</Text>
</TouchableOpacity>
<View style={styles.footerContainer}> <View style={styles.footerContainer}>
<Text style={styles.footerText}>Não tens conta? </Text> <Text style={styles.footerText}>Não tens conta? </Text>
<TouchableOpacity onPress={() => navigation.navigate('Register')}> <TouchableOpacity onPress={() => navigation.navigate('Register')}>
@@ -432,9 +408,7 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
scrollContent: { scrollContent: {
flexGrow: 1, paddingTop: 48,
justifyContent: 'space-between',
paddingTop: 60,
}, },
headerContainer: { headerContainer: {
alignItems: 'center', alignItems: 'center',
@@ -484,12 +458,8 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
borderTopLeftRadius: 32, borderTopLeftRadius: 32,
borderTopRightRadius: 32, borderTopRightRadius: 32,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
padding: 24, padding: 24,
paddingBottom: Platform.OS === 'ios' ? 40 : 24, paddingBottom: Platform.OS === 'ios' ? 40 : 32,
flexGrow: 1,
justifyContent: 'center',
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: -4 }, shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1, shadowOpacity: 0.1,

View File

@@ -176,7 +176,15 @@ 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 => <TripCard key={trip.id} trip={trip} />) trips.map(trip => (
<TouchableOpacity
key={trip.id}
activeOpacity={0.9}
onPress={() => navigation.navigate('TripDetails', { trip })}
>
<TripCard trip={trip} />
</TouchableOpacity>
))
) : ( ) : (
<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>

View File

@@ -6,6 +6,141 @@ import { colors } from '../../utils/colors';
import { supabase } from '../../services/supabase'; import { supabase } from '../../services/supabase';
import { getSpotifyAccessToken, refreshSpotifyToken, clearSpotifyTokens } from '../../auth/spotifyToken'; import { getSpotifyAccessToken, refreshSpotifyToken, clearSpotifyTokens } from '../../auth/spotifyToken';
import { OLLAMA_API_URL } from '../../services/ollama'; 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 // @ts-ignore
export default function NewTripScreen({ navigation }) { export default function NewTripScreen({ navigation }) {
@@ -48,6 +183,8 @@ export default function NewTripScreen({ navigation }) {
let playlistCreationFailed = false; let playlistCreationFailed = false;
let playlistFailureReason = ''; let playlistFailureReason = '';
let playlistSuccessMessage = 'Viagem e playlist criadas com sucesso.'; let playlistSuccessMessage = 'Viagem e playlist criadas com sucesso.';
let hasGenre = false;
let accumulatedDurationMs = 0;
try { try {
console.log("GENERATING_PLAYLIST_FOR_TRIP:", tripName); console.log("GENERATING_PLAYLIST_FOR_TRIP:", tripName);
@@ -124,210 +261,475 @@ export default function NewTripScreen({ navigation }) {
console.log("SPOTIFY_USER_ID_EXISTS:", !!spotifyUserId); console.log("SPOTIFY_USER_ID_EXISTS:", !!spotifyUserId);
if (!spotifyUserId) throw new Error('Could not fetch Spotify User ID from /v1/me'); if (!spotifyUserId) throw new Error('Could not fetch Spotify User ID from /v1/me');
// C. Call Ollama server // C. Build varied music queries using AI + favorite genre
const ollamaPrompt = `I am taking a roadtrip from ${origin} to ${destination}. The trip is called "${tripName}" and takes about ${duration}. Reply ONLY with a JSON array of up to 10 Spotify search queries (e.g. genres, moods, or themes) that fit this journey. Example: ["portuguese pop", "italian road trip", "european indie", "summer travel songs"]. No other text.`; 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`, { const ollamaRes = await fetch(`${OLLAMA_API_URL}/api/chat`, {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: "qwen3-coder:30b", model: 'qwen3-coder:30b',
messages: [{ "role": "user", "content": ollamaPrompt }], messages: [{ role: 'user', content: ollamaPrompt }],
stream: false 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' },
}); });
let searchQueries: string[] = ["pop hits", "road trip songs", "top hits Portugal", "summer hits", "travel songs"]; // Fallback searchRequestsCount++;
try {
const ollamaData = await safeParseJson(ollamaRes, 'Ollama');
let rawAiText = ollamaData?.message?.content || "";
// Clean AI text if (!searchRes.ok) {
rawAiText = rawAiText.replace(/```json/g, '').replace(/```/g, '').trim(); const errText = await searchRes.text();
console.warn('[Playlist] Spotify search failed:', searchRes.status, errText.substring(0, 150));
if (rawAiText.length > 0 && rawAiText.startsWith("[")) { break;
const parsed = JSON.parse(rawAiText);
if (Array.isArray(parsed) && parsed.length > 0) {
const aiQueries = parsed.map(String).slice(0, 10);
searchQueries = [...aiQueries, ...searchQueries];
} else {
console.log("Ollama returned empty array, using fallbacks");
}
} else {
console.log("AI returned plain text/error, using fallback queries:", rawAiText);
}
} catch (aiError) {
console.log("AI parsing failed, using fallback queries.", aiError);
} }
// D. Create empty playlist 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 createPlaylistUrl = 'https://api.spotify.com/v1/me/playlists';
const createPlaylistBody = JSON.stringify({ const createPlaylistBody = JSON.stringify({
name: tripName, name: tripName,
description: `Roadtrip from ${origin} to ${destination}. Themes: ${searchQueries.join(', ')}`, description: `Roadtrip from ${origin} to ${destination}${cleanGenre ? ` · ${cleanGenre} vibes` : ''}.`,
public: false public: false,
}); });
console.log("CREATE_PLAYLIST_URL:", createPlaylistUrl);
console.log("CREATE_PLAYLIST_BODY:", createPlaylistBody); console.log('CREATE_PLAYLIST_URL:', createPlaylistUrl);
const createPlaylistRes = await fetch(createPlaylistUrl, { const createPlaylistRes = await fetch(createPlaylistUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${providerToken}`, Authorization: `Bearer ${providerToken}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json',
}, },
body: createPlaylistBody body: createPlaylistBody,
}); });
console.log("CREATE_PLAYLIST_HTTP_STATUS:", createPlaylistRes.status); console.log('CREATE_PLAYLIST_HTTP_STATUS:', createPlaylistRes.status);
const createPlaylistResText = await createPlaylistRes.text(); const createPlaylistResText = await createPlaylistRes.text();
if (!createPlaylistRes.ok) { if (!createPlaylistRes.ok) {
console.log("CREATE_PLAYLIST_RESPONSE_BODY_IF_FAILED:", createPlaylistResText.substring(0, 300)); console.log('CREATE_PLAYLIST_RESPONSE_BODY_IF_FAILED:', createPlaylistResText.substring(0, 300));
if (createPlaylistRes.status === 403) { if (createPlaylistRes.status === 403) {
// Stored refresh token predates playlist scopes — clear tokens so next login forces full re-auth
await clearSpotifyTokens(); await clearSpotifyTokens();
console.warn("CREATE_PLAYLIST_403: Cleared stale Spotify tokens. User must reconnect."); console.warn('CREATE_PLAYLIST_403: Cleared stale Spotify tokens. User must reconnect.');
Alert.alert( Alert.alert('Permissão Spotify Necessária', 'Reconecta o Spotify para dar permissão de criar playlists.');
'Permissão Spotify Necessária',
'Reconnect Spotify to grant playlist permissions. Go to Profile and log in with Spotify again.'
);
playlistCreationFailed = true; playlistCreationFailed = true;
playlistFailureReason = 'scope'; playlistFailureReason = 'scope';
// Skip further playlist work — trip will still be saved throw new Error('CREATE_PLAYLIST_SCOPE_ERROR: Tokens cleared, re-auth required.');
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)}`);
throw new Error(
`Spotify API returned status ${createPlaylistRes.status} for CreatePlaylist: ${createPlaylistResText.substring(0, 150)}`
);
} }
let playlistData: any; let playlistData: any;
try { try {
playlistData = JSON.parse(createPlaylistResText); playlistData = JSON.parse(createPlaylistResText);
} catch (e) { } catch {
throw new Error(`Failed to parse JSON response for CreatePlaylist: ${createPlaylistResText.substring(0, 150)}`); throw new Error(`Failed to parse JSON for CreatePlaylist: ${createPlaylistResText.substring(0, 150)}`);
} }
if (!playlistData.id) throw new Error('Could not create playlist'); if (!playlistData.id) throw new Error('Could not create playlist');
const playlistId = playlistData.id; const playlistId = playlistData.id;
generatedPlaylistUrl = playlistData.external_urls.spotify; generatedPlaylistUrl = playlistData.external_urls.spotify;
// E. Fill playlist with tracks based on duration console.log('TARGET_PLAYLIST_DURATION_MS:', tripDurationMs);
console.log("TARGET_PLAYLIST_DURATION_MS:", tripDurationMs);
let accumulatedDurationMs = 0;
let selectedTracks: { id: string; uri: string; duration_ms: number }[] = [];
let searchRequestsCount = 0;
const MAX_SEARCH_REQUESTS = 40;
const MAX_TRACKS = 400;
let queryIndex = 0;
let offset = 0;
let noMoreTracks = false;
while ( // ─── PHASE 1: Collect style tracks (80% of target, genre-validated) ─────────
accumulatedDurationMs < tripDurationMs && const selectedTracks: SelectedSpotifyTrack[] = [];
searchRequestsCount < MAX_SEARCH_REQUESTS &&
selectedTracks.length < MAX_TRACKS &&
!noMoreTracks
) {
const currentQuery = searchQueries[queryIndex % searchQueries.length];
const queryEncoded = encodeURIComponent(currentQuery);
const searchUrl = `https://api.spotify.com/v1/search?type=track&q=${queryEncoded}&limit=10&market=${spotifyUserCountry}&offset=${offset}`;
console.log("TRACK_SEARCH_QUERY:", currentQuery, "offset:", offset); const allStyleQueries = [...styleQueries, ...aiArtistQueries];
console.log("TRACK_SEARCH_URL:", searchUrl); console.log('[Playlist] Phase 1 style queries:', allStyleQueries);
const searchRes = await fetch(searchUrl, { for (const query of allStyleQueries) {
headers: { if (selectedTracks.length >= styleTargetCount) break;
'Authorization': `Bearer ${providerToken}`,
'Content-Type': 'application/json'
}
});
console.log("TRACK_SEARCH_STATUS:", searchRes.status);
if (!searchRes.ok) {
const errText = await searchRes.text();
console.warn("Spotify search failed:", searchRes.status, errText.substring(0, 150));
console.log("TRACK_SEARCH_RESPONSE_BODY_IF_FAILED:", errText.substring(0, 300));
queryIndex++;
offset = 0;
searchRequestsCount++;
continue;
}
const searchData = await safeParseJson(searchRes, 'SearchTracks');
const tracks = searchData?.tracks?.items || [];
console.log("TRACKS_RAW_FOUND_COUNT:", tracks.length);
if (tracks.length === 0) {
queryIndex++;
offset = 0;
if (queryIndex >= searchQueries.length * 3) {
noMoreTracks = true;
}
searchRequestsCount++;
continue;
}
let tracksAfterFilter = 0;
for (const track of tracks) {
if (selectedTracks.length >= MAX_TRACKS) break;
if (accumulatedDurationMs >= tripDurationMs) break; if (accumulatedDurationMs >= tripDurationMs) break;
if (searchRequestsCount >= 60) break;
if (track.id && track.uri && track.duration_ms && track.is_local !== true) { const tracks = await searchAndFilter(query, true);
if (track.is_playable === undefined || track.is_playable !== false) { for (const t of tracks) {
if (!selectedTracks.some(t => t.id === track.id)) { if (selectedTracks.length >= styleTargetCount) break;
selectedTracks.push({ id: track.id, uri: track.uri, duration_ms: track.duration_ms }); if (accumulatedDurationMs >= tripDurationMs) break;
accumulatedDurationMs += track.duration_ms; if (selectedTrackIds.has(t.id)) continue;
tracksAfterFilter++;
} 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("TRACKS_AFTER_FILTER_COUNT:", tracksAfterFilter); console.log('[Playlist] After Phase 1 styleTracks count:', selectedTracks.length, '| duration (ms):', accumulatedDurationMs);
offset += 10; // ─── PHASE 2: Fill remaining duration with roadtrip filler (no genre check) ─
if (offset >= 1000) { if (!hasGenre && accumulatedDurationMs < tripDurationMs) {
queryIndex++; const fillerQueries = [...aiArtistQueries, ...genericFillerQueries];
offset = 0; 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;
} }
searchRequestsCount++;
} }
console.log("TRACKS_SELECTED_COUNT:", selectedTracks.length); console.log('[Playlist] After Phase 2 total tracks:', selectedTracks.length, '| duration (ms):', accumulatedDurationMs);
console.log("SELECTED_TRACKS_TOTAL_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) { if (selectedTracks.length > 0) {
// F. Add tracks to playlist in chunks // F. Add tracks to playlist in chunks
const trackUris = selectedTracks.map(t => t.uri); const trackUris = selectedTracks.map((track) => track.uri);
const chunkSize = 100; const chunkSize = 100;
let tracksAddedSuccessfully = true; let tracksAddedSuccessfully = true;
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);
const addTracksRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/items`, { const addTracksRes = await fetch(
method: 'POST', `https://api.spotify.com/v1/playlists/${playlistId}/items`,
{
method: "POST",
headers: { headers: {
'Authorization': `Bearer ${providerToken}`, Authorization: `Bearer ${providerToken}`,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ uris: chunk }) body: JSON.stringify({ uris: chunk }),
}); }
);
console.log("ADD_TRACKS_HTTP_STATUS:", addTracksRes.status); console.log("ADD_TRACKS_HTTP_STATUS:", addTracksRes.status);
if (!addTracksRes.ok) { if (!addTracksRes.ok) {
const addTracksErr = await addTracksRes.text(); const addTracksErr = await addTracksRes.text();
console.log("ADD_TRACKS_RESPONSE_BODY_IF_FAILED:", addTracksErr.substring(0, 300));
console.log(
"ADD_TRACKS_RESPONSE_BODY_IF_FAILED:",
addTracksErr.substring(0, 300)
);
tracksAddedSuccessfully = false; tracksAddedSuccessfully = false;
throw new Error(`Spotify API returned status ${addTracksRes.status} while adding tracks: ${addTracksErr.substring(0, 150)}`);
throw new Error(
`Spotify API returned status ${addTracksRes.status
} while adding tracks: ${addTracksErr.substring(0, 150)}`
);
} }
} }
if (tracksAddedSuccessfully) { if (tracksAddedSuccessfully) {
console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl); console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl);
playlistCreationFailed = false; playlistCreationFailed = false;
if (accumulatedDurationMs < tripDurationMs - 60000 && (selectedTracks.length >= MAX_TRACKS || searchRequestsCount >= MAX_SEARCH_REQUESTS || noMoreTracks)) { 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; const hours = Math.round((accumulatedDurationMs / 3600000) * 10) / 10;
if (hours >= 1) { if (hours >= 1) {
playlistSuccessMessage = `Viagem guardada! Playlist criada com ${hours} horas de música.`; playlistSuccessMessage = `Viagem guardada! Playlist criada com ${hours} horas de música.`;
} else { } else {
@@ -336,10 +738,12 @@ export default function NewTripScreen({ navigation }) {
} }
} }
} }
}
} else { } else {
console.warn("No tracks found for queries:", searchQueries); console.warn("No tracks found for queries:", searchQueries);
playlistCreationFailed = true; playlistCreationFailed = true;
playlistFailureReason = 'notracks'; playlistFailureReason = "notracks";
} }
} }
} catch (playlistError: any) { } catch (playlistError: any) {
@@ -371,12 +775,20 @@ export default function NewTripScreen({ navigation }) {
} else { } else {
if (playlistCreationFailed) { if (playlistCreationFailed) {
if (playlistFailureReason === 'notracks') { 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.'); Alert.alert('Sucesso!', 'Playlist criada, mas não foram encontradas músicas para adicionar.');
}
} else { } else {
Alert.alert('Sucesso!', 'Viagem guardada! A playlist do Spotify não pôde ser criada, mas a viagem foi guardada.'); Alert.alert('Sucesso!', 'Viagem guardada! A playlist do Spotify não pôde ser criada, mas a viagem foi guardada.');
} }
} else if (generatedPlaylistUrl) { } else if (generatedPlaylistUrl) {
if (hasGenre && accumulatedDurationMs < tripDurationMs - 300000) {
Alert.alert('Aviso', playlistSuccessMessage);
} else {
Alert.alert('Sucesso!', playlistSuccessMessage); Alert.alert('Sucesso!', playlistSuccessMessage);
}
} else { } else {
Alert.alert('Sucesso!', 'Viagem calculada e guardada!'); Alert.alert('Sucesso!', 'Viagem calculada e guardada!');
} }

View File

@@ -1,361 +1,480 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ImageBackground, SafeAreaView, FlatList } from 'react-native'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ImageBackground, SafeAreaView, Linking, Alert } from 'react-native';
import { ArrowLeft, Share2, MoreVertical, Navigation, Clock, Music, Compass, MapPin, Play } from 'lucide-react-native'; import { ArrowLeft, MapPin, Navigation, Music, Play } from 'lucide-react-native';
import { colors } from '../../utils/colors'; import { colors } from '../../utils/colors';
import TimelineItem from '../../components/TimelineItem'; import TimelineItem from '../../components/TimelineItem';
import TrackItem from '../../components/TrackItem'; import { getDestinationLandmarkImage } from '../../services/destinationImage';
const MOCK_TRACKS = [
{ id: '1', title: 'Born to Run', artist: 'Bruce Springsteen', duration: '4:30' },
{ id: '2', title: 'Hotel California', artist: 'Eagles', duration: '6:30' },
{ id: '3', title: 'Holocene', artist: 'Bon Iver', duration: '5:37' },
{ id: '4', title: 'Ganges', artist: 'The National', duration: '4:12' },
];
// @ts-ignore // @ts-ignore
export default function TripDetailsScreen({ navigation }) { export default function TripDetailsScreen({ navigation, route }) {
const [activeTab, setActiveTab] = useState<'rota' | 'playlist'>('rota'); const { trip } = route.params || {};
const renderRotaTab = () => ( const [imageUrl, setImageUrl] = useState<string | null>(trip?.destination_image_url || null);
<View style={styles.tabContent}> const [landmarkName, setLandmarkName] = useState<string | null>(trip?.destination_landmark_name || null);
{/* Stats Cards */}
<View style={styles.statsContainer}>
<View style={styles.statCard}>
{/* @ts-ignore */}
<Navigation color={colors.primary} size={24} style={{ marginBottom: 8 }} />
<Text style={styles.statValue}>314 km</Text>
<Text style={styles.statLabel}>Distância</Text>
</View>
<View style={styles.dividerVertical} />
<View style={styles.statCard}>
<Clock color={colors.primary} size={24} style={{ marginBottom: 8 }} />
<Text style={styles.statValue}>3h 15m</Text>
<Text style={styles.statLabel}>Tempo</Text>
</View>
</View>
{/* DJ Guide Card */} useEffect(() => {
<View style={styles.djCard}> if (!trip?.destination || imageUrl) return;
<View style={styles.djHeader}>
<View style={styles.djIconContainer}>
<Music color={colors.white} size={16} />
</View>
<Text style={styles.djTitle}>O Teu DJ Guide:</Text>
</View>
<Text style={styles.djText}>
"Na primeira hora da viagem, ouve Rock clássico para acordar. Quando passares pela zona de Leiria, ouve Indie Folk. Ah, e faz uma paragem na área de serviço de Pombal porque o café lá tem ótimas reviews."
</Text>
</View>
{/* Itinerary */} let isMounted = true;
<View style={styles.itinerarySection}> const tripTitle = trip.title || trip.destination || 'Viagem';
<View style={styles.sectionHeader}>
<Compass color={colors.textMain} size={20} style={{ marginRight: 8 }} />
<Text style={styles.sectionTitle}>Itinerário</Text>
</View>
<View style={styles.timelineContainer}> getDestinationLandmarkImage(trip.destination, { tripTitle }).then(result => {
<TimelineItem if (!isMounted) return;
title="Lisbon, Portugal" if (result.imageUrl) {
subtitle="Partida • 0 km" setImageUrl(result.imageUrl);
type="start" }
/> if (result.landmarkName) {
<TimelineItem setLandmarkName(result.landmarkName);
title="Área de Serviço de Pombal" }
subtitle="Paragem sugerida para café. + 145 km" });
type="stop"
/>
<TimelineItem
title="Porto, Portugal"
subtitle="Destino • 314 km"
type="end"
isLast
/>
</View>
</View>
{/* Maps Action */} return () => {
<TouchableOpacity style={styles.mapsButton}> isMounted = false;
<MapPin color={colors.white} size={20} style={{ marginRight: 8 }} /> };
<Text style={styles.mapsButtonText}>Abrir no Google Maps</Text> }, [trip?.destination, imageUrl]);
</TouchableOpacity>
</View>
);
const renderPlaylistTab = () => ( const handleOpenGoogleMaps = () => {
<View style={styles.tabContent}> if (!trip?.origin || !trip?.destination) {
{/* Generated Playlist Card */} Alert.alert('Erro', 'Dados de rota incompletos.');
<View style={styles.playlistGeneratedCard}> return;
<View style={styles.spotifyLogoLarge}> }
{/* Mocking large spotify logo with music icon for now */} const url = `https://www.google.com/maps/dir/?api=1&origin=${encodeURIComponent(trip.origin)}&destination=${encodeURIComponent(trip.destination)}`;
<Music color={colors.spotify} size={40} /> Linking.openURL(url);
</View> };
<Text style={styles.playlistTitle}>Playlist Gerada</Text>
<Text style={styles.playlistSubtitle}>45 músicas 3h 20m de viagem</Text>
<TouchableOpacity style={styles.spotifyActionBtn}> const stops = trip?.stops || trip?.waypoints || [];
<Play fill={colors.white} color={colors.white} size={18} style={{ marginRight: 8 }} /> const hasStops = Array.isArray(stops) && stops.length > 0;
<Text style={styles.spotifyActionText}>Ouvir no Spotify</Text>
</TouchableOpacity>
</View>
{/* Preview List */} const routeVisual = (
<Text style={styles.previewTitle}>Pré-visualização (Músicas Iniciais)</Text> <View style={styles.mockRouteVisual}>
<View style={styles.previewListCard}> <View style={styles.routeDotLarge} />
{MOCK_TRACKS.map((track, index) => ( <View style={styles.routeLineDashed} />
<TrackItem <View style={styles.routePinLarge}>
key={track.id} <MapPin color={colors.white} size={14} />
index={index + 1}
title={track.title}
artist={track.artist}
duration={track.duration}
/>
))}
</View> </View>
</View> </View>
); );
return ( return (
<View style={styles.container}> <SafeAreaView style={styles.safeArea}>
<ScrollView> {/* Header */}
{/* Header Image */} <View style={styles.header}>
<ImageBackground <Text style={styles.title} numberOfLines={1}>
source={{ uri: 'https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?q=80&w=2021&auto=format&fit=crop' }} {trip?.title || trip?.destination || 'Detalhes da Viagem'}
style={styles.headerImage} </Text>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
> >
<SafeAreaView style={styles.headerSafeArea}> <ArrowLeft color={colors.textMain} size={20} />
<View style={styles.headerNav}>
<TouchableOpacity style={styles.navIconButton} onPress={() => navigation.goBack()}>
<ArrowLeft color={colors.white} size={24} />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.navRightActions}>
<TouchableOpacity style={styles.navIconButton}>
<Share2 color={colors.white} size={20} />
</TouchableOpacity>
<TouchableOpacity style={[styles.navIconButton, { marginLeft: 12 }]}>
<MoreVertical color={colors.white} size={20} />
</TouchableOpacity>
</View>
</View> </View>
<View style={styles.headerTitles}> <ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.tag}> {/* Top visual representation */}
<Text style={styles.tagText}>ROADTRIP</Text> {imageUrl ? (
<ImageBackground source={{ uri: imageUrl }} style={styles.mapArea}>
<View style={styles.imageOverlay}>
{landmarkName && (
<View style={styles.landmarkTag}>
<Text style={styles.landmarkTagText} numberOfLines={1}>
{landmarkName}
</Text>
</View> </View>
<Text style={styles.mainTitle}>Lisbon to Porto Coastline</Text> )}
<Text style={styles.subTitle}>May 10, 2026</Text> {routeVisual}
</View> </View>
</SafeAreaView>
</ImageBackground> </ImageBackground>
) : (
<View style={styles.mapArea}>
{routeVisual}
</View>
)}
{/* Content Area overlapping image slightly */} {/* Content Card overlapping map/image */}
<View style={styles.contentWrapper}> <View style={styles.detailsCard}>
{/* Trip Name info */}
{/* Custom Tabs */} <View style={styles.inputGroup}>
<View style={styles.tabContainer}> <Text style={styles.inputLabel}>NOME DA VIAGEM</Text>
<TouchableOpacity <View style={styles.textInputReadonly}>
style={[styles.tabButton, activeTab === 'rota' && styles.tabButtonActive]} <Text style={styles.textInputReadonlyText}>
onPress={() => setActiveTab('rota')} {trip?.title || trip?.destination || 'Sem título'}
>
<Text style={[styles.tabText, activeTab === 'rota' && styles.tabTextActive]}>
Rota & DJ
</Text> </Text>
</TouchableOpacity> </View>
<TouchableOpacity
style={[styles.tabButton, activeTab === 'playlist' && styles.tabButtonActive]}
onPress={() => setActiveTab('playlist')}
>
<Text style={[styles.tabText, activeTab === 'playlist' && styles.tabTextActive]}>
Playlist Spotify
</Text>
</TouchableOpacity>
</View> </View>
{/* Render Tab Content */} {/* Origin & Destination timeline */}
{activeTab === 'rota' ? renderRotaTab() : renderPlaylistTab()} <View style={styles.routeInputContainer}>
<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>
<View style={styles.textInputReadonly}>
<Text style={[styles.textInputReadonlyText, styles.boldText]}>
{trip?.origin}
</Text>
</View>
</View>
<View style={[styles.inputGroup, { marginBottom: 0 }]}>
<Text style={styles.inputLabel}>DESTINO</Text>
<View style={styles.textInputReadonly}>
<Text style={[styles.textInputReadonlyText, styles.boldText]}>
{trip?.destination}
</Text>
</View>
</View>
</View>
</View>
{/* Distance and Duration summary */}
<View style={styles.resultsContainer}>
<View style={styles.resultItem}>
<Text style={styles.resultLabel}>Distância</Text>
<Text style={styles.resultValue}>{trip?.distance || '-'}</Text>
</View>
<View style={styles.resultDivider} />
<View style={styles.resultItem}>
<Text style={styles.resultLabel}>Duração</Text>
<Text style={styles.resultValue}>{trip?.duration || '-'}</Text>
</View>
</View>
{/* Spotify Playlist Segment */}
{trip?.playlist_url ? (
<View style={styles.playlistSection}>
<Text style={styles.sectionLabel}>PLAYLIST DO SPOTIFY</Text>
<View style={styles.spotifyCard}>
<View style={styles.spotifyHeader}>
<View style={styles.spotifyLogoContainer}>
<Music color={colors.spotify} size={24} />
</View>
<View style={styles.spotifyHeaderTextContainer}>
<Text style={styles.spotifyCardTitle} numberOfLines={1}>
{trip?.title || 'Playlist da Viagem'}
</Text>
<Text style={styles.spotifyCardSubtitle} numberOfLines={1}>
{trip?.playlist_url}
</Text>
</View>
</View>
<TouchableOpacity
style={styles.spotifyButton}
onPress={() => Linking.openURL(trip.playlist_url)}
>
<Play fill={colors.white} color={colors.white} size={16} style={{ marginRight: 8 }} />
<Text style={styles.spotifyButtonText}>Ouvir no Spotify</Text>
</TouchableOpacity>
</View>
</View>
) : null}
{/* Route Stops / Waypoints */}
<View style={styles.stopsSection}>
<Text style={styles.sectionLabel}>PARAGENS DA ROTA</Text>
{hasStops ? (
<View style={styles.timelineContainer}>
{stops.map((stop: string, index: number) => (
<TimelineItem
key={index}
title={stop}
subtitle={`Paragem #${index + 1}`}
type="stop"
isLast={index === stops.length - 1}
/>
))}
</View>
) : (
<View style={styles.noStopsCard}>
<Text style={styles.noStopsText}>
Ainda não existem paragens guardadas para esta viagem.
</Text>
</View>
)}
</View>
{/* Bottom Actions */}
<View style={styles.bottomActions}>
<TouchableOpacity
style={styles.mapsButton}
onPress={handleOpenGoogleMaps}
>
<Navigation color={colors.primary} size={20} />
<Text style={styles.mapsButtonText}>Abrir no Google Maps</Text>
</TouchableOpacity>
</View>
</View> </View>
</ScrollView> </ScrollView>
</View> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { safeArea: {
flex: 1, flex: 1,
backgroundColor: colors.background, backgroundColor: colors.white,
}, },
headerImage: { scrollContent: {
width: '100%', flexGrow: 1,
height: 320,
justifyContent: 'flex-start',
}, },
headerSafeArea: { header: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.3)', // Overlay
justifyContent: 'space-between',
},
headerNav: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: 16, paddingTop: 16,
}, paddingBottom: 16,
navIconButton: { backgroundColor: colors.white,
width: 40, zIndex: 10,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.2)',
justifyContent: 'center',
alignItems: 'center',
},
navRightActions: {
flexDirection: 'row',
},
headerTitles: {
paddingHorizontal: 20,
paddingBottom: 40, // Room for overlap
},
tag: {
backgroundColor: colors.primary,
alignSelf: 'flex-start',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 8,
marginBottom: 8,
},
tagText: {
color: colors.white,
fontSize: 12,
fontWeight: 'bold',
letterSpacing: 1,
},
mainTitle: {
fontSize: 32,
fontWeight: '800',
color: colors.white,
marginBottom: 4,
lineHeight: 38,
},
subTitle: {
fontSize: 16,
color: colors.white,
fontWeight: '500',
},
contentWrapper: {
flex: 1,
backgroundColor: colors.background,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
marginTop: -24,
minHeight: 500,
},
tabContainer: {
flexDirection: 'row',
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: colors.inputBorder, borderBottomColor: colors.inputBorder,
paddingHorizontal: 20,
marginTop: 10,
}, },
tabButton: { title: {
flex: 1,
paddingVertical: 16,
alignItems: 'center',
borderBottomWidth: 2,
borderBottomColor: 'transparent',
},
tabButtonActive: {
borderBottomColor: colors.primary,
},
tabText: {
fontSize: 15,
fontWeight: 'bold',
color: colors.textSecondary,
},
tabTextActive: {
color: colors.primary,
},
tabContent: {
padding: 20,
},
statsContainer: {
flexDirection: 'row',
backgroundColor: colors.white,
borderRadius: 20,
padding: 20,
marginBottom: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 3,
},
statCard: {
flex: 1,
alignItems: 'center',
},
dividerVertical: {
width: 1,
backgroundColor: colors.inputBorder,
marginHorizontal: 10,
},
statValue: {
fontSize: 20, fontSize: 20,
fontWeight: 'bold', fontWeight: 'bold',
color: colors.textMain, color: colors.textMain,
marginBottom: 4, flex: 1,
marginRight: 16,
}, },
statLabel: { backButton: {
fontSize: 13, 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',
},
imageOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.3)',
justifyContent: 'center',
alignItems: 'center',
},
landmarkTag: {
position: 'absolute',
top: 12,
right: 12,
backgroundColor: 'rgba(0, 0, 0, 0.65)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
maxWidth: '80%',
},
landmarkTagText: {
color: colors.white,
fontSize: 12,
fontWeight: 'bold',
},
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,
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,
},
detailsCard: {
backgroundColor: colors.white,
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
padding: 24,
marginTop: -32, // Overlap map/image
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, color: colors.textSecondary,
marginBottom: 8,
letterSpacing: 0.5,
},
textInputReadonly: {
backgroundColor: colors.inputBackground,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 16,
},
textInputReadonlyText: {
fontSize: 16,
color: colors.textMain,
fontWeight: '500', fontWeight: '500',
}, },
djCard: { boldText: {
backgroundColor: '#FFF5EB', fontWeight: 'bold',
},
routeInputContainer: {
flexDirection: 'row',
marginTop: 10,
},
routeTimeline: {
alignItems: 'center',
width: 30,
marginTop: 38,
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,
},
resultsContainer: {
flexDirection: 'row',
marginTop: 24,
paddingTop: 24,
borderTopWidth: 1,
borderTopColor: colors.inputBorder,
marginBottom: 24,
},
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',
},
sectionLabel: {
fontSize: 12,
fontWeight: 'bold',
color: colors.textSecondary,
marginBottom: 12,
letterSpacing: 0.5,
},
playlistSection: {
marginBottom: 28,
},
spotifyCard: {
backgroundColor: '#E8F5E9', // Light green matches spotify style
borderRadius: 20, borderRadius: 20,
padding: 20, padding: 20,
marginBottom: 30,
borderWidth: 1, borderWidth: 1,
borderColor: '#FFE0C2', borderColor: '#C8E6C9',
}, },
djHeader: { spotifyHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 16, marginBottom: 16,
}, },
djIconContainer: { spotifyLogoContainer: {
width: 28, width: 44,
height: 28, height: 44,
borderRadius: 14, borderRadius: 22,
backgroundColor: colors.primary, backgroundColor: '#000000',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginRight: 10, marginRight: 12,
}, },
djTitle: { spotifyHeaderTextContainer: {
fontSize: 18, flex: 1,
fontWeight: 'bold',
color: '#8C3800',
}, },
djText: { spotifyCardTitle: {
fontSize: 15, fontSize: 16,
lineHeight: 24,
color: '#A34200',
fontWeight: '500',
},
itinerarySection: {
marginBottom: 30,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 20,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold', fontWeight: 'bold',
color: colors.textMain, color: colors.textMain,
marginBottom: 2,
},
spotifyCardSubtitle: {
fontSize: 13,
color: colors.textSecondary,
},
spotifyButton: {
backgroundColor: colors.spotify,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 12,
shadowColor: colors.spotify,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
spotifyButtonText: {
color: colors.white,
fontSize: 14,
fontWeight: 'bold',
},
stopsSection: {
marginBottom: 28,
}, },
timelineContainer: { timelineContainer: {
backgroundColor: colors.white, backgroundColor: colors.white,
@@ -366,87 +485,40 @@ const styles = StyleSheet.create({
shadowOpacity: 0.05, shadowOpacity: 0.05,
shadowRadius: 8, shadowRadius: 8,
elevation: 3, elevation: 3,
borderWidth: 1,
borderColor: colors.inputBorder,
},
noStopsCard: {
backgroundColor: '#FFF5EB',
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: '#FFE0C2',
alignItems: 'center',
},
noStopsText: {
color: '#A34200',
fontSize: 14,
fontWeight: '500',
textAlign: 'center',
lineHeight: 20,
},
bottomActions: {
marginTop: 8,
marginBottom: 16,
}, },
mapsButton: { mapsButton: {
backgroundColor: '#111827', // Almost black backgroundColor: colors.inputBackground,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center',
paddingVertical: 18, paddingVertical: 18,
borderRadius: 30, // Highly rounded borderRadius: 16,
marginBottom: 40,
}, },
mapsButtonText: { mapsButtonText: {
color: colors.white, color: colors.primary,
fontSize: 16, fontSize: 16,
fontWeight: 'bold', fontWeight: 'bold',
}, marginLeft: 8,
// Playlist Tab Styles
playlistGeneratedCard: {
backgroundColor: '#E8F5E9', // Light green
borderRadius: 24,
padding: 30,
alignItems: 'center',
marginBottom: 30,
borderWidth: 1,
borderColor: '#C8E6C9',
},
spotifyLogoLarge: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#000',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
playlistTitle: {
fontSize: 24,
fontWeight: 'bold',
color: colors.textMain,
marginBottom: 8,
},
playlistSubtitle: {
fontSize: 15,
color: colors.textSecondary,
marginBottom: 24,
},
spotifyActionBtn: {
backgroundColor: colors.spotify,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 30,
width: '100%',
justifyContent: 'center',
shadowColor: colors.spotify,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 5,
},
spotifyActionText: {
color: colors.white,
fontSize: 16,
fontWeight: 'bold',
},
previewTitle: {
fontSize: 18,
fontWeight: 'bold',
color: colors.textMain,
marginBottom: 16,
},
previewListCard: {
backgroundColor: colors.white,
borderRadius: 20,
padding: 20,
marginBottom: 40,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 3,
}, },
}); });