diff --git a/App.tsx b/App.tsx index 628753e..5dd7061 100644 --- a/App.tsx +++ b/App.tsx @@ -1,41 +1,53 @@ import { StatusBar } from 'expo-status-bar'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; +import { View, ActivityIndicator } from 'react-native'; 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 * as WebBrowser from 'expo-web-browser'; +import { handleInitialAuthUrl, subscribeToAuthRedirects } from './src/auth/authRedirect'; import { supabase } from './src/services/supabase'; +WebBrowser.maybeCompleteAuthSession(); + export default function App() { + const [isBootstrapped, setIsBootstrapped] = useState(false); + useEffect(() => { - const catchTokenOnLoad = async () => { - const url = await Linking.getInitialURL(); - if (!url) return; + const bootstrapAuth = async () => { + console.log('[App] Bootstrapping auth...'); + await handleInitialAuthUrl(); + await supabase.auth.getSession(); - // Supabase sends tokens after a '#' which Expo might ignore, replace it with '?' - const cleanUrl = url.replace('#', '?'); - const { params, errorCode } = QueryParams.getQueryParams(cleanUrl); + const sub = subscribeToAuthRedirects(); - if (params?.access_token) { - console.log('🔥 TOKEN APANHADO NO ARRANQUE!'); - await supabase.auth.setSession({ - access_token: params.access_token, - refresh_token: params.refresh_token || '', - }); - } + setIsBootstrapped(true); + console.log('[App] Bootstrapped auth!'); + + return () => { + sub.remove(); + }; }; - catchTokenOnLoad(); + + bootstrapAuth(); }, []); + if (!isBootstrapped) { + return ( + + + + ); + } + return ( - ); diff --git a/app.json b/app.json index e2ac969..3483ad4 100644 --- a/app.json +++ b/app.json @@ -1,8 +1,8 @@ { "expo": { "name": "roadtrip-dj", - "scheme": "roadtripdj", "slug": "roadtrip-dj", + "scheme": "roadtripdj", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", @@ -17,18 +17,30 @@ "supportsTablet": true }, "android": { + "package": "com.eduardo12345122.roadtripdj", "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" }, - "edgeToEdgeEnabled": true, + "edgeToEdgeEnabled": false, "predictiveBackGestureEnabled": false }, + "androidStatusBar": { + "barStyle": "dark-content", + "backgroundColor": "#ffffff", + "translucent": false + }, "web": { "favicon": "./assets/favicon.png" }, "plugins": [ - "expo-web-browser" - ] + "expo-web-browser", + "expo-secure-store" + ], + "extra": { + "eas": { + "projectId": "df13cff2-46b7-4344-ba7e-c74c2a8b32f0" + } + } } } \ No newline at end of file diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..03ef0b5 --- /dev/null +++ b/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 18.13.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/package-lock.json b/package-lock.json index 599e01d..209d5cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "expo": "~54.0.33", "expo-auth-session": "~7.0.11", "expo-crypto": "~15.0.9", + "expo-dev-client": "~6.0.21", + "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", "expo-web-browser": "~15.0.11", "lucide-react-native": "^1.14.0", @@ -3450,6 +3452,22 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/anser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", @@ -4831,6 +4849,63 @@ "expo": "*" } }, + "node_modules/expo-dev-client": { + "version": "6.0.21", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.21.tgz", + "integrity": "sha512-SWI6HD0pa4eJujkYFkvvpezUE1zmJXGLu+34azpu7+QJgO+FLutDYDj8BSTdeH/NYDEClDFjCGqVMcWETvmsCQ==", + "license": "MIT", + "dependencies": { + "expo-dev-launcher": "6.0.21", + "expo-dev-menu": "7.0.19", + "expo-dev-menu-interface": "2.0.0", + "expo-manifests": "~1.0.11", + "expo-updates-interface": "~2.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-launcher": { + "version": "6.0.21", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.21.tgz", + "integrity": "sha512-QZ9gcKMZbp6EsIhzS0QoGB8Cf4xeVJhjbNgWUwcoBIk8gshoFz8CkCQOnX+HNv2sSY3rdCaNpx3Xo0Rflyq7rA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.11.0", + "expo-dev-menu": "7.0.19", + "expo-manifests": "~1.0.11" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-menu": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.19.tgz", + "integrity": "sha512-ju5MZiBCPhUKKvHy0ElZdnlhq01mkEEiR8jfrgQVvW26aWjzjLiOhppNAyXtvGbhk7WxJim3wYMiqFFrjGdfKA==", + "license": "MIT", + "dependencies": { + "expo-dev-menu-interface": "2.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-menu-interface": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-2.0.0.tgz", + "integrity": "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-json-utils": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz", + "integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==", + "license": "MIT" + }, "node_modules/expo-linking": { "version": "8.0.12", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz", @@ -4859,6 +4934,19 @@ "react-native": "*" } }, + "node_modules/expo-manifests": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.11.tgz", + "integrity": "sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "expo-json-utils": "~0.15.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.25", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz", @@ -4958,6 +5046,15 @@ "react-native": "*" } }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz", @@ -4980,6 +5077,15 @@ "react-native": "*" } }, + "node_modules/expo-updates-interface": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz", + "integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-web-browser": { "version": "15.0.11", "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz", @@ -5447,6 +5553,22 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -6395,6 +6517,12 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", diff --git a/package.json b/package.json index d28a654..479a43d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "expo": "~54.0.33", "expo-auth-session": "~7.0.11", "expo-crypto": "~15.0.9", + "expo-dev-client": "~6.0.21", + "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", "expo-web-browser": "~15.0.11", "lucide-react-native": "^1.14.0", diff --git a/src/auth/authRedirect.ts b/src/auth/authRedirect.ts new file mode 100644 index 0000000..fa248ee --- /dev/null +++ b/src/auth/authRedirect.ts @@ -0,0 +1,204 @@ +import * as Linking from 'expo-linking'; +import * as QueryParams from 'expo-auth-session/build/QueryParams'; +import { supabase } from '../services/supabase'; +import { setSpotifyToken, setSpotifyRefreshToken } from './spotifyToken'; +import { addAuthDebugEvent } from '../debug/authDebug'; + +import { Alert } from 'react-native'; + +let isHandlingUrl = false; +const processedSuccessUrls = new Set(); + +export function parseOAuthParams(url: string) { + const params: Record = {}; + + try { + const qsIndex = url.indexOf('?'); + const hashIndex = url.indexOf('#'); + + let queryPart = ''; + let hashPart = ''; + + if (qsIndex !== -1) { + queryPart = url.substring(qsIndex + 1, hashIndex !== -1 ? hashIndex : undefined); + } + if (hashIndex !== -1) { + hashPart = url.substring(hashIndex + 1); + } + + const parsePart = (part: string) => { + if (!part) return; + part.split('&').forEach(pair => { + const [key, value] = pair.split('='); + if (key && value) { + params[decodeURIComponent(key)] = decodeURIComponent(value.replace(/\+/g, ' ')); + } + }); + }; + + parsePart(queryPart); + parsePart(hashPart); + + } catch (e) { + console.error('Error parsing OAuth params', e); + } + + return { params, errorCode: params.error_code || params.error || null }; +} + +export async function handleAuthRedirectUrl(url: string | null) { + await addAuthDebugEvent({ event: 'handleAuthRedirectUrl_called', has_url: Boolean(url) }); + + if (!url) return null; + + if (processedSuccessUrls.has(url)) { + console.log('[AuthRedirect] Ignoring already processed URL:', url); + await addAuthDebugEvent({ event: 'handleAuthRedirectUrl_skipped_duplicate' }); + return null; + } + + if (isHandlingUrl) { + console.log('[AuthRedirect] Currently handling a URL, skipping:', url); + return null; + } + + console.log('[AuthRedirect] Processing URL:', url); + isHandlingUrl = true; + + try { + await addAuthDebugEvent({ event: 'handleAuthRedirectUrl_processing' }); + const { params, errorCode } = parseOAuthParams(url); + const paramKeys = Object.keys(params || {}); + console.log('[AuthRedirect] Parsed param keys:', paramKeys); + + await addAuthDebugEvent({ + event: 'url_parsed', + paramKeys, + code_exists: !!params?.code, + access_token_exists: !!params?.access_token, + refresh_token_exists: !!params?.refresh_token, + error_exists: !!(params?.error || params?.error_code), + error_code: params?.error_code || params?.error, + error_description: params?.error_description + }); + + if (params?.error || params?.error_code || params?.error_description) { + await addAuthDebugEvent({ + event: 'oauth_error_received', + error: params?.error, + error_code: params?.error_code, + error_description: params?.error_description + }); + + const errorMsg = params.error_description || params.error || params.error_code; + Alert.alert("Spotify OAuth Error", errorMsg); + + // Do not add to processedSuccessUrls if there's an error + return null; + } + + if (errorCode) { + console.error('[AuthRedirect] OAuth Error:', errorCode); + throw new Error(`OAuth Error: ${errorCode}`); + } + + if (!params) { + return null; + } + + // Save Provider Tokens if present + if (params.provider_token) { + console.log('[AuthRedirect] Provider token found! Saving...'); + await setSpotifyToken(params.provider_token); + await addAuthDebugEvent({ event: 'provider_token_saved_from_params', success: true }); + } + if (params.provider_refresh_token) { + console.log('[AuthRedirect] Provider refresh token found! Saving...'); + await setSpotifyRefreshToken(params.provider_refresh_token); + } + + // PKCE flow usually returns 'code' + if (params.code) { + console.log('[AuthRedirect] Found auth code. Exchanging for session...'); + await addAuthDebugEvent({ event: 'exchangeCodeForSession_started' }); + + const { data, error } = await supabase.auth.exchangeCodeForSession(params.code); + + await addAuthDebugEvent({ event: 'exchangeCodeForSession_finished', success: !error, error: error?.message }); + + if (error) { + console.error('[AuthRedirect] exchangeCodeForSession failed:', error); + } else { + console.log('[AuthRedirect] exchangeCodeForSession succeeded!'); + + // Force session refresh for app state + const sessionRes = await supabase.auth.getSession(); + await addAuthDebugEvent({ event: 'getSession_after_exchange', user_exists: !!sessionRes.data.session?.user }); + + // Sometimes the provider token comes in the session object here instead of params + if (data?.session?.provider_token) { + console.log('[AuthRedirect] Found provider_token in session data! Saving...'); + await setSpotifyToken(data.session.provider_token); + await addAuthDebugEvent({ event: 'provider_token_saved_from_session', success: true }); + } + if (data?.session?.provider_refresh_token) { + await setSpotifyRefreshToken(data.session.provider_refresh_token); + } + + processedSuccessUrls.add(url); + return data.session; + } + } + // Fallback/Implicit flow returns access_token & refresh_token + else if (params.access_token) { + console.log('[AuthRedirect] Found access_token. Setting session directly...'); + await addAuthDebugEvent({ event: 'setSession_started' }); + + const { data, error } = await supabase.auth.setSession({ + access_token: params.access_token, + refresh_token: params.refresh_token || '', + }); + + await addAuthDebugEvent({ event: 'setSession_finished', success: !error, error: error?.message }); + + if (error) { + console.error('[AuthRedirect] setSession failed:', error); + } else { + console.log('[AuthRedirect] setSession succeeded!'); + + const sessionRes = await supabase.auth.getSession(); + await addAuthDebugEvent({ event: 'getSession_after_setSession', user_exists: !!sessionRes.data.session?.user }); + + if (data?.session?.provider_token) { + await setSpotifyToken(data.session.provider_token); + await addAuthDebugEvent({ event: 'provider_token_saved_from_session', success: true }); + } + + processedSuccessUrls.add(url); + return data.session; + } + } + } catch (error: any) { + console.error('[AuthRedirect] Error in handleAuthRedirectUrl:', error); + await addAuthDebugEvent({ event: 'handleAuthRedirectUrl_exception', error: error.message }); + } finally { + isHandlingUrl = false; + } + return null; +} + +export async function handleInitialAuthUrl() { + const url = await Linking.getInitialURL(); + console.log('[AuthRedirect] Linking.getInitialURL returned:', url); + await addAuthDebugEvent({ event: 'Linking.getInitialURL_received', exists: !!url }); + return handleAuthRedirectUrl(url); +} + +export function subscribeToAuthRedirects() { + const subscription = Linking.addEventListener('url', (e) => { + console.log('[AuthRedirect] Linking.addEventListener received URL:', e.url); + addAuthDebugEvent({ event: 'Linking.addEventListener_received', exists: !!e.url }); + handleAuthRedirectUrl(e.url); + }); + return subscription; +} diff --git a/src/auth/spotifyToken.ts b/src/auth/spotifyToken.ts new file mode 100644 index 0000000..5da424d --- /dev/null +++ b/src/auth/spotifyToken.ts @@ -0,0 +1,47 @@ +import * as SecureStore from 'expo-secure-store'; + +const SPOTIFY_TOKEN_KEY = 'spotify_provider_token'; +const SPOTIFY_REFRESH_TOKEN_KEY = 'spotify_provider_refresh_token'; + +export async function setSpotifyToken(token: string) { + try { + await SecureStore.setItemAsync(SPOTIFY_TOKEN_KEY, token); + } catch (error) { + console.error('Error saving Spotify token:', error); + } +} + +export async function getSpotifyAccessToken() { + try { + return await SecureStore.getItemAsync(SPOTIFY_TOKEN_KEY); + } catch (error) { + console.error('Error getting Spotify token:', error); + return null; + } +} + +export async function setSpotifyRefreshToken(token: string) { + try { + await SecureStore.setItemAsync(SPOTIFY_REFRESH_TOKEN_KEY, token); + } catch (error) { + console.error('Error saving Spotify refresh token:', error); + } +} + +export async function getSpotifyRefreshToken() { + try { + return await SecureStore.getItemAsync(SPOTIFY_REFRESH_TOKEN_KEY); + } catch (error) { + console.error('Error getting Spotify refresh token:', error); + return null; + } +} + +export async function clearSpotifyTokens() { + try { + await SecureStore.deleteItemAsync(SPOTIFY_TOKEN_KEY); + await SecureStore.deleteItemAsync(SPOTIFY_REFRESH_TOKEN_KEY); + } catch (error) { + console.error('Error clearing Spotify tokens:', error); + } +} diff --git a/src/debug/authDebug.ts b/src/debug/authDebug.ts new file mode 100644 index 0000000..6696cbc --- /dev/null +++ b/src/debug/authDebug.ts @@ -0,0 +1,35 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const AUTH_DEBUG_KEY = 'auth_debug_events'; + +export async function addAuthDebugEvent(event: Record) { + try { + const existing = await AsyncStorage.getItem(AUTH_DEBUG_KEY); + const events = existing ? JSON.parse(existing) : []; + const newEvent = { timestamp: new Date().toISOString(), ...event }; + events.unshift(newEvent); + // Keep only the last 30 events + const trimmed = events.slice(0, 30); + await AsyncStorage.setItem(AUTH_DEBUG_KEY, JSON.stringify(trimmed)); + } catch (e) { + console.error('Failed to save auth debug event', e); + } +} + +export async function getAuthDebugEvents() { + try { + const existing = await AsyncStorage.getItem(AUTH_DEBUG_KEY); + return existing ? JSON.parse(existing) : []; + } catch (e) { + console.error('Failed to get auth debug events', e); + return []; + } +} + +export async function clearAuthDebugEvents() { + try { + await AsyncStorage.removeItem(AUTH_DEBUG_KEY); + } catch (e) { + console.error('Failed to clear auth debug events', e); + } +} diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index 69c832a..37475d6 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -6,7 +6,10 @@ import { supabase } from '../../services/supabase'; import { makeRedirectUri } from 'expo-auth-session'; import * as QueryParams from 'expo-auth-session/build/QueryParams'; import * as Linking from 'expo-linking'; - +import * as WebBrowser from 'expo-web-browser'; +import { handleAuthRedirectUrl } from '../../auth/authRedirect'; +import { addAuthDebugEvent, getAuthDebugEvents, clearAuthDebugEvents } from '../../debug/authDebug'; +import { clearSpotifyTokens } from '../../auth/spotifyToken'; // @ts-ignore export default function LoginScreen({ navigation }) { const [email, setEmail] = useState(''); @@ -33,14 +36,65 @@ export default function LoginScreen({ navigation }) { setLoading(false); }; + const handleAuthDebug = async () => { + const events = await getAuthDebugEvents(); + Alert.alert('Auth Debug Events', JSON.stringify(events, null, 2)); + }; + + const handleResetAuth = async () => { + await supabase.auth.signOut(); + await clearSpotifyTokens(); + await clearAuthDebugEvents(); + Alert.alert('Reset', 'Auth state cleared.'); + }; + 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); + try { + const redirectTo = "roadtripdj://auth/callback"; + await addAuthDebugEvent({ event: 'login_button_pressed', redirectTo }); + + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'spotify', + options: { + redirectTo, + skipBrowserRedirect: true, + scopes: "user-read-email user-read-private playlist-modify-public playlist-modify-private", + queryParams: { + show_dialog: "true" + } + } + }); + + await addAuthDebugEvent({ + event: 'oauth_url_created', + success: !!data?.url, + host: data?.url ? data.url.split('?')[0] : null + }); + + if (error) { + throw error; + } + + if (data?.url) { + const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo); + await addAuthDebugEvent({ event: 'web_browser_closed', type: result.type }); + if (result.type === 'success' && result.url) { + await addAuthDebugEvent({ event: 'web_browser_success_url_received', success: true }); + await addAuthDebugEvent({ event: 'processing_web_browser_success_url' }); + + await handleAuthRedirectUrl(result.url); + + const { data: sessionData } = await supabase.auth.getSession(); + await addAuthDebugEvent({ + event: 'post_browser_getSession', + user_exists: Boolean(sessionData.session?.user), + provider_token_exists: Boolean((sessionData.session as any)?.provider_token) + }); + } + } + } catch (e: any) { + console.error('🚀 [LoginScreen] OAuth Error:', e); + Alert.alert('Erro de Autenticação', e.message); } }; @@ -103,6 +157,14 @@ export default function LoginScreen({ navigation }) { Entrar com Spotify + + Auth Debug + + + + Reset Auth + + Não tens conta? navigation.navigate('Register')}> diff --git a/src/screens/main/HomeScreen.tsx b/src/screens/main/HomeScreen.tsx index c106016..73621b1 100644 --- a/src/screens/main/HomeScreen.tsx +++ b/src/screens/main/HomeScreen.tsx @@ -1,10 +1,11 @@ -import React from 'react'; -import { View, Text, StyleSheet, ScrollView, TouchableOpacity, SafeAreaView, ImageBackground } from 'react-native'; -import { NavigationProp } from '@react-navigation/native'; -import { Navigation } from 'lucide-react-native'; // Closest to MapPin with outline -import { Clock } from 'lucide-react-native'; +import React, { useState, useCallback } from 'react'; +import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ImageBackground, Alert, ActivityIndicator } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { NavigationProp, useFocusEffect } from '@react-navigation/native'; +import { Navigation, Clock, MapPin } from 'lucide-react-native'; import { colors } from '../../utils/colors'; import { useAuth } from '../../contexts/AuthContext'; +import { supabase } from '../../services/supabase'; interface Props { navigation: NavigationProp; @@ -15,6 +16,43 @@ export default function HomeScreen({ navigation }: Props) { const userName = user?.user_metadata?.name || 'Viajante'; const initial = userName.charAt(0).toUpperCase(); + const [trips, setTrips] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchTrips = useCallback(async () => { + setLoading(true); + try { + console.log('[HomeScreen] Fetching trips. User exists:', !!user); + let query = supabase.from('trips').select('*').order('created_at', { ascending: false }); + + if (user) { + query = query.or(`user_id.eq.${user.id},user_id.is.null`); + } else { + query = query.is('user_id', null); + } + + const { data, error } = await query; + + if (error) { + console.error('[HomeScreen] Supabase error fetching trips:', error); + throw error; + } + + console.log(`[HomeScreen] Fetched ${data?.length || 0} trips`); + setTrips(data || []); + } catch (error: any) { + console.error('[HomeScreen] Error loading trips:', error.message); + } finally { + setLoading(false); + } + }, [user]); + + useFocusEffect( + useCallback(() => { + fetchTrips(); + }, [fetchTrips]) + ); + return ( @@ -27,20 +65,55 @@ export default function HomeScreen({ navigation }: Props) { - {/* Main Trip Card (Removed hardcoded data - empty state below) */} - {/* Prompt Card */} - - Pronto para a próxima? - - Descobre a melhor rota e a banda sonora{'\n'}perfeita para o caminho. - - navigation.navigate('NewTripModal')} - > - Criar Nova Viagem - - + {loading ? ( + + ) : trips.length > 0 ? ( + trips.map(trip => ( + + + + {trip.title} + + + + + {trip.distance} + + + + + {trip.duration} + + + + + + + + + + + {trip.origin} + {trip.destination} + + + + + )) + ) : ( + + Pronto para a próxima? + + Descobre a melhor rota e a banda sonora{'\n'}perfeita para o caminho. + + navigation.navigate('NewTripModal')} + > + Criar Nova Viagem + + + )} diff --git a/src/screens/main/ProfileScreen.tsx b/src/screens/main/ProfileScreen.tsx index 23694e5..46918d5 100644 --- a/src/screens/main/ProfileScreen.tsx +++ b/src/screens/main/ProfileScreen.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { View, Text, StyleSheet, SafeAreaView, TouchableOpacity, Image, ScrollView } from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, Image, ScrollView } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { Settings, Music, Map as MapIcon, Heart, LogOut } from 'lucide-react-native'; import { colors } from '../../utils/colors'; import { supabase } from '../../services/supabase'; diff --git a/src/screens/trip/NewTripScreen.tsx b/src/screens/trip/NewTripScreen.tsx index ec834ec..9d24123 100644 --- a/src/screens/trip/NewTripScreen.tsx +++ b/src/screens/trip/NewTripScreen.tsx @@ -4,6 +4,7 @@ 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 } from '../../auth/spotifyToken'; // @ts-ignore export default function NewTripScreen({ navigation }) { @@ -42,115 +43,128 @@ export default function NewTripScreen({ navigation }) { setDistance(finalDistance); setDuration(finalDuration); + let generatedPlaylistUrl = null; + 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.'); + const providerToken = await getSpotifyAccessToken(); + if (!providerToken) { + throw new Error("Spotify token missing. Please log in with Spotify again."); + } - // 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`, { + if (providerToken) { + // B. Fetch Spotify User ID + const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', { headers: { 'Authorization': `Bearer ${providerToken}` } }); - if (!recommendationsRes.ok) break; - const recommendationsData = await recommendationsRes.json(); - if (!recommendationsData.tracks || recommendationsData.tracks.length === 0) break; + const spotifyUserData = await spotifyUserRes.json(); + if (!spotifyUserData.id) throw new Error('Could not fetch Spotify User ID'); + const spotifyUserId = spotifyUserData.id; - for (const track of recommendationsData.tracks) { - if (!trackUris.includes(track.uri)) { - trackUris.push(track.uri); - accumulatedDurationMs += track.duration_ms; - if (accumulatedDurationMs >= tripDurationMs) break; + // 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; + 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++; } - 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 }) - }); + 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.error("No tracks found for genres:", seed_genres); } } else { - console.log("No tracks found for genres:", seed_genres); + console.error("Spotify token missing, skipping playlist generation."); + Alert.alert('Aviso', 'Sessão Spotify não encontrada. A viagem será guardada sem playlist.'); } - - // 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); + console.error("Error generating playlist:", playlistError); Alert.alert('Erro Playlist', 'A viagem foi calculada, mas ocorreu um erro a criar a playlist.'); } + // 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 { + Alert.alert('Sucesso!', 'Viagem calculada e guardada!'); + } + } catch (dbEx) { + console.error("Exception during DB save:", dbEx); + } + } else { // O NOSSO DETETIVE ENTRA AQUI! console.log("ERRO DA GOOGLE:", data); diff --git a/src/services/supabase.ts b/src/services/supabase.ts index 2a513e7..29c91f7 100644 --- a/src/services/supabase.ts +++ b/src/services/supabase.ts @@ -12,6 +12,7 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, { autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, + flowType: 'pkce', }, }); diff --git a/supabase/create_trips_table.sql b/supabase/create_trips_table.sql new file mode 100644 index 0000000..9818614 --- /dev/null +++ b/supabase/create_trips_table.sql @@ -0,0 +1,21 @@ +-- Drop if exists (optional, but requested robust creation) +-- DROP TABLE IF EXISTS public.trips; + +CREATE TABLE IF NOT EXISTS public.trips ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE, + title text NOT NULL, + origin text NOT NULL, + destination text NOT NULL, + distance text, + duration text, + playlist_url text, + created_at timestamptz DEFAULT now() +); + +-- Note: Depending on your Supabase settings, you might need to enable RLS: +-- ALTER TABLE public.trips ENABLE ROW LEVEL SECURITY; +-- CREATE POLICY "Users can insert their own trips." ON public.trips FOR INSERT WITH CHECK (auth.uid() = user_id); +-- CREATE POLICY "Users can view their own trips." ON public.trips FOR SELECT USING (auth.uid() = user_id); + +NOTIFY pgrst, 'reload schema';