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';