mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-11 20:40:42 +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>;
|
signIn: (email: string, password: string) => Promise<string | null>;
|
||||||
signUp: (email: string, password: string) => Promise<string | null>;
|
signUp: (email: string, password: string) => Promise<string | null>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
|
updateProfile: (partial: { avatarUrl?: string; displayName?: string }) => Promise<string | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AccountContext = createContext<AccountContextValue | undefined>(undefined);
|
const AccountContext = createContext<AccountContextValue | undefined>(undefined);
|
||||||
|
|
@ -18,7 +19,7 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initial session
|
// Initial session (load full profile)
|
||||||
(async () => {
|
(async () => {
|
||||||
const u = await accountService.getCurrentUser();
|
const u = await accountService.getCurrentUser();
|
||||||
setUser(u);
|
setUser(u);
|
||||||
|
|
@ -27,24 +28,22 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||||
if (u) {
|
if (u) {
|
||||||
await syncService.migrateLocalScopeToUser();
|
await syncService.migrateLocalScopeToUser();
|
||||||
await syncService.subscribeRealtime();
|
await syncService.subscribeRealtime();
|
||||||
await Promise.all([
|
// Pull first to hydrate local state, then push to avoid wiping server with empty local
|
||||||
syncService.fullPull(),
|
await syncService.fullPull();
|
||||||
syncService.fullPush(),
|
await syncService.fullPush();
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Auth state listener
|
// Auth state listener
|
||||||
const { data: subscription } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
const { data: subscription } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
||||||
const u = session?.user ? { id: session.user.id, email: session.user.email ?? undefined } : null;
|
const fullUser = session?.user ? await accountService.getCurrentUser() : null;
|
||||||
setUser(u);
|
setUser(fullUser);
|
||||||
if (u) {
|
if (fullUser) {
|
||||||
await syncService.migrateLocalScopeToUser();
|
await syncService.migrateLocalScopeToUser();
|
||||||
await syncService.subscribeRealtime();
|
await syncService.subscribeRealtime();
|
||||||
await Promise.all([
|
// Pull first to hydrate local state, then push to avoid wiping server with empty local
|
||||||
syncService.fullPull(),
|
await syncService.fullPull();
|
||||||
syncService.fullPush(),
|
await syncService.fullPush();
|
||||||
]);
|
|
||||||
} else {
|
} else {
|
||||||
syncService.unsubscribeRealtime();
|
syncService.unsubscribeRealtime();
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +68,15 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||||
signOut: async () => {
|
signOut: async () => {
|
||||||
await accountService.signOut();
|
await accountService.signOut();
|
||||||
setUser(null);
|
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]);
|
}), [user, loading]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import ThemeScreen from '../screens/ThemeScreen';
|
||||||
import ProfilesScreen from '../screens/ProfilesScreen';
|
import ProfilesScreen from '../screens/ProfilesScreen';
|
||||||
import OnboardingScreen from '../screens/OnboardingScreen';
|
import OnboardingScreen from '../screens/OnboardingScreen';
|
||||||
import AuthScreen from '../screens/AuthScreen';
|
import AuthScreen from '../screens/AuthScreen';
|
||||||
|
import AccountManageScreen from '../screens/AccountManageScreen';
|
||||||
import { AccountProvider, useAccount } from '../contexts/AccountContext';
|
import { AccountProvider, useAccount } from '../contexts/AccountContext';
|
||||||
import PluginsScreen from '../screens/PluginsScreen';
|
import PluginsScreen from '../screens/PluginsScreen';
|
||||||
|
|
||||||
|
|
@ -93,6 +94,7 @@ export type RootStackParamList = {
|
||||||
Credits: { mediaId: string; mediaType: string };
|
Credits: { mediaId: string; mediaType: string };
|
||||||
ShowRatings: { showId: number };
|
ShowRatings: { showId: number };
|
||||||
Account: undefined;
|
Account: undefined;
|
||||||
|
AccountManage: undefined;
|
||||||
Payment: undefined;
|
Payment: undefined;
|
||||||
PrivacyPolicy: undefined;
|
PrivacyPolicy: undefined;
|
||||||
About: 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
|
<Stack.Screen
|
||||||
name="Metadata"
|
name="Metadata"
|
||||||
component={MetadataScreen}
|
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 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 { 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 { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { useAccount } from '../contexts/AccountContext';
|
import { useAccount } from '../contexts/AccountContext';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import ToastOverlay from '../components/common/ToastOverlay';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
@ -35,11 +37,10 @@ const AuthScreen: React.FC = () => {
|
||||||
const ctaTextTranslateY = useRef(new Animated.Value(0)).current;
|
const ctaTextTranslateY = useRef(new Animated.Value(0)).current;
|
||||||
const modeAnim = useRef(new Animated.Value(0)).current; // 0 = signin, 1 = signup
|
const modeAnim = useRef(new Animated.Value(0)).current; // 0 = signin, 1 = signup
|
||||||
const [switchWidth, setSwitchWidth] = useState(0);
|
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 [toast, setToast] = useState<{ visible: boolean; message: string; type: 'success' | 'error' | 'info' }>({ visible: false, message: '', type: 'info' });
|
||||||
const [headerHeight, setHeaderHeight] = useState(0);
|
const [headerHeight, setHeaderHeight] = useState(0);
|
||||||
const headerHideAnim = useRef(new Animated.Value(0)).current; // 0 visible, 1 hidden
|
const headerHideAnim = useRef(new Animated.Value(0)).current; // 0 visible, 1 hidden
|
||||||
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
|
|
@ -101,7 +102,9 @@ const AuthScreen: React.FC = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||||
const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||||
const onShow = () => {
|
const onShow = (e: any) => {
|
||||||
|
const kh = e?.endCoordinates?.height ?? 0;
|
||||||
|
setKeyboardHeight(kh);
|
||||||
Animated.timing(headerHideAnim, {
|
Animated.timing(headerHideAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 180,
|
duration: 180,
|
||||||
|
|
@ -110,6 +113,7 @@ const AuthScreen: React.FC = () => {
|
||||||
}).start();
|
}).start();
|
||||||
};
|
};
|
||||||
const onHide = () => {
|
const onHide = () => {
|
||||||
|
setKeyboardHeight(0);
|
||||||
Animated.timing(headerHideAnim, {
|
Animated.timing(headerHideAnim, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
duration: 180,
|
duration: 180,
|
||||||
|
|
@ -117,8 +121,8 @@ const AuthScreen: React.FC = () => {
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
};
|
};
|
||||||
const subShow = Keyboard.addListener(showEvt, onShow);
|
const subShow = Keyboard.addListener(showEvt, onShow as any);
|
||||||
const subHide = Keyboard.addListener(hideEvt, onHide);
|
const subHide = Keyboard.addListener(hideEvt, onHide as any);
|
||||||
return () => {
|
return () => {
|
||||||
subShow.remove();
|
subShow.remove();
|
||||||
subHide.remove();
|
subHide.remove();
|
||||||
|
|
@ -160,21 +164,15 @@ const AuthScreen: React.FC = () => {
|
||||||
setLoading(false);
|
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') => {
|
const showToast = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
||||||
setToast({ visible: true, message, type });
|
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 (
|
return (
|
||||||
|
|
@ -443,24 +441,30 @@ const AuthScreen: React.FC = () => {
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</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>
|
</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>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</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>
|
</SafeAreaView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
@ -619,23 +623,6 @@ const styles = StyleSheet.create({
|
||||||
left: 16,
|
left: 16,
|
||||||
top: 8,
|
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: {
|
switchModeText: {
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ import { useTheme } from '../contexts/ThemeContext';
|
||||||
import type { Theme } from '../contexts/ThemeContext';
|
import type { Theme } from '../contexts/ThemeContext';
|
||||||
import * as ScreenOrientation from 'expo-screen-orientation';
|
import * as ScreenOrientation from 'expo-screen-orientation';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
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 FirstTimeWelcome from '../components/FirstTimeWelcome';
|
||||||
import { imageCacheService } from '../services/imageCacheService';
|
import { imageCacheService } from '../services/imageCacheService';
|
||||||
|
|
||||||
|
|
@ -122,8 +124,10 @@ const HomeScreen = () => {
|
||||||
const [catalogsLoading, setCatalogsLoading] = useState(true);
|
const [catalogsLoading, setCatalogsLoading] = useState(true);
|
||||||
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
|
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
|
||||||
const [hasAddons, setHasAddons] = useState<boolean | null>(null);
|
const [hasAddons, setHasAddons] = useState<boolean | null>(null);
|
||||||
|
const [hintVisible, setHintVisible] = useState(false);
|
||||||
const totalCatalogsRef = useRef(0);
|
const totalCatalogsRef = useRef(0);
|
||||||
const [visibleCatalogCount, setVisibleCatalogCount] = useState(8); // Moderate number of visible catalogs
|
const [visibleCatalogCount, setVisibleCatalogCount] = useState(8); // Moderate number of visible catalogs
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
featuredContent,
|
featuredContent,
|
||||||
|
|
@ -307,6 +311,24 @@ const HomeScreen = () => {
|
||||||
loadCatalogsProgressively();
|
loadCatalogsProgressively();
|
||||||
}, [lastUpdate, 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
|
// Create a refresh function for catalogs
|
||||||
const refreshCatalogs = useCallback(() => {
|
const refreshCatalogs = useCallback(() => {
|
||||||
return loadCatalogsProgressively();
|
return loadCatalogsProgressively();
|
||||||
|
|
@ -677,7 +699,7 @@ const HomeScreen = () => {
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
barStyle="light-content"
|
barStyle="light-content"
|
||||||
backgroundColor="transparent"
|
backgroundColor="transparent"
|
||||||
|
|
@ -706,6 +728,15 @@ const HomeScreen = () => {
|
||||||
disableIntervalMomentum={true}
|
disableIntervalMomentum={true}
|
||||||
scrollEventThrottle={16}
|
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>
|
</View>
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
|
|
@ -860,6 +891,22 @@ const styles = StyleSheet.create<any>({
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '600',
|
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: {
|
loadingContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import Animated, {
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||||
|
import { useAccount } from '../contexts/AccountContext';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
|
@ -75,6 +76,7 @@ const onboardingData: OnboardingSlide[] = [
|
||||||
const OnboardingScreen = () => {
|
const OnboardingScreen = () => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
const { user } = useAccount();
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const progressValue = useSharedValue(0);
|
const progressValue = useSharedValue(0);
|
||||||
|
|
@ -95,22 +97,28 @@ const OnboardingScreen = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSkip = () => {
|
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 () => {
|
const handleGetStarted = async () => {
|
||||||
try {
|
try {
|
||||||
await AsyncStorage.setItem('hasCompletedOnboarding', 'true');
|
await AsyncStorage.setItem('hasCompletedOnboarding', 'true');
|
||||||
navigation.reset({
|
// After onboarding, route to login if no user; otherwise go to app
|
||||||
index: 0,
|
if (!user) {
|
||||||
routes: [{ name: 'MainTabs' }],
|
navigation.reset({ index: 0, routes: [{ name: 'Account' }] });
|
||||||
});
|
} else {
|
||||||
|
navigation.reset({ index: 0, routes: [{ name: 'MainTabs' }] });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving onboarding status:', error);
|
console.error('Error saving onboarding status:', error);
|
||||||
navigation.reset({
|
navigation.reset({ index: 0, routes: [{ name: user ? 'MainTabs' : 'Account' }] });
|
||||||
index: 0,
|
|
||||||
routes: [{ name: 'MainTabs' }],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -300,14 +300,10 @@ const SettingsScreen: React.FC = () => {
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title={user.email || user.id}
|
title={user.displayName || user.email || user.id}
|
||||||
description="Signed in"
|
description="Manage account"
|
||||||
icon="account-circle"
|
icon="account-circle"
|
||||||
/>
|
onPress={() => navigation.navigate('AccountManage')}
|
||||||
<SettingItem
|
|
||||||
title="Sign out"
|
|
||||||
icon="logout"
|
|
||||||
onPress={signOut}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import supabase from './supabaseClient';
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
id: string;
|
id: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
displayName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const USER_SCOPE_KEY = '@user:current';
|
const USER_SCOPE_KEY = '@user:current';
|
||||||
|
|
@ -22,6 +24,10 @@ class AccountService {
|
||||||
if (error) return { error: error.message };
|
if (error) return { error: error.message };
|
||||||
const user = data.user ? { id: data.user.id, email: data.user.email ?? undefined } : undefined;
|
const user = data.user ? { id: data.user.id, email: data.user.email ?? undefined } : undefined;
|
||||||
if (user) await AsyncStorage.setItem(USER_SCOPE_KEY, user.id);
|
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 };
|
return { user };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +48,25 @@ class AccountService {
|
||||||
const { data } = await supabase.auth.getUser();
|
const { data } = await supabase.auth.getUser();
|
||||||
const u = data.user;
|
const u = data.user;
|
||||||
if (!u) return null;
|
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> {
|
async getCurrentUserIdScoped(): Promise<string> {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue