Compare commits
4 Commits
134789cee1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34dd03b1f6 | ||
| b502252811 | |||
| ad8042adaa | |||
| d85e327c07 |
21
scratch_db_test.js
Normal file
21
scratch_db_test.js
Normal 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();
|
||||
@@ -6,9 +6,7 @@ interface AuthContextType {
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
loading: boolean;
|
||||
isDemoMode: boolean;
|
||||
isSpotifyAuthenticated: boolean;
|
||||
enableDemoMode: () => void;
|
||||
enableSpotifyMode: () => void;
|
||||
}
|
||||
|
||||
@@ -16,9 +14,7 @@ const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
session: null,
|
||||
loading: true,
|
||||
isDemoMode: false,
|
||||
isSpotifyAuthenticated: false,
|
||||
enableDemoMode: () => {},
|
||||
enableSpotifyMode: () => {},
|
||||
});
|
||||
|
||||
@@ -28,18 +24,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isDemoMode, setIsDemoMode] = 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 = () => {
|
||||
setIsDemoMode(false);
|
||||
setIsSpotifyAuthenticated(true);
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -49,12 +36,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
if (!session) {
|
||||
setIsDemoMode(false);
|
||||
setIsSpotifyAuthenticated(false);
|
||||
} else {
|
||||
const isSpotify = !!session.user?.user_metadata?.spotify_id;
|
||||
setIsSpotifyAuthenticated(isSpotify);
|
||||
setIsDemoMode(false);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
@@ -63,12 +48,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
if (!session) {
|
||||
setIsDemoMode(false);
|
||||
setIsSpotifyAuthenticated(false);
|
||||
} else {
|
||||
const isSpotify = !!session.user?.user_metadata?.spotify_id;
|
||||
setIsSpotifyAuthenticated(isSpotify);
|
||||
setIsDemoMode(false);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
@@ -77,7 +60,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, session, loading, isDemoMode, isSpotifyAuthenticated, enableDemoMode, enableSpotifyMode }}>
|
||||
<AuthContext.Provider value={{ user, session, loading, isSpotifyAuthenticated, enableSpotifyMode }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ const discovery: DiscoveryDocument = {
|
||||
|
||||
// @ts-ignore
|
||||
export default function LoginScreen({ navigation }) {
|
||||
const { enableDemoMode, enableSpotifyMode } = useAuth();
|
||||
const { enableSpotifyMode } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -305,14 +305,6 @@ export default function LoginScreen({ navigation }) {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleResetAuth = async () => {
|
||||
await supabase.auth.signOut();
|
||||
await clearSpotifyTokens();
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
Alert.alert('Reset', 'Auth state cleared.');
|
||||
};
|
||||
|
||||
const handleSpotifyLogin = async () => {
|
||||
try {
|
||||
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>
|
||||
</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}>
|
||||
<Text style={styles.footerText}>Não tens conta? </Text>
|
||||
<TouchableOpacity onPress={() => navigation.navigate('Register')}>
|
||||
@@ -432,9 +408,7 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 60,
|
||||
paddingTop: 48,
|
||||
},
|
||||
headerContainer: {
|
||||
alignItems: 'center',
|
||||
@@ -484,12 +458,8 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
borderTopLeftRadius: 32,
|
||||
borderTopRightRadius: 32,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
padding: 24,
|
||||
paddingBottom: Platform.OS === 'ios' ? 40 : 24,
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
paddingBottom: Platform.OS === 'ios' ? 40 : 32,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.1,
|
||||
|
||||
@@ -176,7 +176,15 @@ export default function HomeScreen({ navigation }: Props) {
|
||||
{loading ? (
|
||||
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
|
||||
) : 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}>
|
||||
<Text style={styles.promptTitle}>Pronto para a próxima?</Text>
|
||||
|
||||
@@ -6,6 +6,141 @@ 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 }) {
|
||||
@@ -48,6 +183,8 @@ export default function NewTripScreen({ navigation }) {
|
||||
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);
|
||||
@@ -81,35 +218,35 @@ export default function NewTripScreen({ navigation }) {
|
||||
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}` }
|
||||
});
|
||||
// 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 (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.');
|
||||
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', {
|
||||
@@ -124,222 +261,489 @@ export default function NewTripScreen({ navigation }) {
|
||||
console.log("SPOTIFY_USER_ID_EXISTS:", !!spotifyUserId);
|
||||
if (!spotifyUserId) throw new Error('Could not fetch Spotify User ID from /v1/me');
|
||||
|
||||
// C. Call Ollama server
|
||||
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.`;
|
||||
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
|
||||
})
|
||||
});
|
||||
// C. Build varied music queries using AI + favorite genre
|
||||
function shuffleArray<T>(array: T[]): T[] {
|
||||
const copy = [...array];
|
||||
|
||||
let searchQueries: string[] = ["pop hits", "road trip songs", "top hits Portugal", "summer hits", "travel songs"]; // Fallback
|
||||
try {
|
||||
const ollamaData = await safeParseJson(ollamaRes, 'Ollama');
|
||||
let rawAiText = ollamaData?.message?.content || "";
|
||||
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]];
|
||||
}
|
||||
|
||||
// Clean AI text
|
||||
rawAiText = rawAiText.replace(/```json/g, '').replace(/```/g, '').trim();
|
||||
|
||||
if (rawAiText.length > 0 && rawAiText.startsWith("[")) {
|
||||
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);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// D. Create empty playlist
|
||||
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}. Themes: ${searchQueries.join(', ')}`,
|
||||
public: false
|
||||
description: `Roadtrip from ${origin} to ${destination}${cleanGenre ? ` · ${cleanGenre} vibes` : ''}.`,
|
||||
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, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${providerToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
Authorization: `Bearer ${providerToken}`,
|
||||
'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();
|
||||
|
||||
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) {
|
||||
// Stored refresh token predates playlist scopes — clear tokens so next login forces full re-auth
|
||||
await clearSpotifyTokens();
|
||||
console.warn("CREATE_PLAYLIST_403: Cleared stale Spotify tokens. User must reconnect.");
|
||||
Alert.alert(
|
||||
'Permissão Spotify Necessária',
|
||||
'Reconnect Spotify to grant playlist permissions. Go to Profile and log in with Spotify again.'
|
||||
);
|
||||
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';
|
||||
// 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;
|
||||
try {
|
||||
playlistData = JSON.parse(createPlaylistResText);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse JSON response for CreatePlaylist: ${createPlaylistResText.substring(0, 150)}`);
|
||||
} 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;
|
||||
|
||||
// E. Fill playlist with tracks based on duration
|
||||
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;
|
||||
console.log('TARGET_PLAYLIST_DURATION_MS:', tripDurationMs);
|
||||
|
||||
while (
|
||||
accumulatedDurationMs < tripDurationMs &&
|
||||
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}`;
|
||||
// ─── PHASE 1: Collect style tracks (80% of target, genre-validated) ─────────
|
||||
const selectedTracks: SelectedSpotifyTrack[] = [];
|
||||
|
||||
console.log("TRACK_SEARCH_QUERY:", currentQuery, "offset:", offset);
|
||||
console.log("TRACK_SEARCH_URL:", searchUrl);
|
||||
const allStyleQueries = [...styleQueries, ...aiArtistQueries];
|
||||
console.log('[Playlist] Phase 1 style queries:', allStyleQueries);
|
||||
|
||||
const searchRes = await fetch(searchUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${providerToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
for (const query of allStyleQueries) {
|
||||
if (selectedTracks.length >= styleTargetCount) break;
|
||||
if (accumulatedDurationMs >= tripDurationMs) break;
|
||||
if (searchRequestsCount >= 60) break;
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
if (track.id && track.uri && track.duration_ms && track.is_local !== true) {
|
||||
if (track.is_playable === undefined || track.is_playable !== false) {
|
||||
if (!selectedTracks.some(t => t.id === track.id)) {
|
||||
selectedTracks.push({ id: track.id, uri: track.uri, duration_ms: track.duration_ms });
|
||||
accumulatedDurationMs += track.duration_ms;
|
||||
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);
|
||||
|
||||
offset += 10;
|
||||
if (offset >= 1000) {
|
||||
queryIndex++;
|
||||
offset = 0;
|
||||
}
|
||||
searchRequestsCount++;
|
||||
}
|
||||
|
||||
console.log("TRACKS_SELECTED_COUNT:", selectedTracks.length);
|
||||
console.log("SELECTED_TRACKS_TOTAL_DURATION_MS:", accumulatedDurationMs);
|
||||
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(t => t.uri);
|
||||
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 chunk = trackUris.slice(i, i + chunkSize);
|
||||
|
||||
const addTracksRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/items`, {
|
||||
method: 'POST',
|
||||
const addTracksRes = await fetch(
|
||||
`https://api.spotify.com/v1/playlists/${playlistId}/items`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Authorization': `Bearer ${providerToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
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)}`);
|
||||
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;
|
||||
console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl);
|
||||
|
||||
if (accumulatedDurationMs < tripDurationMs - 60000 && (selectedTracks.length >= MAX_TRACKS || searchRequestsCount >= MAX_SEARCH_REQUESTS || noMoreTracks)) {
|
||||
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.`;
|
||||
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.`;
|
||||
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';
|
||||
console.warn("No tracks found for queries:", searchQueries);
|
||||
|
||||
playlistCreationFailed = true;
|
||||
playlistFailureReason = "notracks";
|
||||
}
|
||||
}
|
||||
} catch (playlistError: any) {
|
||||
@@ -366,24 +770,32 @@ export default function NewTripScreen({ navigation }) {
|
||||
});
|
||||
|
||||
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);
|
||||
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') {
|
||||
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) {
|
||||
Alert.alert('Sucesso!', playlistSuccessMessage);
|
||||
} else {
|
||||
Alert.alert('Sucesso!', 'Viagem calculada e guardada!');
|
||||
}
|
||||
navigation.goBack();
|
||||
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);
|
||||
console.error("Exception during DB save:", dbEx);
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
@@ -1,361 +1,480 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ImageBackground, SafeAreaView, FlatList } from 'react-native';
|
||||
import { ArrowLeft, Share2, MoreVertical, Navigation, Clock, Music, Compass, MapPin, Play } from 'lucide-react-native';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ImageBackground, SafeAreaView, Linking, Alert } from 'react-native';
|
||||
import { ArrowLeft, MapPin, Navigation, Music, Play } from 'lucide-react-native';
|
||||
import { colors } from '../../utils/colors';
|
||||
import TimelineItem from '../../components/TimelineItem';
|
||||
import TrackItem from '../../components/TrackItem';
|
||||
|
||||
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' },
|
||||
];
|
||||
import { getDestinationLandmarkImage } from '../../services/destinationImage';
|
||||
|
||||
// @ts-ignore
|
||||
export default function TripDetailsScreen({ navigation }) {
|
||||
const [activeTab, setActiveTab] = useState<'rota' | 'playlist'>('rota');
|
||||
export default function TripDetailsScreen({ navigation, route }) {
|
||||
const { trip } = route.params || {};
|
||||
|
||||
const renderRotaTab = () => (
|
||||
<View style={styles.tabContent}>
|
||||
{/* 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>
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(trip?.destination_image_url || null);
|
||||
const [landmarkName, setLandmarkName] = useState<string | null>(trip?.destination_landmark_name || null);
|
||||
|
||||
{/* DJ Guide Card */}
|
||||
<View style={styles.djCard}>
|
||||
<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>
|
||||
useEffect(() => {
|
||||
if (!trip?.destination || imageUrl) return;
|
||||
|
||||
{/* Itinerary */}
|
||||
<View style={styles.itinerarySection}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Compass color={colors.textMain} size={20} style={{ marginRight: 8 }} />
|
||||
<Text style={styles.sectionTitle}>Itinerário</Text>
|
||||
</View>
|
||||
let isMounted = true;
|
||||
const tripTitle = trip.title || trip.destination || 'Viagem';
|
||||
|
||||
<View style={styles.timelineContainer}>
|
||||
<TimelineItem
|
||||
title="Lisbon, Portugal"
|
||||
subtitle="Partida • 0 km"
|
||||
type="start"
|
||||
/>
|
||||
<TimelineItem
|
||||
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>
|
||||
getDestinationLandmarkImage(trip.destination, { tripTitle }).then(result => {
|
||||
if (!isMounted) return;
|
||||
if (result.imageUrl) {
|
||||
setImageUrl(result.imageUrl);
|
||||
}
|
||||
if (result.landmarkName) {
|
||||
setLandmarkName(result.landmarkName);
|
||||
}
|
||||
});
|
||||
|
||||
{/* Maps Action */}
|
||||
<TouchableOpacity style={styles.mapsButton}>
|
||||
<MapPin color={colors.white} size={20} style={{ marginRight: 8 }} />
|
||||
<Text style={styles.mapsButtonText}>Abrir no Google Maps</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [trip?.destination, imageUrl]);
|
||||
|
||||
const renderPlaylistTab = () => (
|
||||
<View style={styles.tabContent}>
|
||||
{/* Generated Playlist Card */}
|
||||
<View style={styles.playlistGeneratedCard}>
|
||||
<View style={styles.spotifyLogoLarge}>
|
||||
{/* Mocking large spotify logo with music icon for now */}
|
||||
<Music color={colors.spotify} size={40} />
|
||||
</View>
|
||||
<Text style={styles.playlistTitle}>Playlist Gerada</Text>
|
||||
<Text style={styles.playlistSubtitle}>45 músicas • 3h 20m de viagem</Text>
|
||||
const handleOpenGoogleMaps = () => {
|
||||
if (!trip?.origin || !trip?.destination) {
|
||||
Alert.alert('Erro', 'Dados de rota incompletos.');
|
||||
return;
|
||||
}
|
||||
const url = `https://www.google.com/maps/dir/?api=1&origin=${encodeURIComponent(trip.origin)}&destination=${encodeURIComponent(trip.destination)}`;
|
||||
Linking.openURL(url);
|
||||
};
|
||||
|
||||
<TouchableOpacity style={styles.spotifyActionBtn}>
|
||||
<Play fill={colors.white} color={colors.white} size={18} style={{ marginRight: 8 }} />
|
||||
<Text style={styles.spotifyActionText}>Ouvir no Spotify</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
const stops = trip?.stops || trip?.waypoints || [];
|
||||
const hasStops = Array.isArray(stops) && stops.length > 0;
|
||||
|
||||
{/* Preview List */}
|
||||
<Text style={styles.previewTitle}>Pré-visualização (Músicas Iniciais)</Text>
|
||||
<View style={styles.previewListCard}>
|
||||
{MOCK_TRACKS.map((track, index) => (
|
||||
<TrackItem
|
||||
key={track.id}
|
||||
index={index + 1}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
duration={track.duration}
|
||||
/>
|
||||
))}
|
||||
const routeVisual = (
|
||||
<View style={styles.mockRouteVisual}>
|
||||
<View style={styles.routeDotLarge} />
|
||||
<View style={styles.routeLineDashed} />
|
||||
<View style={styles.routePinLarge}>
|
||||
<MapPin color={colors.white} size={14} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView>
|
||||
{/* Header Image */}
|
||||
<ImageBackground
|
||||
source={{ uri: 'https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?q=80&w=2021&auto=format&fit=crop' }}
|
||||
style={styles.headerImage}
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{trip?.title || trip?.destination || 'Detalhes da Viagem'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<SafeAreaView style={styles.headerSafeArea}>
|
||||
<View style={styles.headerNav}>
|
||||
<TouchableOpacity style={styles.navIconButton} onPress={() => navigation.goBack()}>
|
||||
<ArrowLeft color={colors.white} size={24} />
|
||||
</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>
|
||||
<ArrowLeft color={colors.textMain} size={20} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
{/* Top visual representation */}
|
||||
{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>
|
||||
)}
|
||||
{routeVisual}
|
||||
</View>
|
||||
</ImageBackground>
|
||||
) : (
|
||||
<View style={styles.mapArea}>
|
||||
{routeVisual}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.headerTitles}>
|
||||
<View style={styles.tag}>
|
||||
<Text style={styles.tagText}>ROADTRIP</Text>
|
||||
</View>
|
||||
<Text style={styles.mainTitle}>Lisbon to Porto Coastline</Text>
|
||||
<Text style={styles.subTitle}>May 10, 2026</Text>
|
||||
{/* Content Card overlapping map/image */}
|
||||
<View style={styles.detailsCard}>
|
||||
{/* Trip Name info */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.inputLabel}>NOME DA VIAGEM</Text>
|
||||
<View style={styles.textInputReadonly}>
|
||||
<Text style={styles.textInputReadonlyText}>
|
||||
{trip?.title || trip?.destination || 'Sem título'}
|
||||
</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</ImageBackground>
|
||||
|
||||
{/* Content Area overlapping image slightly */}
|
||||
<View style={styles.contentWrapper}>
|
||||
|
||||
{/* Custom Tabs */}
|
||||
<View style={styles.tabContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tabButton, activeTab === 'rota' && styles.tabButtonActive]}
|
||||
onPress={() => setActiveTab('rota')}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'rota' && styles.tabTextActive]}>
|
||||
Rota & DJ
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.tabButton, activeTab === 'playlist' && styles.tabButtonActive]}
|
||||
onPress={() => setActiveTab('playlist')}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'playlist' && styles.tabTextActive]}>
|
||||
Playlist Spotify
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Render Tab Content */}
|
||||
{activeTab === 'rota' ? renderRotaTab() : renderPlaylistTab()}
|
||||
{/* Origin & Destination timeline */}
|
||||
<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>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: colors.white,
|
||||
},
|
||||
headerImage: {
|
||||
width: '100%',
|
||||
height: 320,
|
||||
justifyContent: 'flex-start',
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
headerSafeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)', // Overlay
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
headerNav: {
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
},
|
||||
navIconButton: {
|
||||
width: 40,
|
||||
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',
|
||||
paddingBottom: 16,
|
||||
backgroundColor: colors.white,
|
||||
zIndex: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.inputBorder,
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 10,
|
||||
},
|
||||
tabButton: {
|
||||
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: {
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.textMain,
|
||||
marginBottom: 4,
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 13,
|
||||
backButton: {
|
||||
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,
|
||||
marginBottom: 8,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
textInputReadonly: {
|
||||
backgroundColor: colors.inputBackground,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
textInputReadonlyText: {
|
||||
fontSize: 16,
|
||||
color: colors.textMain,
|
||||
fontWeight: '500',
|
||||
},
|
||||
djCard: {
|
||||
backgroundColor: '#FFF5EB',
|
||||
boldText: {
|
||||
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,
|
||||
padding: 20,
|
||||
marginBottom: 30,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FFE0C2',
|
||||
borderColor: '#C8E6C9',
|
||||
},
|
||||
djHeader: {
|
||||
spotifyHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
djIconContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.primary,
|
||||
spotifyLogoContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#000000',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 10,
|
||||
marginRight: 12,
|
||||
},
|
||||
djTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#8C3800',
|
||||
spotifyHeaderTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
djText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
color: '#A34200',
|
||||
fontWeight: '500',
|
||||
},
|
||||
itinerarySection: {
|
||||
marginBottom: 30,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
spotifyCardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
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: {
|
||||
backgroundColor: colors.white,
|
||||
@@ -366,87 +485,40 @@ const styles = StyleSheet.create({
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
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: {
|
||||
backgroundColor: '#111827', // Almost black
|
||||
backgroundColor: colors.inputBackground,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 18,
|
||||
borderRadius: 30, // Highly rounded
|
||||
marginBottom: 40,
|
||||
borderRadius: 16,
|
||||
},
|
||||
mapsButtonText: {
|
||||
color: colors.white,
|
||||
color: colors.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
|
||||
// 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,
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user