Initial commit: Expo app with Better Auth integration
- Complete Expo React Native app setup with TypeScript - Better Auth authentication system integration - Secure storage implementation for session tokens - Authentication flow with login/logout functionality - API client configuration for backend communication - Responsive UI components with themed styling - Expo Router navigation setup - Development configuration and scripts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
15
app/(auth)/_layout.tsx
Normal file
15
app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: "#fff" },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="login" />
|
||||
<Stack.Screen name="register" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
180
app/(auth)/login.tsx
Normal file
180
app/(auth)/login.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { storage } from '../../lib/storage';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && isAuthenticated) {
|
||||
console.log("[Login] User authenticated, redirecting to tabs...");
|
||||
router.replace("/(tabs)");
|
||||
}
|
||||
}, [isAuthenticated, authLoading]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
console.log("[Login] handleLogin called");
|
||||
|
||||
if (!username.trim() || !password.trim()) {
|
||||
console.log("[Login] Validation failed: empty username or password");
|
||||
Alert.alert("错误", "请输入用户名和密码");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Login] Starting login with username:", username.trim());
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
console.log("[Login] Calling authClient.signIn.username...");
|
||||
await new Promise<void>(async (resolve, reject) => {
|
||||
await authClient.signIn.username({
|
||||
username: username.trim(),
|
||||
password,
|
||||
}, {
|
||||
onSuccess: async (ctx) => {
|
||||
const authToken = ctx.response.headers.get("set-auth-token")
|
||||
if (authToken) {
|
||||
await storage.setItem(`bestaibest.better-auth.session_token`, authToken);
|
||||
resolve()
|
||||
} else {
|
||||
console.error("Bearer token not set");
|
||||
reject()
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
Alert.alert("登录失败", error?.message || "用户名或密码错误");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
console.log("[Login] handleLogin completed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={styles.container}
|
||||
>
|
||||
<ThemedView style={styles.content}>
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
欢迎登录
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>请输入您的账号信息</ThemedText>
|
||||
|
||||
<View style={styles.form}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<ThemedText style={styles.buttonText}>登录</ThemedText>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.registerLink}
|
||||
onPress={() => router.push("/(auth)/register")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ThemedText style={styles.registerText}>还没有账号?立即注册</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
justifyContent: "center",
|
||||
},
|
||||
title: {
|
||||
marginBottom: 8,
|
||||
textAlign: "center",
|
||||
},
|
||||
subtitle: {
|
||||
marginBottom: 32,
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
},
|
||||
form: {
|
||||
gap: 16,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#007AFF",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
registerLink: {
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
registerText: {
|
||||
color: "#007AFF",
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
191
app/(auth)/register.tsx
Normal file
191
app/(auth)/register.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username.trim() || !email.trim() || !password.trim()) {
|
||||
Alert.alert("错误", "请填写所有必填字段");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
Alert.alert("错误", "两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
Alert.alert("错误", "密码长度至少为 6 位");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await authClient.signUp.email({
|
||||
email: email.trim(),
|
||||
password,
|
||||
name: username.trim(),
|
||||
});
|
||||
Alert.alert("注册成功", "请登录您的账号", [
|
||||
{ text: "确定", onPress: () => router.replace("/(auth)/login") },
|
||||
]);
|
||||
} catch (error: any) {
|
||||
Alert.alert("注册失败", error?.message || "注册失败,请稍后重试");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={styles.container}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<ThemedView style={styles.content}>
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
创建账号
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>填写信息完成注册</ThemedText>
|
||||
|
||||
<View style={styles.form}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="邮箱"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="密码(至少 6 位)"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="确认密码"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleRegister}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<ThemedText style={styles.buttonText}>注册</ThemedText>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.loginLink}
|
||||
onPress={() => router.back()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ThemedText style={styles.loginText}>已有账号?立即登录</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
justifyContent: "center",
|
||||
},
|
||||
title: {
|
||||
marginBottom: 8,
|
||||
textAlign: "center",
|
||||
},
|
||||
subtitle: {
|
||||
marginBottom: 32,
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
},
|
||||
form: {
|
||||
gap: 16,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#007AFF",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
loginLink: {
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
loginText: {
|
||||
color: "#007AFF",
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
45
app/(tabs)/_layout.tsx
Normal file
45
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Tabs, Redirect } from 'expo-router';
|
||||
import React from 'react';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Redirect href="/(auth)/login" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
112
app/(tabs)/explore.tsx
Normal file
112
app/(tabs)/explore.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Fonts } from '@/constants/theme';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type="title"
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}>
|
||||
Explore 222
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('@/assets/images/react-logo.png')}
|
||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||
/>
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful{' '}
|
||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
241
app/(tabs)/index.tsx
Normal file
241
app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { TemplateList } from '@/components/templates/template-list';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { getTemplates } from '@/lib/api/templates';
|
||||
import { Template } from '@/lib/types/template';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Animated, StyleSheet, Text } from 'react-native';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pulseAnim = useRef(new Animated.Value(0)).current;
|
||||
const bounceAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const fetchTemplates = useCallback(async (page: number = 1, isRefreshing = false, isLoadMore = false) => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
try {
|
||||
if (isRefreshing) {
|
||||
setRefreshing(true);
|
||||
} else if (isLoadMore) {
|
||||
setIsLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const response = await getTemplates({
|
||||
page,
|
||||
size: 10,
|
||||
status: 'RELEASE'
|
||||
});
|
||||
|
||||
if (isRefreshing || page === 1) {
|
||||
// 刷新或首次加载,替换数据
|
||||
setTemplates(response.data);
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
// 加载更多,追加数据
|
||||
setTemplates(prev => [...prev, ...response.data]);
|
||||
setCurrentPage(page);
|
||||
}
|
||||
|
||||
// 检查是否还有更多数据
|
||||
setHasMore(response.pagination.page < response.pagination.totalPages);
|
||||
} catch (err: any) {
|
||||
console.error('获取模板列表失败:', err);
|
||||
setError(err.message || '获取模板列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!isLoadingMore && hasMore && !refreshing) {
|
||||
fetchTemplates(currentPage + 1, false, true);
|
||||
}
|
||||
}, [currentPage, hasMore, isLoadingMore, refreshing, fetchTemplates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchTemplates();
|
||||
}
|
||||
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 2000,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 0,
|
||||
duration: 2000,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(bounceAnim, {
|
||||
toValue: -10,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(bounceAnim, {
|
||||
toValue: -5,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(bounceAnim, {
|
||||
toValue: 0,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(bounceAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
}, [isAuthenticated, fetchTemplates, pulseAnim, bounceAnim]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchTemplates(1, true, false);
|
||||
}, [fetchTemplates]);
|
||||
|
||||
const handleTemplatePress = (template: Template) => {
|
||||
Alert.alert('模板详情', `您选择了: ${template.title}`);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedView style={styles.centerContent}>
|
||||
<ThemedText type="title" style={styles.title}>欢迎使用</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>请先登录以查看模板列表</ThemedText>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const shadowOpacity = pulseAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.3, 0.4],
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<Animated.View style={[styles.headerBanner, { shadowOpacity }]}>
|
||||
<LinearGradient
|
||||
colors={['#ff6b6b', '#ff8e53', '#ff6b35']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradientBanner}
|
||||
>
|
||||
<Text style={styles.bannerText}>
|
||||
New User 50% Off Coupon : <Text style={styles.couponText}>NEWUSER</Text>
|
||||
</Text>
|
||||
<Animated.Text
|
||||
style={[
|
||||
styles.giftIcon,
|
||||
{ transform: [{ translateY: bounceAnim }] },
|
||||
]}
|
||||
>
|
||||
🎁
|
||||
</Animated.Text>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
|
||||
<TemplateList
|
||||
templates={templates}
|
||||
loading={loading}
|
||||
refreshing={refreshing}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
onRefresh={handleRefresh}
|
||||
onLoadMore={loadMore}
|
||||
onTemplatePress={handleTemplatePress}
|
||||
error={error}
|
||||
/>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
headerBanner: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: '10%',
|
||||
right: '10%',
|
||||
zIndex: 1000,
|
||||
borderRadius: 20,
|
||||
shadowColor: '#ff6b6b',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
gradientBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 16,
|
||||
position: 'relative',
|
||||
},
|
||||
bannerText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
},
|
||||
couponText: {
|
||||
fontWeight: '800',
|
||||
fontSize: 14,
|
||||
color: '#fff',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 6,
|
||||
marginLeft: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.6)',
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
giftIcon: {
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
fontSize: 16,
|
||||
},
|
||||
centerContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
},
|
||||
});
|
||||
25
app/_layout.tsx
Normal file
25
app/_layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
29
app/modal.tsx
Normal file
29
app/modal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This is a modal</ThemedText>
|
||||
<Link href="/" dismissTo style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
531
app/template/[id]/run.tsx
Normal file
531
app/template/[id]/run.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import { DynamicForm } from '@/components/forms/dynamic-form';
|
||||
import { ResultDisplay } from '@/components/template-run/result-display';
|
||||
import { RunProgressView } from '@/components/template-run/run-progress';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useTemplateFormData, useTemplateRun } from '@/hooks/use-template-run';
|
||||
import { getTemplateById } from '@/lib/api/templates';
|
||||
import { subscription } from '@/lib/auth/client';
|
||||
import { Template } from '@/lib/types/template';
|
||||
import { RunFormSchema, RunTemplateData } from '@/lib/types/template-run';
|
||||
import { transformWorkflowToFormSchema, validateFormSchema } from '@/lib/utils/form-schema-transformer';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, BackHandler, Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
type RunStep = 'form' | 'progress' | 'result';
|
||||
|
||||
// 转换表单数据为 API 期望的格式
|
||||
function transformFormData(formData: RunTemplateData): RunTemplateData {
|
||||
const transformed: RunTemplateData = {};
|
||||
|
||||
Object.entries(formData).forEach(([fieldName, value]) => {
|
||||
// 根据字段名称判断类型并转换
|
||||
if (fieldName.startsWith('image_')) {
|
||||
// 图片字段转换为对象格式
|
||||
transformed[fieldName] = {
|
||||
url: value,
|
||||
type: 'image'
|
||||
};
|
||||
} else if (fieldName.startsWith('text_')) {
|
||||
// 文本字段转换为对象格式
|
||||
transformed[fieldName] = {
|
||||
content: value,
|
||||
type: 'text'
|
||||
};
|
||||
} else if (fieldName.startsWith('video_')) {
|
||||
// 视频字段转换为对象格式
|
||||
transformed[fieldName] = {
|
||||
url: value,
|
||||
type: 'video'
|
||||
};
|
||||
} else if (fieldName.startsWith('color_')) {
|
||||
// 颜色字段转换为对象格式
|
||||
transformed[fieldName] = {
|
||||
value: value,
|
||||
type: 'color'
|
||||
};
|
||||
} else {
|
||||
// 其他类型保持原样
|
||||
transformed[fieldName] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
export default function TemplateRunScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [template, setTemplate] = useState<Template | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentStep, setCurrentStep] = useState<RunStep>('form');
|
||||
const [formSchema, setFormSchema] = useState<RunFormSchema>({ fields: [] });
|
||||
|
||||
const {
|
||||
state: runState,
|
||||
progress,
|
||||
result,
|
||||
error,
|
||||
isLoading,
|
||||
isCompleted,
|
||||
isError,
|
||||
executeTemplate,
|
||||
cancelRun,
|
||||
reset,
|
||||
cleanup,
|
||||
} = useTemplateRun({
|
||||
onSuccess: (result) => {
|
||||
setCurrentStep('result');
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert('运行失败', error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
formData,
|
||||
errors,
|
||||
updateField,
|
||||
validateForm,
|
||||
isValid,
|
||||
} = useTemplateFormData();
|
||||
|
||||
// 获取模板信息
|
||||
useEffect(() => {
|
||||
const loadTemplate = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getTemplateById(id);
|
||||
if (response.success && response.data) {
|
||||
setTemplate(response.data);
|
||||
|
||||
// 解析表单配置
|
||||
if (response.data.formSchema) {
|
||||
try {
|
||||
console.log('原始formSchema数据:', response.data.formSchema);
|
||||
|
||||
// 解析formSchema数据(可能是字符串或对象)
|
||||
const rawData = typeof response.data.formSchema === 'string'
|
||||
? JSON.parse(response.data.formSchema)
|
||||
: response.data.formSchema;
|
||||
|
||||
// 使用转换工具将工作流数据转换为表单配置
|
||||
const formConfig = transformWorkflowToFormSchema(rawData);
|
||||
|
||||
// 验证转换结果
|
||||
if (validateFormSchema(formConfig)) {
|
||||
console.log('转换后的表单配置:', formConfig);
|
||||
setFormSchema(formConfig);
|
||||
} else {
|
||||
console.warn('表单配置验证失败,使用空配置');
|
||||
setFormSchema({ fields: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析表单配置失败:', error);
|
||||
console.log('使用空表单配置作为后备方案');
|
||||
setFormSchema({ fields: [] });
|
||||
}
|
||||
} else {
|
||||
console.log('模板没有formSchema,使用空配置');
|
||||
setFormSchema({ fields: [] });
|
||||
}
|
||||
} else {
|
||||
throw new Error('模板不存在');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载模板失败:', error);
|
||||
Alert.alert('错误', '无法加载模板,请稍后重试');
|
||||
router.back();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTemplate();
|
||||
}, [id, router]);
|
||||
|
||||
// 处理返回键
|
||||
useEffect(() => {
|
||||
const handleBackPress = () => {
|
||||
if (currentStep === 'progress' && isLoading) {
|
||||
Alert.alert(
|
||||
'确认退出',
|
||||
'任务正在运行中,确定要退出吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '退出',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
cancelRun();
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress);
|
||||
return () => subscription.remove();
|
||||
}, [currentStep, isLoading, cancelRun, router]);
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return cleanup;
|
||||
}, [cleanup]);
|
||||
|
||||
// 验证表单数据
|
||||
const validateFormData = useCallback((): { isValid: boolean; message?: string } => {
|
||||
if (!template) {
|
||||
return { isValid: false, message: '模板信息未加载完成' };
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return { isValid: false, message: `请修正 ${Object.keys(errors).length} 个错误后再提交` };
|
||||
}
|
||||
|
||||
if (formSchema.fields?.length > 0) {
|
||||
const requiredFields = formSchema.fields.filter(field => field.required);
|
||||
const missingFields = requiredFields.filter(field => {
|
||||
const value = formData[field.name];
|
||||
return value === undefined || value === null || value === '';
|
||||
});
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
const fieldNames = missingFields.map(f => f.label || f.name).join('、');
|
||||
return { isValid: false, message: `请填写必填项:${fieldNames}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}, [template, errors, formSchema.fields, formData]);
|
||||
|
||||
// 确认对话框
|
||||
const showConfirmDialog = useCallback((onConfirm: () => void) => {
|
||||
if (Platform.OS === 'web') {
|
||||
const confirmed = window.confirm('确定要开始生成任务吗?');
|
||||
if (confirmed) onConfirm();
|
||||
} else {
|
||||
Alert.alert(
|
||||
'确认提交',
|
||||
'确定要开始生成任务吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '确定', onPress: onConfirm }
|
||||
]
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = useCallback(() => {
|
||||
const validation = validateFormData();
|
||||
|
||||
if (!validation.isValid) {
|
||||
Alert.alert('表单错误', validation.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
// 转换表单数据为 API 期望的格式
|
||||
const transformedData = transformFormData(formData);
|
||||
console.log('转换后的数据:', transformedData);
|
||||
|
||||
const identify = await subscription.meterEvent({
|
||||
meter_id: `mtr_test_61TR3BkyDcen9OrBm412fzUa9k2QB732`,
|
||||
event_name: `token_usage`,
|
||||
payload: {
|
||||
value: `100`
|
||||
}
|
||||
})
|
||||
console.log({ identify })
|
||||
// error
|
||||
const orid = identify.data?.identifier
|
||||
console.log({ orid })
|
||||
if (orid) {
|
||||
try {
|
||||
setCurrentStep('progress');
|
||||
executeTemplate(template!.id, transformedData);
|
||||
} catch (e) {
|
||||
// 退款orid
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
showConfirmDialog(handleConfirm);
|
||||
}, [validateFormData, showConfirmDialog, executeTemplate, template, formData]);
|
||||
|
||||
// 重新运行
|
||||
const handleRerun = useCallback(() => {
|
||||
setCurrentStep('form');
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
// 渲染表单步骤
|
||||
const renderFormStep = () => (
|
||||
<View style={styles.container}>
|
||||
{/* 模板信息头部 */}
|
||||
{template && (
|
||||
<View style={styles.templateHeader}>
|
||||
<LinearGradient
|
||||
colors={['#4ECDC4', '#44A3A0']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.headerGradient}
|
||||
>
|
||||
<View style={styles.templateInfo}>
|
||||
<Image
|
||||
source={{ uri: template.coverImageUrl }}
|
||||
style={styles.templateImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<View style={styles.templateDetails}>
|
||||
<ThemedText style={styles.templateTitle}>
|
||||
{template.title}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.templateDescription} numberOfLines={2}>
|
||||
{template.description}
|
||||
</ThemedText>
|
||||
{template.category && (
|
||||
<View style={styles.categoryBadge}>
|
||||
<ThemedText style={styles.categoryText}>
|
||||
{template.category.name}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 表单内容 */}
|
||||
<View style={styles.formContainer}>
|
||||
<ThemedText style={styles.formTitle}>配置参数</ThemedText>
|
||||
{formSchema.fields && formSchema.fields.length > 0 ? (
|
||||
<DynamicForm
|
||||
schema={formSchema}
|
||||
onDataChange={(data, valid) => {
|
||||
// 实时更新表单数据到外部状态
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
updateField(key, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.noConfigContainer}>
|
||||
<ThemedText style={styles.noConfigText}>
|
||||
此模板无需配置,直接使用默认参数
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<View style={styles.submitContainer}>
|
||||
<TouchableOpacity style={styles.submitButton} onPress={handleSubmit}>
|
||||
<ThemedText style={styles.submitButtonText}>
|
||||
{isLoading ? '处理中...' : '开始生成'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染进度步骤
|
||||
const renderProgressStep = () => (
|
||||
<View style={styles.container}>
|
||||
<RunProgressView
|
||||
progress={progress}
|
||||
onCancel={() => {
|
||||
Alert.alert(
|
||||
'确认取消',
|
||||
'确定要取消当前任务吗?',
|
||||
[
|
||||
{ text: '继续执行', style: 'cancel' },
|
||||
{
|
||||
text: '取消任务',
|
||||
style: 'destructive',
|
||||
onPress: cancelRun,
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染结果步骤
|
||||
const renderResultStep = () => (
|
||||
<View style={styles.container}>
|
||||
{result && (
|
||||
<ResultDisplay
|
||||
result={result}
|
||||
onShare={(url) => {
|
||||
// 可以添加自定义分享逻辑
|
||||
}}
|
||||
onDownload={(url) => {
|
||||
// 可以添加自定义下载逻辑
|
||||
}}
|
||||
onRerun={handleRerun}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<ThemedView style={styles.loadingContainer}>
|
||||
<ThemedText>加载中...</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error && !template) {
|
||||
return (
|
||||
<ThemedView style={styles.errorContainer}>
|
||||
<ThemedText style={styles.errorText}>
|
||||
{error.message}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染当前步骤
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: currentStep === 'form' ? '配置模板' :
|
||||
currentStep === 'progress' ? '生成中' : '生成结果',
|
||||
headerBackVisible: currentStep !== 'progress' || !isLoading,
|
||||
}}
|
||||
/>
|
||||
|
||||
{currentStep === 'form' && renderFormStep()}
|
||||
{currentStep === 'progress' && renderProgressStep()}
|
||||
{currentStep === 'result' && renderResultStep()}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
},
|
||||
templateHeader: {
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
headerGradient: {
|
||||
paddingTop: 20,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
templateInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
templateImage: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 12,
|
||||
marginRight: 16,
|
||||
},
|
||||
templateDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
templateTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
marginBottom: 4,
|
||||
},
|
||||
templateDescription: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
marginBottom: 8,
|
||||
},
|
||||
categoryBadge: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 12,
|
||||
color: '#fff',
|
||||
fontWeight: '500',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
formTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
},
|
||||
noConfigContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
noConfigText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
},
|
||||
submitContainer: {
|
||||
marginTop: 'auto',
|
||||
paddingTop: 20,
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: '#4ECDC4',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#4ECDC4',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user