minor Ui fixes.

This commit is contained in:
tapframe 2025-08-09 00:51:12 +05:30
parent 86f0fde656
commit a8fa2183ee
9 changed files with 607 additions and 79 deletions

View file

@ -0,0 +1,120 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { Animated, Easing, StyleSheet, Text, ViewStyle } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export type ToastType = 'success' | 'error' | 'info';
type Props = {
visible: boolean;
message: string;
type?: ToastType;
duration?: number; // ms
onHide?: () => void;
bottomOffset?: number; // extra offset above safe area / tab bar
containerStyle?: ViewStyle;
};
const colorsByType: Record<ToastType, string> = {
success: 'rgba(46,160,67,0.95)',
error: 'rgba(229, 62, 62, 0.95)',
info: 'rgba(99, 102, 241, 0.95)',
};
export const ToastOverlay: React.FC<Props> = ({
visible,
message,
type = 'info',
duration = 1800,
onHide,
bottomOffset = 90,
containerStyle,
}) => {
const insets = useSafeAreaInsets();
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(12)).current;
const hideTimer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (visible) {
// clear any existing timer
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
opacity.setValue(0);
translateY.setValue(12);
Animated.parallel([
Animated.timing(opacity, { toValue: 1, duration: 160, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(translateY, { toValue: 0, duration: 160, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
]).start(() => {
hideTimer.current = setTimeout(() => {
Animated.parallel([
Animated.timing(opacity, { toValue: 0, duration: 160, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(translateY, { toValue: 12, duration: 160, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
]).start(() => {
if (onHide) onHide();
});
}, Math.max(800, duration));
});
} else {
// If toggled off externally, hide instantly
Animated.parallel([
Animated.timing(opacity, { toValue: 0, duration: 120, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(translateY, { toValue: 12, duration: 120, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
]).start();
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
}
return () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
};
}, [visible, duration, onHide, opacity, translateY]);
const bg = useMemo(() => colorsByType[type], [type]);
const bottom = (insets?.bottom || 0) + bottomOffset;
if (!visible && opacity.__getValue() === 0) {
// Avoid mounting when fully hidden to minimize layout cost
return null;
}
return (
<Animated.View
pointerEvents="none"
style={[styles.container, { bottom }, containerStyle, { opacity, transform: [{ translateY }] }]}
>
<Text style={[styles.text, { backgroundColor: bg }]} numberOfLines={3}>
{message}
</Text>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
left: 16,
right: 16,
zIndex: 999,
},
text: {
color: '#fff',
fontWeight: '700',
textAlign: 'center',
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 12,
overflow: 'hidden',
fontSize: 12,
},
});
export default ToastOverlay;

View file

@ -9,6 +9,7 @@ type AccountContextValue = {
signIn: (email: string, password: string) => Promise<string | null>;
signUp: (email: string, password: string) => Promise<string | null>;
signOut: () => Promise<void>;
updateProfile: (partial: { avatarUrl?: string; displayName?: string }) => Promise<string | null>;
};
const AccountContext = createContext<AccountContextValue | undefined>(undefined);
@ -18,7 +19,7 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child
const [loading, setLoading] = useState(true);
useEffect(() => {
// Initial session
// Initial session (load full profile)
(async () => {
const u = await accountService.getCurrentUser();
setUser(u);
@ -27,24 +28,22 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child
if (u) {
await syncService.migrateLocalScopeToUser();
await syncService.subscribeRealtime();
await Promise.all([
syncService.fullPull(),
syncService.fullPush(),
]);
// Pull first to hydrate local state, then push to avoid wiping server with empty local
await syncService.fullPull();
await syncService.fullPush();
}
})();
// Auth state listener
const { data: subscription } = supabase.auth.onAuthStateChange(async (_event, session) => {
const u = session?.user ? { id: session.user.id, email: session.user.email ?? undefined } : null;
setUser(u);
if (u) {
const fullUser = session?.user ? await accountService.getCurrentUser() : null;
setUser(fullUser);
if (fullUser) {
await syncService.migrateLocalScopeToUser();
await syncService.subscribeRealtime();
await Promise.all([
syncService.fullPull(),
syncService.fullPush(),
]);
// Pull first to hydrate local state, then push to avoid wiping server with empty local
await syncService.fullPull();
await syncService.fullPush();
} else {
syncService.unsubscribeRealtime();
}
@ -69,6 +68,15 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child
signOut: async () => {
await accountService.signOut();
setUser(null);
},
updateProfile: async (partial) => {
const err = await accountService.updateProfile(partial);
if (!err) {
// Refresh user from server to pick updated fields
const u = await accountService.getCurrentUser();
setUser(u);
}
return err;
}
}), [user, loading]);

View file

@ -40,6 +40,7 @@ import ThemeScreen from '../screens/ThemeScreen';
import ProfilesScreen from '../screens/ProfilesScreen';
import OnboardingScreen from '../screens/OnboardingScreen';
import AuthScreen from '../screens/AuthScreen';
import AccountManageScreen from '../screens/AccountManageScreen';
import { AccountProvider, useAccount } from '../contexts/AccountContext';
import PluginsScreen from '../screens/PluginsScreen';
@ -93,6 +94,7 @@ export type RootStackParamList = {
Credits: { mediaId: string; mediaType: string };
ShowRatings: { showId: number };
Account: undefined;
AccountManage: undefined;
Payment: undefined;
PrivacyPolicy: undefined;
About: undefined;
@ -748,6 +750,18 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
},
}}
/>
<Stack.Screen
name="AccountManage"
component={AccountManageScreen as any}
options={{
headerShown: false,
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="Metadata"
component={MetadataScreen}

View file

@ -0,0 +1,324 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert, StatusBar, Platform, Animated, Easing, TextInput, ActivityIndicator } from 'react-native';
import { Image } from 'expo-image';
import { useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons';
import { useAccount } from '../contexts/AccountContext';
import { useTheme } from '../contexts/ThemeContext';
import { LinearGradient } from 'expo-linear-gradient';
import * as Haptics from 'expo-haptics';
const AccountManageScreen: React.FC = () => {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { user, signOut, updateProfile } = useAccount();
const { currentTheme } = useTheme();
const headerOpacity = useRef(new Animated.Value(0)).current;
const headerTranslateY = useRef(new Animated.Value(8)).current;
const contentOpacity = useRef(new Animated.Value(0)).current;
const contentTranslateY = useRef(new Animated.Value(8)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(headerOpacity, { toValue: 1, duration: 260, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(headerTranslateY, { toValue: 0, duration: 260, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(contentOpacity, { toValue: 1, duration: 320, delay: 80, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(contentTranslateY, { toValue: 0, duration: 320, delay: 80, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
]).start();
}, [headerOpacity, headerTranslateY, contentOpacity, contentTranslateY]);
const initial = useMemo(() => (user?.email?.[0]?.toUpperCase() || 'U'), [user?.email]);
const [displayName, setDisplayName] = useState(user?.displayName || '');
const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl || '');
const [saving, setSaving] = useState(false);
const [avatarError, setAvatarError] = useState(false);
useEffect(() => {
// Reset image error state when URL changes
setAvatarError(false);
}, [avatarUrl]);
const handleSave = async () => {
if (saving) return;
setSaving(true);
const err = await updateProfile({ displayName: displayName.trim() || undefined, avatarUrl: avatarUrl.trim() || undefined });
if (err) {
Alert.alert('Error', err);
}
setSaving(false);
};
const handleSignOut = () => {
Alert.alert(
'Sign out',
'Are you sure you want to sign out?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Sign out',
style: 'destructive',
onPress: async () => {
try {
await signOut();
// Navigate back to root after sign out
// @ts-ignore
navigation.goBack();
} catch (_) {}
},
},
]
);
};
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar translucent barStyle="light-content" backgroundColor="transparent" />
{/* Header */}
<Animated.View
style={[
styles.header,
{
paddingTop: (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + 12,
opacity: headerOpacity,
transform: [{ translateY: headerTranslateY }],
},
]}
>
<LinearGradient
colors={[currentTheme.colors.darkBackground, '#111318']}
style={StyleSheet.absoluteFill}
/>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.headerBack} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<MaterialIcons name="arrow-back" size={22} color={currentTheme.colors.white} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Account</Text>
<View style={{ width: 22, height: 22 }} />
</Animated.View>
{/* Content */}
<Animated.View style={[styles.content, { opacity: contentOpacity, transform: [{ translateY: contentTranslateY }] }]}>
{/* Profile Badge */}
<View style={styles.profileContainer}>
{avatarUrl && !avatarError ? (
<View style={[styles.avatar, { overflow: 'hidden' }]}>
<Image
source={{ uri: avatarUrl }}
style={styles.avatarImage}
contentFit="cover"
onError={() => setAvatarError(true)}
/>
</View>
) : (
<View style={[styles.avatar, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={styles.avatarText}>{(displayName?.[0] || initial)}</Text>
</View>
)}
</View>
{/* Account details card */}
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
<View style={styles.itemRow}>
<View style={styles.itemLeft}>
<MaterialIcons name="badge" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.itemTitle, { color: currentTheme.colors.highEmphasis }]}>Display name</Text>
</View>
<TextInput
placeholder="Add a display name"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
style={[styles.input, { color: currentTheme.colors.white }]}
value={displayName}
onChangeText={setDisplayName}
numberOfLines={1}
/>
</View>
<View style={styles.divider} />
<View style={[styles.itemRow, Platform.OS === 'android' && styles.itemRowCompact]}>
<View style={styles.itemLeft}>
<MaterialIcons name="image" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.itemTitle, { color: currentTheme.colors.highEmphasis }]}>Avatar URL</Text>
</View>
<TextInput
placeholder="https://..."
placeholderTextColor={currentTheme.colors.mediumEmphasis}
style={[styles.input, { color: currentTheme.colors.white }]}
value={avatarUrl}
onChangeText={setAvatarUrl}
autoCapitalize="none"
numberOfLines={1}
/>
</View>
<View style={styles.divider} />
<View style={styles.itemRow}>
<View style={styles.itemLeft}>
<MaterialIcons name="account-circle" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.itemTitle, { color: currentTheme.colors.highEmphasis }]}>Email</Text>
</View>
<Text style={[styles.itemValue, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={1}>
{user?.email || '—'}
</Text>
</View>
<View style={styles.divider} />
<View style={styles.itemRow}>
<View style={styles.itemLeft}>
<MaterialIcons name="fingerprint" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.itemTitle, { color: currentTheme.colors.highEmphasis }]}>User ID</Text>
</View>
<Text style={[styles.itemValue, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={1}>
{user?.id}
</Text>
</View>
</View>
{/* Save and Sign out */}
<TouchableOpacity
activeOpacity={0.85}
style={[styles.saveButton, { backgroundColor: currentTheme.colors.elevation2, borderColor: currentTheme.colors.elevation2 }]}
onPress={handleSave}
disabled={saving}
>
{saving ? (
<ActivityIndicator color={currentTheme.colors.white} />
) : (
<>
<MaterialIcons name="save-alt" size={18} color={currentTheme.colors.white} style={{ marginRight: 8 }} />
<Text style={styles.saveText}>Save changes</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.85}
style={[
styles.signOutButton,
{ backgroundColor: currentTheme.colors.primary },
]}
onPress={handleSignOut}
>
<MaterialIcons name="logout" size={18} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.signOutText}>Sign out</Text>
</TouchableOpacity>
</Animated.View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
paddingHorizontal: 16,
paddingBottom: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerBack: {
padding: 4,
},
headerTitle: {
fontSize: 18,
fontWeight: '800',
},
content: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 10,
},
profileContainer: {
alignItems: 'center',
marginBottom: 12,
},
avatar: {
width: 72,
height: 72,
borderRadius: 36,
alignItems: 'center',
justifyContent: 'center',
},
avatarImage: {
width: '100%',
height: '100%',
},
avatarText: {
color: '#fff',
fontWeight: '800',
fontSize: 24,
},
card: {
borderRadius: 14,
borderWidth: StyleSheet.hairlineWidth,
paddingHorizontal: 14,
paddingVertical: 10,
},
itemRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
},
itemRowCompact: {
paddingVertical: 6,
},
input: {
flex: 1,
textAlign: 'right',
paddingVertical: 6,
marginLeft: 12,
},
itemLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
itemTitle: {
fontSize: 14,
fontWeight: '600',
},
itemValue: {
fontSize: 14,
maxWidth: '65%',
},
divider: {
height: StyleSheet.hairlineWidth,
backgroundColor: 'rgba(255,255,255,0.08)',
},
signOutButton: {
marginTop: 16,
height: 48,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
signOutText: {
color: '#fff',
fontWeight: '700',
},
saveButton: {
marginTop: 12,
height: 46,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
borderWidth: StyleSheet.hairlineWidth,
},
saveText: {
color: '#fff',
fontWeight: '700',
},
});
export default AccountManageScreen;

View file

@ -1,11 +1,13 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { View, TextInput, Text, TouchableOpacity, StyleSheet, ActivityIndicator, SafeAreaView, KeyboardAvoidingView, Platform, Dimensions, Animated, Easing, Keyboard } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { LinearGradient } from 'expo-linear-gradient';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext';
import { useAccount } from '../contexts/AccountContext';
import { useNavigation } from '@react-navigation/native';
import * as Haptics from 'expo-haptics';
import ToastOverlay from '../components/common/ToastOverlay';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { width, height } = Dimensions.get('window');
@ -35,11 +37,10 @@ const AuthScreen: React.FC = () => {
const ctaTextTranslateY = useRef(new Animated.Value(0)).current;
const modeAnim = useRef(new Animated.Value(0)).current; // 0 = signin, 1 = signup
const [switchWidth, setSwitchWidth] = useState(0);
const toastOpacity = useRef(new Animated.Value(0)).current;
const toastTranslateY = useRef(new Animated.Value(16)).current;
const [toast, setToast] = useState<{ visible: boolean; message: string; type: 'success' | 'error' | 'info' }>({ visible: false, message: '', type: 'info' });
const [headerHeight, setHeaderHeight] = useState(0);
const headerHideAnim = useRef(new Animated.Value(0)).current; // 0 visible, 1 hidden
const [keyboardHeight, setKeyboardHeight] = useState(0);
useEffect(() => {
Animated.parallel([
@ -101,7 +102,9 @@ const AuthScreen: React.FC = () => {
useEffect(() => {
const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const onShow = () => {
const onShow = (e: any) => {
const kh = e?.endCoordinates?.height ?? 0;
setKeyboardHeight(kh);
Animated.timing(headerHideAnim, {
toValue: 1,
duration: 180,
@ -110,6 +113,7 @@ const AuthScreen: React.FC = () => {
}).start();
};
const onHide = () => {
setKeyboardHeight(0);
Animated.timing(headerHideAnim, {
toValue: 0,
duration: 180,
@ -117,8 +121,8 @@ const AuthScreen: React.FC = () => {
useNativeDriver: true,
}).start();
};
const subShow = Keyboard.addListener(showEvt, onShow);
const subHide = Keyboard.addListener(hideEvt, onHide);
const subShow = Keyboard.addListener(showEvt, onShow as any);
const subHide = Keyboard.addListener(hideEvt, onHide as any);
return () => {
subShow.remove();
subHide.remove();
@ -160,21 +164,15 @@ const AuthScreen: React.FC = () => {
setLoading(false);
};
const handleSkipAuth = async () => {
try {
await AsyncStorage.setItem('showLoginHintToastOnce', 'true');
} catch {}
navigation.reset({ index: 0, routes: [{ name: 'MainTabs' as never }] } as any);
};
const showToast = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
setToast({ visible: true, message, type });
toastOpacity.setValue(0);
toastTranslateY.setValue(16);
Animated.parallel([
Animated.timing(toastOpacity, { toValue: 1, duration: 160, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(toastTranslateY, { toValue: 0, duration: 160, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
]).start(() => {
setTimeout(() => {
Animated.parallel([
Animated.timing(toastOpacity, { toValue: 0, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(toastTranslateY, { toValue: 16, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
]).start(() => setToast(prev => ({ ...prev, visible: false })));
}, 2200);
});
};
return (
@ -443,24 +441,30 @@ const AuthScreen: React.FC = () => {
</Text>
</Text>
</TouchableOpacity>
{/* Skip sign in */}
<TouchableOpacity
onPress={handleSkipAuth}
activeOpacity={0.7}
style={{ marginTop: 10, alignSelf: 'center' }}
>
<Text style={{ color: currentTheme.colors.textMuted, textAlign: 'center' }}>
Continue without an account
</Text>
</TouchableOpacity>
</Animated.View>
{/* Toast */}
{toast.visible && (
<Animated.View
pointerEvents="none"
style={[styles.toast, {
opacity: toastOpacity,
transform: [{ translateY: toastTranslateY }],
backgroundColor: toast.type === 'success' ? 'rgba(46,160,67,0.95)' : toast.type === 'error' ? 'rgba(229, 62, 62, 0.95)' : 'rgba(99, 102, 241, 0.95)'
}]}
>
<MaterialIcons name={toast.type === 'success' ? 'check-circle' : toast.type === 'error' ? 'error-outline' : 'info-outline'} size={16} color="#fff" />
<Text style={styles.toastText}>{toast.message}</Text>
</Animated.View>
)}
</View>
</KeyboardAvoidingView>
{/* Screen-level toast overlay so it is not clipped by the card */}
<ToastOverlay
visible={toast.visible}
message={toast.message}
type={toast.type}
duration={1600}
bottomOffset={(keyboardHeight > 0 ? keyboardHeight + 8 : 24)}
onHide={() => setToast(prev => ({ ...prev, visible: false }))}
/>
</SafeAreaView>
</View>
);
@ -619,23 +623,6 @@ const styles = StyleSheet.create({
left: 16,
top: 8,
},
toast: {
position: 'absolute',
bottom: 24,
left: 20,
right: 20,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
toastText: {
color: '#fff',
fontWeight: '600',
flex: 1,
},
switchModeText: {
textAlign: 'center',
fontSize: 14,

View file

@ -63,6 +63,8 @@ import { useTheme } from '../contexts/ThemeContext';
import type { Theme } from '../contexts/ThemeContext';
import * as ScreenOrientation from 'expo-screen-orientation';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import ToastOverlay from '../components/common/ToastOverlay';
import FirstTimeWelcome from '../components/FirstTimeWelcome';
import { imageCacheService } from '../services/imageCacheService';
@ -122,8 +124,10 @@ const HomeScreen = () => {
const [catalogsLoading, setCatalogsLoading] = useState(true);
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
const [hasAddons, setHasAddons] = useState<boolean | null>(null);
const [hintVisible, setHintVisible] = useState(false);
const totalCatalogsRef = useRef(0);
const [visibleCatalogCount, setVisibleCatalogCount] = useState(8); // Moderate number of visible catalogs
const insets = useSafeAreaInsets();
const {
featuredContent,
@ -307,6 +311,24 @@ const HomeScreen = () => {
loadCatalogsProgressively();
}, [lastUpdate, loadCatalogsProgressively]);
// One-time hint after skipping login in onboarding
useEffect(() => {
let hideTimer: any;
(async () => {
try {
const flag = await AsyncStorage.getItem('showLoginHintToastOnce');
if (flag === 'true') {
setHintVisible(true);
await AsyncStorage.removeItem('showLoginHintToastOnce');
hideTimer = setTimeout(() => setHintVisible(false), 2000);
}
} catch {}
})();
return () => {
if (hideTimer) clearTimeout(hideTimer);
};
}, []);
// Create a refresh function for catalogs
const refreshCatalogs = useCallback(() => {
return loadCatalogsProgressively();
@ -677,7 +699,7 @@ const HomeScreen = () => {
if (isLoading) return null;
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
@ -706,6 +728,15 @@ const HomeScreen = () => {
disableIntervalMomentum={true}
scrollEventThrottle={16}
/>
{/* One-time hint toast after skipping sign-in */}
<ToastOverlay
visible={hintVisible}
message="You can sign in anytime from Settings → Account"
type="info"
duration={1600}
bottomOffset={88}
onHide={() => setHintVisible(false)}
/>
</View>
);
}, [
@ -860,6 +891,22 @@ const styles = StyleSheet.create<any>({
fontSize: 14,
fontWeight: '600',
},
toast: {
position: 'absolute',
bottom: 24,
left: 16,
right: 16,
paddingVertical: 8,
paddingHorizontal: 10,
backgroundColor: 'rgba(99, 102, 241, 0.95)',
borderRadius: 12,
},
toastText: {
color: '#fff',
fontWeight: '600',
textAlign: 'center',
fontSize: 12,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',

View file

@ -23,6 +23,7 @@ import Animated, {
} from 'react-native-reanimated';
import { useTheme } from '../contexts/ThemeContext';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { useAccount } from '../contexts/AccountContext';
import { RootStackParamList } from '../navigation/AppNavigator';
import AsyncStorage from '@react-native-async-storage/async-storage';
@ -75,6 +76,7 @@ const onboardingData: OnboardingSlide[] = [
const OnboardingScreen = () => {
const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { user } = useAccount();
const [currentIndex, setCurrentIndex] = useState(0);
const flatListRef = useRef<FlatList>(null);
const progressValue = useSharedValue(0);
@ -95,22 +97,28 @@ const OnboardingScreen = () => {
};
const handleSkip = () => {
handleGetStarted();
// Skip login: proceed to app and show a one-time hint toast
(async () => {
try {
await AsyncStorage.setItem('hasCompletedOnboarding', 'true');
await AsyncStorage.setItem('showLoginHintToastOnce', 'true');
} catch {}
navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] });
})();
};
const handleGetStarted = async () => {
try {
await AsyncStorage.setItem('hasCompletedOnboarding', 'true');
navigation.reset({
index: 0,
routes: [{ name: 'MainTabs' }],
});
// After onboarding, route to login if no user; otherwise go to app
if (!user) {
navigation.reset({ index: 0, routes: [{ name: 'Account' }] });
} else {
navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] });
}
} catch (error) {
console.error('Error saving onboarding status:', error);
navigation.reset({
index: 0,
routes: [{ name: 'MainTabs' }],
});
navigation.reset({ index: 0, routes: [{ name: user ? 'MainTabs' : 'Account' }] });
}
};

View file

@ -300,14 +300,10 @@ const SettingsScreen: React.FC = () => {
{user ? (
<>
<SettingItem
title={user.email || user.id}
description="Signed in"
title={user.displayName || user.email || user.id}
description="Manage account"
icon="account-circle"
/>
<SettingItem
title="Sign out"
icon="logout"
onPress={signOut}
onPress={() => navigation.navigate('AccountManage')}
/>
</>
) : (

View file

@ -4,6 +4,8 @@ import supabase from './supabaseClient';
export type AuthUser = {
id: string;
email?: string;
avatarUrl?: string;
displayName?: string;
};
const USER_SCOPE_KEY = '@user:current';
@ -22,6 +24,10 @@ class AccountService {
if (error) return { error: error.message };
const user = data.user ? { id: data.user.id, email: data.user.email ?? undefined } : undefined;
if (user) await AsyncStorage.setItem(USER_SCOPE_KEY, user.id);
// Initialize profile row
if (user) {
await supabase.from('user_profiles').upsert({ user_id: user.id }, { onConflict: 'user_id' });
}
return { user };
}
@ -42,7 +48,25 @@ class AccountService {
const { data } = await supabase.auth.getUser();
const u = data.user;
if (!u) return null;
return { id: u.id, email: u.email ?? undefined };
// Fetch profile for avatar and display name
const { data: profile } = await supabase
.from('user_profiles')
.select('avatar_url, display_name')
.eq('user_id', u.id)
.maybeSingle();
return { id: u.id, email: u.email ?? undefined, avatarUrl: profile?.avatar_url ?? undefined, displayName: profile?.display_name ?? undefined };
}
async updateProfile(partial: { avatarUrl?: string; displayName?: string }): Promise<string | null> {
const { data } = await supabase.auth.getUser();
const userId = data.user?.id;
if (!userId) return 'Not authenticated';
const { error } = await supabase.from('user_profiles').upsert({
user_id: userId,
avatar_url: partial.avatarUrl,
display_name: partial.displayName,
}, { onConflict: 'user_id' });
return error?.message ?? null;
}
async getCurrentUserIdScoped(): Promise<string> {