Fix Android APK Issues Phase 2

This commit is contained in:
RoadtripDJ Dev
2026-05-17 23:36:26 +01:00
parent dedf25c51f
commit a0f11f73e8
14 changed files with 776 additions and 143 deletions

48
App.tsx
View File

@@ -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 (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#ffffff' }}>
<ActivityIndicator size="large" color="#0000ff" />
</View>
);
}
return (
<SafeAreaProvider>
<AuthProvider>
<NavigationContainer>
<AppNavigator />
</NavigationContainer>
<StatusBar style="auto" hidden={false} />
<StatusBar style="dark" translucent={false} backgroundColor="#ffffff" hidden={false} />
</AuthProvider>
</SafeAreaProvider>
);

View File

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

21
eas.json Normal file
View File

@@ -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": {}
}
}

128
package-lock.json generated
View File

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

View File

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

204
src/auth/authRedirect.ts Normal file
View File

@@ -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<string>();
export function parseOAuthParams(url: string) {
const params: Record<string, string> = {};
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;
}

47
src/auth/spotifyToken.ts Normal file
View File

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

35
src/debug/authDebug.ts Normal file
View File

@@ -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<string, any>) {
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);
}
}

View File

@@ -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 }) {
<Text style={styles.spotifyButtonText}>Entrar com Spotify</Text>
</TouchableOpacity>
<TouchableOpacity style={{ padding: 10, alignItems: 'center' }} onPress={handleAuthDebug}>
<Text style={{ color: colors.textSecondary }}>Auth Debug</Text>
</TouchableOpacity>
<TouchableOpacity style={{ padding: 10, alignItems: 'center', marginBottom: 10 }} onPress={handleResetAuth}>
<Text style={{ color: 'red' }}>Reset Auth</Text>
</TouchableOpacity>
<View style={styles.footerContainer}>
<Text style={styles.footerText}>Não tens conta? </Text>
<TouchableOpacity onPress={() => navigation.navigate('Register')}>

View File

@@ -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<any>;
@@ -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<any[]>([]);
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 (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.scrollContent}>
@@ -27,20 +65,55 @@ export default function HomeScreen({ navigation }: Props) {
</View>
</View>
{/* Main Trip Card (Removed hardcoded data - empty state below) */}
{/* Prompt Card */}
<View style={styles.promptCard}>
<Text style={styles.promptTitle}>Pronto para a próxima?</Text>
<Text style={styles.promptSubtitle}>
Descobre a melhor rota e a banda sonora{'\n'}perfeita para o caminho.
</Text>
<TouchableOpacity
style={styles.promptButton}
onPress={() => navigation.navigate('NewTripModal')}
>
<Text style={styles.promptButtonText}>Criar Nova Viagem</Text>
</TouchableOpacity>
</View>
{loading ? (
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
) : trips.length > 0 ? (
trips.map(trip => (
<View key={trip.id} style={styles.mainTripCard}>
<View style={[styles.tripImage, { backgroundColor: colors.inputBackground, borderTopLeftRadius: 24, borderTopRightRadius: 24 }]} />
<View style={styles.tripContent}>
<Text style={styles.tripTitle}>{trip.title}</Text>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Navigation color={colors.primary} size={16} />
<Text style={styles.statText}>{trip.distance}</Text>
</View>
<View style={styles.statDot} />
<View style={styles.statItem}>
<Clock color={colors.primary} size={16} />
<Text style={styles.statText}>{trip.duration}</Text>
</View>
</View>
<View style={styles.itinerarySnippet}>
<View style={styles.timeline}>
<View style={styles.dot} />
<View style={styles.line} />
<View style={[styles.dot, styles.dotEmpty]} />
</View>
<View style={styles.locations}>
<Text style={styles.locationText}>{trip.origin}</Text>
<Text style={styles.locationText}>{trip.destination}</Text>
</View>
</View>
</View>
</View>
))
) : (
<View style={styles.promptCard}>
<Text style={styles.promptTitle}>Pronto para a próxima?</Text>
<Text style={styles.promptSubtitle}>
Descobre a melhor rota e a banda sonora{'\n'}perfeita para o caminho.
</Text>
<TouchableOpacity
style={styles.promptButton}
onPress={() => navigation.navigate('NewTripModal')}
>
<Text style={styles.promptButtonText}>Criar Nova Viagem</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
</SafeAreaView>

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
flowType: 'pkce',
},
});

View File

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