- 更新默认药品图片为专用图标 - 移除未使用的 loading 状态选择器 - 优化 Apple 登录按钮样式,支持毛玻璃效果和加载状态 - 添加登录成功后返回功能(shouldBack 参数) - 药品详情页添加信息卡片点击交互 - 添加药品添加页面的登录状态检查 - 增强时间选择器错误处理和数据验证 - 修复药品图片显示逻辑,支持网络图片 - 优化药品卡片样式和布局 - 添加图片加载错误处理
478 lines
16 KiB
TypeScript
478 lines
16 KiB
TypeScript
import { Ionicons } from '@expo/vector-icons';
|
||
import * as AppleAuthentication from 'expo-apple-authentication';
|
||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||
import { LinearGradient } from 'expo-linear-gradient';
|
||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { ActivityIndicator, Alert, Animated, Linking, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
|
||
import { ThemedText } from '@/components/ThemedText';
|
||
import { ThemedView } from '@/components/ThemedView';
|
||
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { useAppDispatch } from '@/hooks/redux';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
import { fetchMyProfile, login } from '@/store/userSlice';
|
||
import Toast from 'react-native-toast-message';
|
||
|
||
export default function LoginScreen() {
|
||
const router = useRouter();
|
||
const searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string; shouldBack?: string }>();
|
||
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||
const color = Colors[scheme];
|
||
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
|
||
const dispatch = useAppDispatch();
|
||
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
|
||
|
||
// 背景动效:轻微平移/旋转与呼吸动画
|
||
const translateAnim = useRef(new Animated.Value(0)).current;
|
||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||
const pulseAnimA = useRef(new Animated.Value(0)).current;
|
||
const pulseAnimB = useRef(new Animated.Value(0)).current;
|
||
|
||
useEffect(() => {
|
||
const loopTranslate = Animated.loop(
|
||
Animated.sequence([
|
||
Animated.timing(translateAnim, { toValue: 1, duration: 6000, useNativeDriver: true }),
|
||
Animated.timing(translateAnim, { toValue: 0, duration: 6000, useNativeDriver: true }),
|
||
])
|
||
);
|
||
const loopRotate = Animated.loop(
|
||
Animated.sequence([
|
||
Animated.timing(rotateAnim, { toValue: 1, duration: 10000, useNativeDriver: true }),
|
||
Animated.timing(rotateAnim, { toValue: 0, duration: 10000, useNativeDriver: true }),
|
||
])
|
||
);
|
||
const loopPulseA = Animated.loop(
|
||
Animated.sequence([
|
||
Animated.timing(pulseAnimA, { toValue: 1, duration: 3500, useNativeDriver: true }),
|
||
Animated.timing(pulseAnimA, { toValue: 0, duration: 3500, useNativeDriver: true }),
|
||
])
|
||
);
|
||
const loopPulseB = Animated.loop(
|
||
Animated.sequence([
|
||
Animated.timing(pulseAnimB, { toValue: 1, duration: 4200, useNativeDriver: true }),
|
||
Animated.timing(pulseAnimB, { toValue: 0, duration: 4200, useNativeDriver: true }),
|
||
])
|
||
);
|
||
loopTranslate.start();
|
||
loopRotate.start();
|
||
loopPulseA.start();
|
||
loopPulseB.start();
|
||
return () => {
|
||
loopTranslate.stop();
|
||
loopRotate.stop();
|
||
loopPulseA.stop();
|
||
loopPulseB.stop();
|
||
};
|
||
}, [pulseAnimA, pulseAnimB, rotateAnim, translateAnim]);
|
||
|
||
const [hasAgreed, setHasAgreed] = useState<boolean>(false);
|
||
const [appleAvailable, setAppleAvailable] = useState<boolean>(false);
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
|
||
useEffect(() => {
|
||
AppleAuthentication.isAvailableAsync().then(setAppleAvailable).catch(() => setAppleAvailable(false));
|
||
}, []);
|
||
|
||
const guardAgreement = useCallback((action: () => void) => {
|
||
if (!hasAgreed) {
|
||
Alert.alert(
|
||
'请先阅读并同意',
|
||
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。',
|
||
[
|
||
{ text: '取消', style: 'cancel' },
|
||
{
|
||
text: '同意并继续',
|
||
onPress: () => {
|
||
setHasAgreed(true);
|
||
setTimeout(() => action(), 0);
|
||
},
|
||
},
|
||
],
|
||
{ cancelable: true }
|
||
);
|
||
return;
|
||
}
|
||
action();
|
||
}, [hasAgreed]);
|
||
|
||
const onAppleLogin = useCallback(async () => {
|
||
if (!appleAvailable) return;
|
||
try {
|
||
setLoading(true);
|
||
const credential = await AppleAuthentication.signInAsync({
|
||
requestedScopes: [
|
||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||
],
|
||
});
|
||
const identityToken = (credential as any)?.identityToken;
|
||
if (!identityToken || typeof identityToken !== 'string') {
|
||
throw new Error('未获取到 Apple 身份令牌');
|
||
}
|
||
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
||
|
||
// 拉取用户信息
|
||
await dispatch(fetchMyProfile())
|
||
|
||
Toast.show({
|
||
text1: '登录成功',
|
||
type: 'success',
|
||
});
|
||
// 登录成功后处理重定向
|
||
const shouldBack = searchParams?.shouldBack === 'true';
|
||
|
||
if (shouldBack) {
|
||
// 如果设置了 shouldBack,直接返回上一页
|
||
router.back();
|
||
} else {
|
||
// 否则按照原有逻辑进行重定向
|
||
const to = searchParams?.redirectTo as string | undefined;
|
||
const paramsJson = searchParams?.redirectParams as string | undefined;
|
||
let parsedParams: Record<string, any> | undefined;
|
||
if (paramsJson) {
|
||
try { parsedParams = JSON.parse(paramsJson); } catch { }
|
||
}
|
||
if (to) {
|
||
router.replace({ pathname: to, params: parsedParams } as any);
|
||
} else {
|
||
router.back();
|
||
}
|
||
}
|
||
} catch (err: any) {
|
||
console.log('err.code', err.code);
|
||
|
||
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
|
||
const message = err?.message || '登录失败,请稍后再试';
|
||
Alert.alert('登录失败', message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]);
|
||
|
||
|
||
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
|
||
|
||
return (
|
||
<SafeAreaView edges={['top']} style={[styles.safeArea, { backgroundColor: pageBackground }]}>
|
||
<ThemedView style={[styles.container, { backgroundColor: pageBackground }]}>
|
||
{/* 动态背景层(置于内容之下) */}
|
||
<View pointerEvents="none" style={styles.bgWrap}>
|
||
{/* 基础全屏渐变:保证覆盖全屏 */}
|
||
<AnimatedLinear
|
||
colors={
|
||
scheme === 'light'
|
||
? [color.pageBackgroundEmphasis, color.heroSurfaceTint, color.surface]
|
||
: [color.background, '#0F1112', color.surface]
|
||
}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 1 }}
|
||
style={[styles.bgGradientFull]}
|
||
/>
|
||
|
||
{/* 次级大面积渐变:对角线方向形成层次 */}
|
||
<AnimatedLinear
|
||
colors={
|
||
scheme === 'light'
|
||
? ['rgba(164,138,237,0.12)', 'rgba(187,242,70,0.16)', 'transparent']
|
||
: ['rgba(164,138,237,0.16)', 'rgba(187,242,70,0.12)', 'transparent']
|
||
}
|
||
start={{ x: 1, y: 0 }}
|
||
end={{ x: 0, y: 1 }}
|
||
style={[
|
||
styles.bgGradientCover,
|
||
{
|
||
transform: [
|
||
{
|
||
rotate: rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['-4deg', '6deg'] }),
|
||
},
|
||
],
|
||
opacity: scheme === 'light' ? 0.9 : 0.65,
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{/* 动感色块 A(主色呼吸,置于左下) */}
|
||
<Animated.View
|
||
style={[
|
||
styles.accentBlobLarge,
|
||
{
|
||
backgroundColor: color.ornamentPrimary,
|
||
transform: [
|
||
{ translateX: -80 },
|
||
{ translateY: 320 },
|
||
{ scale: pulseAnimA.interpolate({ inputRange: [0, 1], outputRange: [1, 1.05] }) },
|
||
],
|
||
opacity: scheme === 'light' ? 0.55 : 0.4,
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{/* 动感色块 B(辅色漂移,置于右上) */}
|
||
<Animated.View
|
||
style={[
|
||
styles.accentBlobMedium,
|
||
{
|
||
backgroundColor: color.ornamentAccent,
|
||
transform: [
|
||
{ translateX: 240 },
|
||
{ translateY: -40 },
|
||
{ scale: pulseAnimB.interpolate({ inputRange: [0, 1], outputRange: [1, 1.07] }) },
|
||
],
|
||
opacity: scheme === 'light' ? 0.5 : 0.38,
|
||
},
|
||
]}
|
||
/>
|
||
</View>
|
||
{/* 自定义头部,与其它页面风格一致 */}
|
||
<View style={styles.header}>
|
||
{isLiquidGlassAvailable() ? (
|
||
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} activeOpacity={0.7}>
|
||
<GlassView
|
||
style={styles.backButton}
|
||
glassEffectStyle="clear"
|
||
tintColor="rgba(255, 255, 255, 0.2)"
|
||
isInteractive={true}
|
||
>
|
||
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
|
||
</GlassView>
|
||
</TouchableOpacity>
|
||
) : (
|
||
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={[styles.backButton, styles.fallbackBackground]} activeOpacity={0.7}>
|
||
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
|
||
</TouchableOpacity>
|
||
)}
|
||
<Text style={[styles.headerTitle, { color: color.text }]}>登录</Text>
|
||
<View style={{ width: 32 }} />
|
||
</View>
|
||
|
||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||
<View style={styles.headerWrap}>
|
||
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
|
||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>健康生活,自律让我更自由</ThemedText>
|
||
</View>
|
||
|
||
{/* Apple 登录 */}
|
||
{appleAvailable && (
|
||
<TouchableOpacity
|
||
accessibilityRole="button"
|
||
onPress={() => guardAgreement(onAppleLogin)}
|
||
disabled={loading}
|
||
activeOpacity={0.7}
|
||
>
|
||
{isLiquidGlassAvailable() ? (
|
||
<GlassView
|
||
style={styles.appleButton}
|
||
glassEffectStyle="regular"
|
||
tintColor="rgba(0, 0, 0, 0.8)"
|
||
isInteractive={true}
|
||
>
|
||
{loading ? (
|
||
<>
|
||
<ActivityIndicator
|
||
size="small"
|
||
color="#FFFFFF"
|
||
style={{ marginRight: 10 }}
|
||
/>
|
||
<Text style={styles.appleText}>登录中...</Text>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
|
||
<Text style={styles.appleText}>使用 Apple 登录</Text>
|
||
</>
|
||
)}
|
||
</GlassView>
|
||
) : (
|
||
<View style={[styles.appleButton, styles.appleButtonFallback, loading && { opacity: 0.7 }]}>
|
||
{loading ? (
|
||
<>
|
||
<ActivityIndicator
|
||
size="small"
|
||
color="#FFFFFF"
|
||
style={{ marginRight: 10 }}
|
||
/>
|
||
<Text style={styles.appleText}>登录中...</Text>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
|
||
<Text style={styles.appleText}>使用 Apple 登录</Text>
|
||
</>
|
||
)}
|
||
</View>
|
||
)}
|
||
</TouchableOpacity>
|
||
)}
|
||
|
||
{/* 协议勾选 */}
|
||
<View style={styles.agreementRow}>
|
||
<Pressable onPress={() => setHasAgreed((v) => !v)} style={styles.checkboxWrap} accessibilityRole="checkbox" accessibilityState={{ checked: hasAgreed }}>
|
||
<View
|
||
style={[styles.checkbox, {
|
||
backgroundColor: hasAgreed ? color.primary : 'transparent',
|
||
borderColor: hasAgreed ? color.primary : color.border,
|
||
}]}
|
||
>
|
||
{hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />}
|
||
</View>
|
||
</Pressable>
|
||
<Text style={[styles.agreementText, { color: color.textMuted }]}>我已阅读并同意</Text>
|
||
<Pressable onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
|
||
<Text style={[styles.link, { color: color.primary }]}>《隐私政策》</Text>
|
||
</Pressable>
|
||
<Text style={[styles.agreementText, { color: color.textMuted }]}>和</Text>
|
||
<Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}>
|
||
<Text style={[styles.link, { color: color.primary }]}>《用户协议》</Text>
|
||
</Pressable>
|
||
</View>
|
||
|
||
{/* 占位底部间距 */}
|
||
<View style={{ height: 40 }} />
|
||
</ScrollView>
|
||
</ThemedView>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
safeArea: { flex: 1 },
|
||
container: { flex: 1 },
|
||
content: {
|
||
flexGrow: 1,
|
||
paddingHorizontal: 24,
|
||
justifyContent: 'center',
|
||
},
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 16,
|
||
paddingTop: 4,
|
||
paddingBottom: 8,
|
||
},
|
||
backButton: {
|
||
width: 38,
|
||
height: 38,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
borderRadius: 38,
|
||
overflow: 'hidden',
|
||
},
|
||
fallbackBackground: {
|
||
backgroundColor: 'rgba(255, 255, 255, 0.5)',
|
||
},
|
||
headerTitle: { fontSize: 18, fontWeight: '700' },
|
||
headerWrap: {
|
||
marginBottom: 36,
|
||
},
|
||
title: {
|
||
fontSize: 32,
|
||
fontWeight: '500',
|
||
letterSpacing: 0.5,
|
||
lineHeight: 38,
|
||
},
|
||
subtitle: {
|
||
marginTop: 8,
|
||
fontSize: 14,
|
||
fontWeight: '500',
|
||
},
|
||
appleButton: {
|
||
height: 56,
|
||
borderRadius: 28,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
flexDirection: 'row',
|
||
marginBottom: 16,
|
||
overflow: 'hidden',
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 8 },
|
||
shadowOpacity: 0.15,
|
||
shadowRadius: 12,
|
||
elevation: 2,
|
||
},
|
||
appleButtonFallback: {
|
||
backgroundColor: '#000000',
|
||
},
|
||
appleText: {
|
||
fontSize: 16,
|
||
color: '#FFFFFF',
|
||
fontWeight: '600',
|
||
},
|
||
guestButton: {
|
||
height: 52,
|
||
borderRadius: 26,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
flexDirection: 'row',
|
||
borderWidth: 1,
|
||
marginTop: 6,
|
||
},
|
||
guestText: {
|
||
fontSize: 15,
|
||
fontWeight: '500',
|
||
},
|
||
agreementRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
flexWrap: 'wrap',
|
||
marginTop: 24,
|
||
},
|
||
checkboxWrap: { marginRight: 8 },
|
||
checkbox: {
|
||
width: 18,
|
||
height: 18,
|
||
borderRadius: 5,
|
||
borderWidth: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
agreementText: { fontSize: 12 },
|
||
link: { fontSize: 12, fontWeight: '600' },
|
||
footerHint: { marginTop: 24 },
|
||
hintText: { fontSize: 12 },
|
||
// 背景样式
|
||
bgWrap: {
|
||
...StyleSheet.absoluteFillObject,
|
||
zIndex: 0,
|
||
},
|
||
bgGradientFull: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
top: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
},
|
||
bgGradientCover: {
|
||
position: 'absolute',
|
||
left: '-10%',
|
||
top: '-15%',
|
||
width: '130%',
|
||
height: '70%',
|
||
borderBottomLeftRadius: 36,
|
||
borderBottomRightRadius: 36,
|
||
},
|
||
accentBlob: {
|
||
position: 'absolute',
|
||
width: 180,
|
||
height: 180,
|
||
borderRadius: 90,
|
||
},
|
||
accentBlobLarge: {
|
||
position: 'absolute',
|
||
width: 260,
|
||
height: 260,
|
||
borderRadius: 130,
|
||
},
|
||
accentBlobMedium: {
|
||
position: 'absolute',
|
||
width: 180,
|
||
height: 180,
|
||
borderRadius: 90,
|
||
},
|
||
});
|
||
|
||
|