mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
minor Ui fixes.
This commit is contained in:
parent
86f0fde656
commit
a8fa2183ee
9 changed files with 607 additions and 79 deletions
120
src/components/common/ToastOverlay.tsx
Normal file
120
src/components/common/ToastOverlay.tsx
Normal 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;
|
||||
|
||||
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
324
src/screens/AccountManageScreen.tsx
Normal file
324
src/screens/AccountManageScreen.tsx
Normal 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;
|
||||
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' }] });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue