Compare commits

...

2 Commits

Author SHA1 Message Date
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 1015 additions and 619 deletions

28
scratch_db_test.js Normal file
View File

@@ -0,0 +1,28 @@
const supabaseUrl = "https://qyvnryhskgmvgjajqqru.supabase.co";
const supabaseKey = "sb_publishable_fazCCLmO7XjtryY28ePR-A_CS7aU6fF";
async function run() {
try {
const res = await fetch(`${supabaseUrl}/rest/v1/trips`, {
method: "POST",
headers: {
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
"Content-Type": "application/json",
Prefer: "return=representation"
},
body: JSON.stringify({
title: "Test Trip",
origin: "Lisbon",
destination: "Porto",
waypoints: []
})
});
const data = await res.json();
console.log("INSERT_RESPONSE:", JSON.stringify(data, null, 2));
} catch (err) {
console.error("Error inserting:", err);
}
}
run();

View File

@@ -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>
);

View File

@@ -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,

View File

@@ -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>

View File

@@ -6,6 +6,85 @@ 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;
}
// ─────────────────────────────────────────────────────────────────────────────
// @ts-ignore
export default function NewTripScreen({ navigation }) {
@@ -40,7 +119,7 @@ export default function NewTripScreen({ navigation }) {
const finalDistance = leg.distance.text;
const finalDuration = leg.duration.text;
const tripDurationMs = leg.duration.value * 1000;
setDistance(finalDistance);
setDuration(finalDuration);
@@ -59,7 +138,7 @@ export default function NewTripScreen({ navigation }) {
console.log(`PLAYLIST_API_STATUS [${label}]:`, res.status);
console.log(`PLAYLIST_API_CONTENT_TYPE [${label}]:`, res.headers.get("content-type"));
console.log(`PLAYLIST_API_RAW_RESPONSE [${label}]:`, rawText.substring(0, 300) + (rawText.length > 300 ? "..." : ""));
if (!res.ok) {
throw new Error(`Spotify API returned status ${res.status} for [${label}]: ${rawText.substring(0, 150)}`);
}
@@ -81,39 +160,39 @@ 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}` }
});
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).");
}
// Validate token via GET /v1/me (free endpoint, no Premium required)
let meRes = await fetch('https://api.spotify.com/v1/me', {
headers: { Authorization: `Bearer ${providerToken}` }
});
if (meRes.status === 401) {
console.log("Spotify token is invalid/expired (401), attempting to refresh...");
const newToken = await refreshSpotifyToken();
if (newToken) {
providerToken = newToken;
console.log("Spotify token refreshed successfully!");
} else {
console.log("Failed to refresh Spotify token.");
providerToken = null;
}
} else if (!meRes.ok) {
const meErr = await meRes.text();
console.warn("Spotify GET /v1/me failed:", meRes.status, meErr);
providerToken = null;
} else {
console.log("Spotify token valid (GET /v1/me returned 200 OK).");
}
}
if (!providerToken) {
console.log("Spotify token missing or expired, skipping playlist generation.");
playlistCreationFailed = true;
playlistFailureReason = 'token';
Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.');
console.log("Spotify token missing or expired, skipping playlist generation.");
playlistCreationFailed = true;
playlistFailureReason = 'token';
Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.');
} else {
// B. Fetch Spotify User ID (reuse /v1/me — already validated above)
const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', {
headers: {
headers: {
'Authorization': `Bearer ${providerToken}`,
'Content-Type': 'application/json'
}
@@ -124,173 +203,408 @@ 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
})
});
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 || "";
// 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);
// C. Build varied music queries using AI + favorite genre
function shuffleArray<T>(array: T[]): T[] {
const copy = [...array];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
function cleanSearchQuery(query: string): string {
return query
.replace(/["[\]]/g, "")
.replace(/\s+/g, " ")
.trim();
}
function getRandomSpotifyOffset(): number {
const offsets = [0, 10, 20, 30, 40, 50];
return offsets[Math.floor(Math.random() * offsets.length)];
}
function getMainArtistKey(track: any): string {
return (
track?.artists?.[0]?.id ||
track?.artists?.[0]?.name ||
"unknown_artist"
);
}
async function readFavoriteGenreForPlaylist(): Promise<string> {
try {
const {
data: { session },
} = await supabase.auth.getSession();
const appUserId = session?.user?.id ?? null;
const possibleKeys = [
appUserId ? `favoriteGenre:${appUserId}` : "",
appUserId ? `userFavoriteGenre:${appUserId}` : "",
appUserId ? `@roadtripdj:favoriteGenre:${appUserId}` : "",
"favoriteGenre",
"userFavoriteGenre",
"@roadtripdj:favoriteGenre",
].filter(Boolean);
for (const key of possibleKeys) {
const value = await AsyncStorage.getItem(key);
if (value && value.trim().length > 0) {
return value.trim();
}
}
if (appUserId) {
try {
const { data } = await supabase
.from("profiles")
.select("favorite_genre")
.eq("id", appUserId)
.maybeSingle();
const genre = (data as any)?.favorite_genre;
if (genre && String(genre).trim().length > 0) {
return String(genre).trim();
}
} catch {
// Ignore profile lookup errors. Favorite genre is optional.
}
try {
const { data } = await supabase
.from("profiles")
.select("favoriteGenre")
.eq("id", appUserId)
.maybeSingle();
const genre = (data as any)?.favoriteGenre;
if (genre && String(genre).trim().length > 0) {
return String(genre).trim();
}
} catch {
// Ignore profile lookup errors. Favorite genre is optional.
}
}
} catch (error) {
console.warn("Failed to read favorite genre:", error);
}
return "";
}
const favoriteGenre = await readFavoriteGenreForPlaylist();
console.log("PLAYLIST_FAVORITE_GENRE_USED:", favoriteGenre || "none");
const ollamaPrompt = `I am taking a roadtrip from ${origin} to ${destination}.
The trip is called "${tripName}" and takes about ${duration}.
The user's favorite music genre is: "${favoriteGenre || "not set"}".
Reply ONLY with a JSON array of up to 10 Spotify search queries.
The queries should be varied and specific to this trip.
If a favorite genre is set, strongly include it in the search ideas.
Do NOT return song names.
Do NOT return explanations.
Example: ["rock road trip", "rock hits", "porto travel songs", "portuguese indie"].`;
let aiQueries: 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 || "";
rawAiText = rawAiText
.replace(/```json/g, "")
.replace(/```/g, "")
.trim();
if (rawAiText.length > 0 && rawAiText.startsWith("[")) {
const parsed = JSON.parse(rawAiText);
if (Array.isArray(parsed)) {
aiQueries = parsed
.map(String)
.map(cleanSearchQuery)
.filter(Boolean)
.slice(0, 10);
}
}
} catch (aiError) {
console.log("AI parsing failed, using fallback queries.", aiError);
}
const favoriteGenreQueries = favoriteGenre
? [
`${favoriteGenre} road trip`,
`${favoriteGenre} hits`,
`${favoriteGenre} travel songs`,
`${favoriteGenre} driving music`,
`${favoriteGenre} playlist`,
`${favoriteGenre} ${destination}`,
]
: [];
const tripSpecificQueries = [
`${destination} road trip`,
`${origin} to ${destination} music`,
`${tripName} playlist`,
`${destination} travel songs`,
`${origin} ${destination} road trip`,
];
const fallbackQueries = [
"road trip songs",
"travel songs",
"summer hits",
"top hits Portugal",
"pop hits",
"driving music",
"feel good road trip",
"european travel music",
"party road trip",
"indie road trip",
];
const firstQueries = [
...favoriteGenreQueries,
...aiQueries,
]
.map(cleanSearchQuery)
.filter(Boolean);
const remainingQueries = [
...tripSpecificQueries,
...fallbackQueries,
]
.map(cleanSearchQuery)
.filter(Boolean);
const searchQueries = Array.from(
new Set([
...firstQueries,
...shuffleArray(remainingQueries),
])
);
const playlistRandomSeed = `${Date.now()}-${Math.random()
.toString(36)
.slice(2)}`;
console.log("PLAYLIST_RANDOM_SEED:", playlistRandomSeed);
console.log("PLAYLIST_MUSIC_QUERIES:", searchQueries);
// D. Create empty playlist
const createPlaylistUrl = 'https://api.spotify.com/v1/me/playlists';
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}. Themes: ${searchQueries
.slice(0, 8)
.join(", ")}`,
public: false,
});
console.log("CREATE_PLAYLIST_URL:", createPlaylistUrl);
console.log("CREATE_PLAYLIST_BODY:", createPlaylistBody);
const createPlaylistRes = await fetch(createPlaylistUrl, {
method: 'POST',
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);
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.`);
playlistFailureReason = "scope";
throw new Error(
"CREATE_PLAYLIST_SCOPE_ERROR: Tokens cleared, re-auth required."
);
}
throw new Error(`Spotify API returned status ${createPlaylistRes.status} for CreatePlaylist: ${createPlaylistResText.substring(0, 150)}`);
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 response 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;
generatedPlaylistUrl = playlistData.external_urls.spotify;
// E. Fill playlist with tracks based on duration
// E. Fill playlist with varied tracks based on duration
console.log("TARGET_PLAYLIST_DURATION_MS:", tripDurationMs);
let accumulatedDurationMs = 0;
let selectedTracks: { id: string; uri: string; duration_ms: number }[] = [];
const selectedTracks: SelectedSpotifyTrack[] = [];
const selectedTrackIds = new Set<string>();
const artistCount = new Map<string, number>();
let searchRequestsCount = 0;
let queryIndex = 0;
const MAX_SEARCH_REQUESTS = 40;
const MAX_TRACKS = 400;
let queryIndex = 0;
let offset = 0;
let noMoreTracks = false;
const MAX_TRACKS_PER_ARTIST = 3;
while (
accumulatedDurationMs < tripDurationMs &&
accumulatedDurationMs < tripDurationMs &&
searchRequestsCount < MAX_SEARCH_REQUESTS &&
selectedTracks.length < MAX_TRACKS &&
!noMoreTracks
searchQueries.length > 0
) {
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 offset = getRandomSpotifyOffset();
const searchUrl =
`https://api.spotify.com/v1/search` +
`?type=track` +
`&q=${queryEncoded}` +
`&limit=10` +
`&market=${spotifyUserCountry}` +
`&offset=${offset}`;
console.log("TRACK_SEARCH_QUERY:", currentQuery);
console.log("TRACK_SEARCH_OFFSET:", offset);
console.log("TRACK_SEARCH_URL:", searchUrl);
const searchRes = await fetch(searchUrl, {
headers: {
'Authorization': `Bearer ${providerToken}`,
'Content-Type': 'application/json'
}
headers: {
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;
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)
);
searchRequestsCount++;
queryIndex++;
continue;
}
const searchData = (await safeParseJson(searchRes, "SearchTracks")) as any;
const rawTracks: SpotifySearchTrack[] = Array.isArray(searchData?.tracks?.items)
? (searchData.tracks.items as SpotifySearchTrack[])
: [];
console.log("TRACKS_RAW_FOUND_COUNT:", rawTracks.length);
const shuffledTracks = shuffleArray<SpotifySearchTrack>(rawTracks);
let tracksAfterFilter = 0;
for (const track of tracks) {
for (const track of shuffledTracks) {
if (selectedTracks.length >= MAX_TRACKS) break;
if (accumulatedDurationMs >= tripDurationMs) break;
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 trackId = track.id;
const trackUri = track.uri;
const trackDurationMs = track.duration_ms;
if (!trackId) continue;
if (!trackUri) continue;
if (!trackDurationMs) continue;
if (track.is_local === true) continue;
if (track.is_playable === false) continue;
if (selectedTrackIds.has(trackId)) continue;
const artistKey = getMainArtistKey(track);
const currentArtistCount = artistCount.get(artistKey) ?? 0;
if (currentArtistCount >= MAX_TRACKS_PER_ARTIST) continue;
selectedTracks.push({
id: trackId,
uri: trackUri,
duration_ms: trackDurationMs,
});
selectedTrackIds.add(trackId);
artistCount.set(artistKey, currentArtistCount + 1);
accumulatedDurationMs += trackDurationMs;
tracksAfterFilter++;
}
console.log("TRACKS_AFTER_FILTER_COUNT:", tracksAfterFilter);
offset += 10;
if (offset >= 1000) {
queryIndex++;
offset = 0;
}
console.log("TRACKS_SELECTED_COUNT:", selectedTracks.length);
console.log("UNIQUE_ARTISTS_COUNT:", artistCount.size);
console.log("SELECTED_TRACKS_TOTAL_DURATION_MS:", accumulatedDurationMs);
searchRequestsCount++;
queryIndex++;
}
console.log("TRACKS_SELECTED_COUNT:", selectedTracks.length);
@@ -298,48 +612,69 @@ export default function NewTripScreen({ navigation }) {
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 addTracksRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/items`, {
method: 'POST',
const chunk = trackUris.slice(i, i + chunkSize);
const addTracksRes = await fetch(
`https://api.spotify.com/v1/playlists/${playlistId}/items`,
{
method: "POST",
headers: {
'Authorization': `Bearer ${providerToken}`,
'Content-Type': 'application/json'
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;
if (accumulatedDurationMs < tripDurationMs - 60000 && (selectedTracks.length >= MAX_TRACKS || searchRequestsCount >= MAX_SEARCH_REQUESTS || noMoreTracks)) {
const hours = Math.round((accumulatedDurationMs / 3600000) * 10) / 10;
if (hours >= 1) {
playlistSuccessMessage = `Viagem guardada! Playlist criada com ${hours} horas de música.`;
} else {
const minutes = Math.round(accumulatedDurationMs / 60000);
playlistSuccessMessage = `Viagem guardada! Playlist criada com ${minutes} minutos de música.`;
}
}
console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl);
playlistCreationFailed = false;
if (
accumulatedDurationMs < tripDurationMs - 60000 &&
(selectedTracks.length >= MAX_TRACKS ||
searchRequestsCount >= MAX_SEARCH_REQUESTS)
) {
const hours = Math.round((accumulatedDurationMs / 3600000) * 10) / 10;
if (hours >= 1) {
playlistSuccessMessage = `Viagem guardada! Playlist criada com ${hours} horas de música.`;
} else {
const minutes = Math.round(accumulatedDurationMs / 60000);
playlistSuccessMessage = `Viagem guardada! Playlist criada com ${minutes} minutos de música.`;
}
}
}
} else {
console.warn("No tracks found for queries:", searchQueries);
playlistCreationFailed = true;
playlistFailureReason = 'notracks';
console.warn("No tracks found for queries:", searchQueries);
playlistCreationFailed = true;
playlistFailureReason = "notracks";
}
}
} catch (playlistError: any) {
@@ -354,36 +689,36 @@ export default function NewTripScreen({ navigation }) {
try {
const { data: { session } } = await supabase.auth.getSession();
const userId = session?.user?.id || null;
const { error: dbError } = await supabase.from('trips').insert({
user_id: userId,
title: tripName,
origin,
destination,
distance: finalDistance,
duration: finalDuration,
playlist_url: generatedPlaylistUrl
const { error: dbError } = await supabase.from('trips').insert({
user_id: userId,
title: tripName,
origin,
destination,
distance: finalDistance,
duration: finalDuration,
playlist_url: generatedPlaylistUrl
});
if (dbError) {
console.error("DB Insert error:", dbError);
Alert.alert('Erro ao Guardar', 'Não foi possível guardar a viagem na base de dados: ' + dbError.message);
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') {
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();
}
} catch (dbEx) {
console.error("Exception during DB save:", dbEx);
console.error("Exception during DB save:", dbEx);
}
} else {

View File

@@ -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>
</View>
<ArrowLeft color={colors.textMain} size={20} />
</TouchableOpacity>
</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>
<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>
</SafeAreaView>
</ImageBackground>
</ImageBackground>
) : (
<View style={styles.mapArea}>
{routeVisual}
</View>
)}
{/* 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
{/* 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>
</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>
</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,
},
});