From dedf25c51fdeaca4d44b65657e456300567d0a39 Mon Sep 17 00:00:00 2001 From: RoadtripDJ Dev Date: Sun, 17 May 2026 20:48:19 +0100 Subject: [PATCH] Fix Supabase OAuth flow and various UI updates --- App.tsx | 25 ++- app.json | 3 +- src/screens/auth/LoginScreen.tsx | 40 ++-- src/screens/auth/RegisterScreen.tsx | 11 +- src/screens/trip/NewTripScreen.tsx | 296 +++++++++++++++++++++++++--- src/services/supabase.ts | 6 + tsconfig.json | 2 +- 7 files changed, 326 insertions(+), 57 deletions(-) diff --git a/App.tsx b/App.tsx index 84ad1a9..628753e 100644 --- a/App.tsx +++ b/App.tsx @@ -1,11 +1,34 @@ import { StatusBar } from 'expo-status-bar'; -import React from 'react'; +import React, { useEffect } from 'react'; import { NavigationContainer } from '@react-navigation/native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import AppNavigator from './src/navigation/AppNavigator'; import { AuthProvider } from './src/contexts/AuthContext'; +import * as Linking from 'expo-linking'; +import * as QueryParams from 'expo-auth-session/build/QueryParams'; +import { supabase } from './src/services/supabase'; export default function App() { + useEffect(() => { + const catchTokenOnLoad = async () => { + const url = await Linking.getInitialURL(); + if (!url) return; + + // Supabase sends tokens after a '#' which Expo might ignore, replace it with '?' + const cleanUrl = url.replace('#', '?'); + const { params, errorCode } = QueryParams.getQueryParams(cleanUrl); + + if (params?.access_token) { + console.log('🔥 TOKEN APANHADO NO ARRANQUE!'); + await supabase.auth.setSession({ + access_token: params.access_token, + refresh_token: params.refresh_token || '', + }); + } + }; + catchTokenOnLoad(); + }, []); + return ( diff --git a/app.json b/app.json index fa01599..e2ac969 100644 --- a/app.json +++ b/app.json @@ -1,6 +1,7 @@ { "expo": { "name": "roadtrip-dj", + "scheme": "roadtripdj", "slug": "roadtrip-dj", "version": "1.0.0", "orientation": "portrait", @@ -30,4 +31,4 @@ "expo-web-browser" ] } -} +} \ No newline at end of file diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index d802e43..69c832a 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -1,18 +1,20 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { View, Text, TextInput, TouchableOpacity, StyleSheet, SafeAreaView, KeyboardAvoidingView, Platform, ScrollView, Alert, ActivityIndicator } from 'react-native'; import { Car, Music } from 'lucide-react-native'; import { colors } from '../../utils/colors'; import { supabase } from '../../services/supabase'; import { makeRedirectUri } from 'expo-auth-session'; -import * as WebBrowser from 'expo-web-browser'; +import * as QueryParams from 'expo-auth-session/build/QueryParams'; +import * as Linking from 'expo-linking'; -WebBrowser.maybeCompleteAuthSession(); // @ts-ignore export default function LoginScreen({ navigation }) { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); + + const handleLogin = async () => { if (!email || !password) { Alert.alert('Erro', 'Por favor preenche todos os campos.'); @@ -31,28 +33,14 @@ export default function LoginScreen({ navigation }) { setLoading(false); }; - const handleSpotifyAuth = async () => { - try { - const redirectUri = makeRedirectUri(); - const { data, error } = await supabase.auth.signInWithOAuth({ - provider: 'spotify', - options: { - redirectTo: redirectUri, - }, - }); - - if (error) throw error; - - if (data?.url) { - const res = await WebBrowser.openAuthSessionAsync(data.url, redirectUri); - if (res.type === 'success') { - const { url } = res; - // Ensure Supabase catches the session from the returned URL - await supabase.auth.getSessionFromUrl(url); - } - } - } catch (err: any) { - Alert.alert('Erro', err?.message || 'Ocorreu um erro no Spotify Auth.'); + const handleSpotifyLogin = async () => { + const redirectUrl = Linking.createURL('/'); + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'spotify', + options: { redirectTo: redirectUrl } + }); + if (data?.url) { + await Linking.openURL(data.url); } }; @@ -109,7 +97,7 @@ export default function LoginScreen({ navigation }) { )} - + {/* Note: In a real app we would use an actual Spotify SVG logo. Using Music icon for now as a placeholder for the Spotify logo. */} Entrar com Spotify diff --git a/src/screens/auth/RegisterScreen.tsx b/src/screens/auth/RegisterScreen.tsx index 0481893..41207e4 100644 --- a/src/screens/auth/RegisterScreen.tsx +++ b/src/screens/auth/RegisterScreen.tsx @@ -5,6 +5,7 @@ import { colors } from '../../utils/colors'; import { supabase } from '../../services/supabase'; import { makeRedirectUri } from 'expo-auth-session'; import * as WebBrowser from 'expo-web-browser'; +import * as QueryParams from 'expo-auth-session/build/QueryParams'; WebBrowser.maybeCompleteAuthSession(); // @ts-ignore @@ -58,8 +59,14 @@ export default function RegisterScreen({ navigation }) { const res = await WebBrowser.openAuthSessionAsync(data.url, redirectUri); if (res.type === 'success') { const { url } = res; - // Ensure Supabase catches the session from the returned URL - await supabase.auth.getSessionFromUrl(url); + const { params, errorCode } = QueryParams.getQueryParams(url); + if (errorCode) throw new Error(errorCode); + if (params.access_token && params.refresh_token) { + await supabase.auth.setSession({ + access_token: params.access_token, + refresh_token: params.refresh_token, + }); + } } } } catch (err: any) { diff --git a/src/screens/trip/NewTripScreen.tsx b/src/screens/trip/NewTripScreen.tsx index 5b06b06..ec834ec 100644 --- a/src/screens/trip/NewTripScreen.tsx +++ b/src/screens/trip/NewTripScreen.tsx @@ -1,27 +1,191 @@ -import React from 'react'; -import { View, Text, StyleSheet, SafeAreaView, TouchableOpacity, TextInput, ImageBackground, KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; -import { X, MapPin, ArrowRight } from 'lucide-react-native'; +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'; // @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 handleCreateRoute = () => { - // In a real app, this would trigger the orchestration flow (Ollama -> Spotify -> Firebase) - // For now, we mock success and navigate to the Trip Details - navigation.replace('TripDetails'); + 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); + + try { + // A. Get provider token + const { data: sessionData, error: sessionError } = await supabase.auth.getSession(); + if (sessionError || !sessionData?.session) throw new Error('Session not found.'); + const providerToken = sessionData.session.provider_token; + if (!providerToken) throw new Error('Spotify provider token missing.'); + + // B. Fetch Spotify User ID + const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', { + headers: { 'Authorization': `Bearer ${providerToken}` } + }); + const spotifyUserData = await spotifyUserRes.json(); + if (!spotifyUserData.id) throw new Error('Could not fetch Spotify User ID'); + const spotifyUserId = spotifyUserData.id; + + // C. Call Ollama server + const ollamaRes = await fetch("http://89.114.196.110:11434/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "qwen3-coder:30b", + messages: [{ "role": "user", "content": `I am taking a roadtrip from ${origin} to ${destination}. Reply ONLY with 3 Spotify genre seeds separated by commas (e.g., pop,rock,indie) that fit this journey. No other text.` }], + stream: false + }) + }); + const ollamaData = await ollamaRes.json(); + const seed_genres = ollamaData.message.content.trim().replace(/\s+/g, '').toLowerCase(); + + // D. Create empty playlist + const createPlaylistRes = await fetch(`https://api.spotify.com/v1/users/${spotifyUserId}/playlists`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${providerToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: tripName, + description: `Roadtrip from ${origin} to ${destination}. Genres: ${seed_genres}`, + public: false + }) + }); + const playlistData = await createPlaylistRes.json(); + if (!playlistData.id) throw new Error('Could not create playlist'); + const playlistId = playlistData.id; + const generatedPlaylistUrl = playlistData.external_urls.spotify; + + // E. Fetch Spotify track recommendations + let accumulatedDurationMs = 0; + let trackUris: string[] = []; + let attempts = 0; + while (accumulatedDurationMs < tripDurationMs && attempts < 10) { + const recommendationsRes = await fetch(`https://api.spotify.com/v1/recommendations?seed_genres=${encodeURIComponent(seed_genres)}&limit=50`, { + headers: { 'Authorization': `Bearer ${providerToken}` } + }); + if (!recommendationsRes.ok) break; + const recommendationsData = await recommendationsRes.json(); + if (!recommendationsData.tracks || recommendationsData.tracks.length === 0) break; + + for (const track of recommendationsData.tracks) { + if (!trackUris.includes(track.uri)) { + trackUris.push(track.uri); + accumulatedDurationMs += track.duration_ms; + if (accumulatedDurationMs >= tripDurationMs) break; + } + } + attempts++; + } + + if (trackUris.length > 0) { + // F. Add tracks to playlist + const chunkSize = 100; + for (let i = 0; i < trackUris.length; i += chunkSize) { + const chunk = trackUris.slice(i, i + chunkSize); + await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${providerToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ uris: chunk }) + }); + } + } else { + console.log("No tracks found for genres:", seed_genres); + } + + // G. Save to Supabase + const { data: { user } } = await supabase.auth.getUser(); + if (user) { + const { error: dbError } = await supabase.from('trips').insert({ + user_id: user.id, + title: tripName, + origin, + destination, + distance: finalDistance, + duration: finalDuration, + playlist_url: generatedPlaylistUrl + }); + if (dbError) { + console.log("DB Insert error:", dbError); + } + } + + Alert.alert('Sucesso!', 'Viagem e Playlist Criadas!'); + } catch (playlistError) { + console.log("Error generating playlist:", playlistError); + Alert.alert('Erro Playlist', 'A viagem foi calculada, mas ocorreu um erro a criar a playlist.'); + } + + } 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 ( - {/* Header */} Nova Viagem - navigation.goBack()} > @@ -34,23 +198,25 @@ export default function NewTripScreen({ navigation }) { {/* Using a solid light gray color instead of a complex map image to keep it clean */} - - - - - + + + + + {/* Form Card */} - + NOME DA VIAGEM - @@ -65,36 +231,72 @@ export default function NewTripScreen({ navigation }) { PARTIDA - DESTINO - + {/* Results Section */} + {(distance || duration) ? ( + + + Distância + {distance} + + + + Duração + {duration} + + + ) : null} + {/* Bottom Actions */} - - Criar Rota & Playlist - + {loading ? ( + + ) : ( + <> + Calcular Viagem + + + )} + {(distance || duration) ? ( + + + Abrir no Google Maps + + ) : null} + A IA vai analisar o trajeto, pontos de interesse, clima{'\n'}e duração para criar a banda sonora perfeita. @@ -282,4 +484,46 @@ const styles = StyleSheet.create({ 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, + }, }); diff --git a/src/services/supabase.ts b/src/services/supabase.ts index c539eb8..2a513e7 100644 --- a/src/services/supabase.ts +++ b/src/services/supabase.ts @@ -1,6 +1,7 @@ import 'react-native-url-polyfill/auto'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { createClient } from '@supabase/supabase-js'; +import { AppState } from 'react-native'; const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL as string; const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY as string; @@ -12,4 +13,9 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, { persistSession: true, detectSessionInUrl: false, }, +}); + +AppState.addEventListener('change', (state) => { + if (state === 'active') { supabase.auth.startAutoRefresh(); } + else { supabase.auth.stopAutoRefresh(); } }); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index b9567f6..e22f4e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,4 +3,4 @@ "compilerOptions": { "strict": true } -} +} \ No newline at end of file