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