✨ feat: 应用界面重构 - 从模板中心到AI内容生成平台
- 重构首页:从模板列表展示改为AI功能展示界面 - 更新底部导航:将explore改为content,index改为home - 新增多个页面:积分、兑换、历史记录、结果展示、设置等 - 优化UI组件:新增通用按钮、分类标签、加载状态等组件 - 清理冗余文件:删除旧的认证文档和积分详情页面 - 更新主题配置:调整配色方案和视觉风格 - 修复导入路径:优化组件导入结构 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,8 @@ 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';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
@@ -23,16 +23,16 @@ export default function TabLayout() {
|
||||
tabBarButton: HapticTab,
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
name="home"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
name="history"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
title: 'Content',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, View, TouchableOpacity, Text } from 'react-native';
|
||||
|
||||
import {
|
||||
CategoryTabs,
|
||||
CommunityGrid,
|
||||
FeatureCarousel,
|
||||
Header,
|
||||
PageLayout,
|
||||
SectionHeader,
|
||||
StatusBarSpacer,
|
||||
type CommunityItem,
|
||||
type FeatureItem,
|
||||
} from '@/components/bestai';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
|
||||
const categories = [
|
||||
{ id: 'visual-effects', label: 'Visual Effects' },
|
||||
{ id: 'higgsfield-soul', label: 'Higgsfield Soul' },
|
||||
{ id: 'higgsfield-apps', label: 'Higgsfield Apps' },
|
||||
{ id: 'king', label: 'King' },
|
||||
];
|
||||
|
||||
const baseFeatureItems: FeatureItem[] = [
|
||||
{
|
||||
id: 'sketch-video',
|
||||
title: 'UNLIMITED SKETCH TO VIDEO',
|
||||
subtitle: 'Bring your imagination to life with Sora 2',
|
||||
image: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=800&q=80',
|
||||
},
|
||||
{
|
||||
id: 'portrait-video',
|
||||
title: 'UNLIMITED PORTRAIT TO VIDEO',
|
||||
subtitle: 'Transform portraits into motion-driven stories',
|
||||
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=800&q=80',
|
||||
},
|
||||
{
|
||||
id: 'water-simulation',
|
||||
title: 'FLUID SIMULATION PACK',
|
||||
subtitle: 'Realistic water physics for cinematic scenes',
|
||||
image: 'https://images.unsplash.com/photo-1505744386214-51dba16a26fc?auto=format&fit=crop&w=800&q=80',
|
||||
},
|
||||
];
|
||||
|
||||
const baseCommunityItems: CommunityItem[] = [
|
||||
{
|
||||
id: 'community-1',
|
||||
chip: '64/10',
|
||||
title: 'OBJECTS AROUND',
|
||||
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=800&q=80',
|
||||
actionLabel: 'Generate',
|
||||
},
|
||||
{
|
||||
id: 'community-2',
|
||||
chip: '64/10',
|
||||
title: 'OBJECTS AROUND',
|
||||
image: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=800&q=80',
|
||||
actionLabel: 'Generate',
|
||||
},
|
||||
{
|
||||
id: 'community-3',
|
||||
chip: '64/10',
|
||||
title: 'OBJECTS AROUND',
|
||||
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=800&q=80',
|
||||
actionLabel: 'Generate',
|
||||
},
|
||||
{
|
||||
id: 'community-4',
|
||||
chip: '64/10',
|
||||
title: 'OBJECTS AROUND',
|
||||
image: 'https://images.unsplash.com/photo-1531259683007-016a7b628fc4?auto=format&fit=crop&w=800&q=80',
|
||||
actionLabel: 'Generate',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { requireAuth } = useAuthGuard();
|
||||
const [activeCategory, setActiveCategory] = useState(categories[0]?.id ?? 'visual-effects');
|
||||
|
||||
const featureItems = useMemo(() => {
|
||||
return baseFeatureItems.map((item, index) => ({
|
||||
...item,
|
||||
id: `${activeCategory}-${index}`,
|
||||
}));
|
||||
}, [activeCategory]);
|
||||
|
||||
const communityItems = useMemo(() => {
|
||||
return baseCommunityItems.map((item, index) => ({
|
||||
...item,
|
||||
id: `${activeCategory}-${index}`,
|
||||
}));
|
||||
}, [activeCategory]);
|
||||
|
||||
const handleGeneratePress = () => {
|
||||
requireAuth(() => {
|
||||
console.log('用户已登录,执行生成操作');
|
||||
});
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<PageLayout backgroundColor="#050505">
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<StatusBarSpacer />
|
||||
<Header />
|
||||
<View style={styles.loginPrompt}>
|
||||
<View style={styles.lockIconContainer}>
|
||||
<Text style={styles.lockIcon}>🔒</Text>
|
||||
</View>
|
||||
<Text style={styles.loginTitle}>需要登录</Text>
|
||||
<Text style={styles.loginSubtitle}>
|
||||
登录后即可浏览和使用所有功能
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.loginButton}
|
||||
onPress={() => requireAuth()}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.loginButtonText}>立即登录</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout backgroundColor="#050505">
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<StatusBarSpacer />
|
||||
<Header />
|
||||
<CategoryTabs categories={categories} activeId={activeCategory} onChange={setActiveCategory} />
|
||||
<FeatureCarousel items={featureItems} />
|
||||
<SectionHeader title="WAN 2.5 COMMUNITY" />
|
||||
<CommunityGrid items={communityItems} />
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingTop: 16,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
bottomSpacer: {
|
||||
height: 32,
|
||||
},
|
||||
loginPrompt: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
lockIconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
lockIcon: {
|
||||
fontSize: 40,
|
||||
},
|
||||
loginTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#ffffff',
|
||||
marginBottom: 12,
|
||||
},
|
||||
loginSubtitle: {
|
||||
fontSize: 16,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
marginBottom: 32,
|
||||
},
|
||||
loginButton: {
|
||||
backgroundColor: '#d7ff1f',
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
loginButtonText: {
|
||||
color: '#050505',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
149
app/(tabs)/history.tsx
Normal file
149
app/(tabs)/history.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React from 'react';
|
||||
import { Image, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
type GalleryLane = 'leading' | 'trailing';
|
||||
|
||||
interface GalleryTile {
|
||||
id: string;
|
||||
lane: GalleryLane;
|
||||
uri: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const curatedGallery: GalleryTile[] = [
|
||||
{
|
||||
id: 'ember-circle',
|
||||
lane: 'leading',
|
||||
uri: 'https://images.unsplash.com/photo-1616004655122-818af0f3efc6?auto=format&fit=crop&w=1200&q=80',
|
||||
height: 232,
|
||||
},
|
||||
{
|
||||
id: 'ocean-horizon',
|
||||
lane: 'trailing',
|
||||
uri: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?auto=format&fit=crop&w=1200&q=80',
|
||||
height: 152,
|
||||
},
|
||||
{
|
||||
id: 'studio-poise',
|
||||
lane: 'trailing',
|
||||
uri: 'https://images.unsplash.com/photo-1582096897407-9b6c86e1a8d3?auto=format&fit=crop&w=1200&q=80',
|
||||
height: 232,
|
||||
},
|
||||
{
|
||||
id: 'duo-portrait',
|
||||
lane: 'leading',
|
||||
uri: 'https://images.unsplash.com/photo-1601040122900-86ad461b8234?auto=format&fit=crop&w=1200&q=80',
|
||||
height: 148,
|
||||
},
|
||||
{
|
||||
id: 'sage-armor',
|
||||
lane: 'leading',
|
||||
uri: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=1200&q=80',
|
||||
height: 224,
|
||||
},
|
||||
{
|
||||
id: 'velvet-repose',
|
||||
lane: 'trailing',
|
||||
uri: 'https://images.unsplash.com/photo-1515378791036-0648a3ef77b2?auto=format&fit=crop&w=1200&q=80',
|
||||
height: 188,
|
||||
},
|
||||
{
|
||||
id: 'nocturne-lounge',
|
||||
lane: 'trailing',
|
||||
uri: 'https://images.unsplash.com/photo-1573497491208-6b1acb260507?auto=format&fit=crop&w=1200&q=80',
|
||||
height: 184,
|
||||
},
|
||||
{
|
||||
id: 'sunlit-duet',
|
||||
lane: 'leading',
|
||||
uri: 'https://images.unsplash.com/photo-1531257240576-a4a0ef4af1c8?auto=format&fit=crop&w=1200&q=80',
|
||||
height: 188,
|
||||
},
|
||||
];
|
||||
|
||||
const leadingNarratives = curatedGallery.filter(tile => tile.lane === 'leading');
|
||||
const trailingNarratives = curatedGallery.filter(tile => tile.lane === 'trailing');
|
||||
|
||||
export default function HistoryScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
|
||||
<StatusBar style="light" />
|
||||
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<Text style={styles.heading}>Content Generation</Text>
|
||||
<Text style={styles.dateline}>10.19 Wednesday</Text>
|
||||
<View style={styles.gallery}>
|
||||
<View style={styles.leadingLane}>
|
||||
{leadingNarratives.map(tile => (
|
||||
<Image
|
||||
key={tile.id}
|
||||
source={{ uri: tile.uri }}
|
||||
style={[styles.frame, { height: tile.height }]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.trailingLane}>
|
||||
{trailingNarratives.map(tile => (
|
||||
<Image
|
||||
key={tile.id}
|
||||
source={{ uri: tile.uri }}
|
||||
style={[styles.frame, { height: tile.height }]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
canvas: {
|
||||
flex: 1,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
heading: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
dateline: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#EDEDED',
|
||||
},
|
||||
gallery: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 24,
|
||||
},
|
||||
leadingLane: {
|
||||
flex: 1,
|
||||
paddingRight: 12,
|
||||
},
|
||||
trailingLane: {
|
||||
flex: 1,
|
||||
paddingLeft: 12,
|
||||
},
|
||||
frame: {
|
||||
width: '100%',
|
||||
borderRadius: 28,
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
@@ -1,349 +1,139 @@
|
||||
import { TemplateList } from '@/components/templates/template-list';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, View } from 'react-native';
|
||||
|
||||
import {
|
||||
CategoryTabs,
|
||||
CommunityGrid,
|
||||
FeatureCarousel,
|
||||
Header,
|
||||
PageLayout,
|
||||
SectionHeader,
|
||||
StatusBarSpacer,
|
||||
type CommunityItem,
|
||||
type FeatureItem,
|
||||
} from '@/components/bestai';
|
||||
import type { FeatureItem as FeatureItemType } from '@/components/bestai/feature-carousel';
|
||||
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 { Ionicons } from '@expo/vector-icons';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { LoginModal } from '@/components/auth/login-modal';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const categories = [
|
||||
{ id: 'visual-effects', label: 'Visual Effects' },
|
||||
{ id: 'higgsfield-soul', label: 'Higgsfield Soul' },
|
||||
{ id: 'higgsfield-apps', label: 'Higgsfield Apps' },
|
||||
{ id: 'king', label: 'King' },
|
||||
];
|
||||
|
||||
const baseFeatureItems: FeatureItem[] = [
|
||||
{
|
||||
id: 'sketch-video',
|
||||
title: 'UNLIMITED SKETCH TO VIDEO',
|
||||
subtitle: 'Bring your imagination to life with Sora 2',
|
||||
image: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=800&q=80',
|
||||
},
|
||||
{
|
||||
id: 'portrait-video',
|
||||
title: 'UNLIMITED PORTRAIT TO VIDEO',
|
||||
subtitle: 'Transform portraits into motion-driven stories',
|
||||
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=800&q=80',
|
||||
},
|
||||
{
|
||||
id: 'water-simulation',
|
||||
title: 'FLUID SIMULATION PACK',
|
||||
subtitle: 'Realistic water physics for cinematic scenes',
|
||||
image: 'https://images.unsplash.com/photo-1505744386214-51dba16a26fc?auto=format&fit=crop&w=800&q=80',
|
||||
},
|
||||
];
|
||||
|
||||
const baseCommunityItems: CommunityItem[] = [
|
||||
{
|
||||
id: 'community-1',
|
||||
chip: '64/10',
|
||||
title: 'OBJECTS AROUND',
|
||||
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=800&q=80',
|
||||
actionLabel: 'Generate',
|
||||
},
|
||||
{
|
||||
id: 'community-2',
|
||||
chip: '64/10',
|
||||
title: 'OBJECTS AROUND',
|
||||
image: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=800&q=80',
|
||||
actionLabel: 'Generate',
|
||||
},
|
||||
{
|
||||
id: 'community-3',
|
||||
chip: '64/10',
|
||||
title: 'OBJECTS AROUND',
|
||||
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=800&q=80',
|
||||
actionLabel: 'Generate',
|
||||
},
|
||||
{
|
||||
id: 'community-4',
|
||||
chip: '64/10',
|
||||
title: 'OBJECTS AROUND',
|
||||
image: 'https://images.unsplash.com/photo-1531259683007-016a7b628fc4?auto=format&fit=crop&w=800&q=80',
|
||||
actionLabel: 'Generate',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ExploreScreen() {
|
||||
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 [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const pulseAnim = useRef(new Animated.Value(0)).current;
|
||||
const bounceAnim = useRef(new Animated.Value(0)).current;
|
||||
const { requireAuth } = useAuthGuard();
|
||||
const [activeCategory, setActiveCategory] = useState(categories[0]?.id ?? 'visual-effects');
|
||||
|
||||
const fetchTemplates = useCallback(async (page: number = 1, isRefreshing = false, isLoadMore = false) => {
|
||||
if (!isAuthenticated) return;
|
||||
const featureItems = useMemo(() => {
|
||||
return baseFeatureItems.map((item, index) => ({
|
||||
...item,
|
||||
id: `${activeCategory}-${index}`,
|
||||
}));
|
||||
}, [activeCategory]);
|
||||
|
||||
try {
|
||||
if (isRefreshing) {
|
||||
setRefreshing(true);
|
||||
} else if (isLoadMore) {
|
||||
setIsLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
const communityItems = useMemo(() => {
|
||||
return baseCommunityItems.map((item, index) => ({
|
||||
...item,
|
||||
id: `${activeCategory}-${index}`,
|
||||
}));
|
||||
}, [activeCategory]);
|
||||
|
||||
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();
|
||||
setShowLoginModal(false);
|
||||
}
|
||||
|
||||
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}`);
|
||||
};
|
||||
|
||||
const handleVideoChange = (template: Template, index: number) => {
|
||||
console.log('视频切换到:', template.title, '索引:', index);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
const scaleAnim = pulseAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.95, 1.05],
|
||||
const handleGeneratePress = () => {
|
||||
requireAuth(() => {
|
||||
console.log('用户已登录,执行生成操作');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<View style={styles.welcomeContainer}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
{ transform: [{ scale: scaleAnim }] },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="albums-outline" size={80} color="#007AFF" />
|
||||
</Animated.View>
|
||||
|
||||
<ThemedText type="title" style={styles.welcomeTitle}>
|
||||
欢迎使用模板中心
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.welcomeSubtitle}>
|
||||
登录后即可浏览和使用海量精美模板
|
||||
</ThemedText>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.loginButton}
|
||||
onPress={() => setShowLoginModal(true)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#007AFF', '#0056D2']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.loginButtonGradient}
|
||||
>
|
||||
<Ionicons name="log-in-outline" size={20} color="#fff" style={styles.loginButtonIcon} />
|
||||
<Text style={styles.loginButtonText}>立即登录</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.guestButton} activeOpacity={0.6}>
|
||||
<Text style={styles.guestButtonText}>游客浏览</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<LoginModal
|
||||
visible={showLoginModal}
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
/>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const shadowOpacity = pulseAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.3, 0.4],
|
||||
});
|
||||
const handleFeaturePress = (item: FeatureItemType) => {
|
||||
requireAuth(() => {
|
||||
console.log('用户已登录,使用功能:', item.title);
|
||||
});
|
||||
};
|
||||
|
||||
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}
|
||||
onVideoChange={handleVideoChange}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<LoginModal
|
||||
visible={showLoginModal}
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
/>
|
||||
</ThemedView>
|
||||
<PageLayout backgroundColor="#050505">
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<StatusBarSpacer />
|
||||
<Header />
|
||||
<CategoryTabs categories={categories} activeId={activeCategory} onChange={setActiveCategory} />
|
||||
<FeatureCarousel items={featureItems} onPress={handleFeaturePress} />
|
||||
<SectionHeader title="WAN 2.5 COMMUNITY" />
|
||||
<CommunityGrid items={communityItems} onPressAction={handleGeneratePress} />
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
welcomeContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 40,
|
||||
contentContainer: {
|
||||
paddingTop: 16,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
backgroundColor: 'rgba(0, 122, 255, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
welcomeTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
welcomeSubtitle: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
marginBottom: 48,
|
||||
lineHeight: 24,
|
||||
},
|
||||
loginButton: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
elevation: 8,
|
||||
shadowColor: '#007AFF',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 12,
|
||||
shadowOpacity: 0.3,
|
||||
},
|
||||
loginButtonGradient: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
},
|
||||
loginButtonIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
loginButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
},
|
||||
guestButton: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(0, 122, 255, 0.3)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
guestButtonText: {
|
||||
color: '#007AFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
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,
|
||||
bottomSpacer: {
|
||||
height: 32,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,7 +48,6 @@ const TABS: { key: TabKey; label: string }[] = [
|
||||
|
||||
const BILLING_OPTIONS: { key: BillingMode; label: string }[] = [
|
||||
{ key: 'monthly', label: '月付' },
|
||||
{ key: 'lifetime', label: '终身' },
|
||||
];
|
||||
|
||||
const palettes: Record<'dark' | 'light', Palette> = {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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';
|
||||
import { AuthProvider } from '@/components/auth/auth-provider';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
@@ -17,11 +16,9 @@ export default function RootLayout() {
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<AuthProvider>
|
||||
<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" />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
621
app/edit/[id].tsx
Normal file
621
app/edit/[id].tsx
Normal file
@@ -0,0 +1,621 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Copy, Download, Eraser, Image as ImageIcon, Redo2, RotateCcw, Undo2, Video } from 'lucide-react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import CommonHeader from '@/components/CommonHeader';
|
||||
|
||||
interface EditableResult {
|
||||
id: string;
|
||||
originalContent: string;
|
||||
currentContent: string;
|
||||
type: 'text' | 'image' | 'video';
|
||||
hasChanges: boolean;
|
||||
lastModified: string;
|
||||
history: string[];
|
||||
historyIndex: number;
|
||||
metadata?: {
|
||||
imageUri?: string;
|
||||
videoUri?: string;
|
||||
duration?: number;
|
||||
filters?: {
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
saturation: number;
|
||||
};
|
||||
crop?: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
RESULTS: 'editable_results',
|
||||
VERSIONS: 'result_versions',
|
||||
};
|
||||
|
||||
export default function EditResultScreen() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [result, setResult] = useState<EditableResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [imageEditorVisible, setImageEditorVisible] = useState(false);
|
||||
const [videoEditorVisible, setVideoEditorVisible] = useState(false);
|
||||
|
||||
const loadResult = useCallback(async () => {
|
||||
try {
|
||||
const storedResults = await AsyncStorage.getItem(STORAGE_KEYS.RESULTS);
|
||||
if (storedResults) {
|
||||
const results = JSON.parse(storedResults);
|
||||
const foundResult = results.find((r: EditableResult) => r.id === id);
|
||||
if (foundResult) {
|
||||
setResult(foundResult);
|
||||
} else {
|
||||
createNewEditableResult();
|
||||
}
|
||||
} else {
|
||||
createNewEditableResult();
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '加载数据失败');
|
||||
console.error('加载结果失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const createNewEditableResult = useCallback(() => {
|
||||
const newResult: EditableResult = {
|
||||
id: String(id),
|
||||
originalContent: '这是一段示例文本,您可以在这里编辑内容。',
|
||||
currentContent: '这是一段示例文本,您可以在这里编辑内容。',
|
||||
type: 'text',
|
||||
hasChanges: false,
|
||||
lastModified: new Date().toISOString(),
|
||||
history: ['这是一段示例文本,您可以在这里编辑内容。'],
|
||||
historyIndex: 0,
|
||||
};
|
||||
setResult(newResult);
|
||||
}, [id]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadResult();
|
||||
}, [loadResult])
|
||||
);
|
||||
|
||||
const updateContent = useCallback((newContent: string) => {
|
||||
if (!result) return;
|
||||
|
||||
const history = result.history || [];
|
||||
const currentIndex = result.historyIndex;
|
||||
|
||||
if (currentIndex < history.length - 1) {
|
||||
history.splice(currentIndex + 1);
|
||||
}
|
||||
|
||||
history.push(result.currentContent);
|
||||
|
||||
setResult({
|
||||
...result,
|
||||
currentContent: newContent,
|
||||
history,
|
||||
historyIndex: Math.min(history.length - 1, 49),
|
||||
hasChanges: newContent !== result.originalContent,
|
||||
lastModified: new Date().toISOString(),
|
||||
});
|
||||
}, [result]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (!result || result.historyIndex <= 0) return;
|
||||
|
||||
const newIndex = result.historyIndex - 1;
|
||||
setResult({
|
||||
...result,
|
||||
currentContent: result.history[newIndex],
|
||||
historyIndex: newIndex,
|
||||
});
|
||||
}, [result]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (!result || result.historyIndex >= (result.history?.length || 0) - 1) return;
|
||||
|
||||
const newIndex = result.historyIndex + 1;
|
||||
setResult({
|
||||
...result,
|
||||
currentContent: result.history[newIndex],
|
||||
historyIndex: newIndex,
|
||||
});
|
||||
}, [result]);
|
||||
|
||||
const copyToClipboard = useCallback(() => {
|
||||
if (!result?.currentContent) return;
|
||||
Alert.alert('已复制', '内容已复制到剪贴板');
|
||||
}, [result?.currentContent]);
|
||||
|
||||
const clearFormat = useCallback(() => {
|
||||
Alert.alert('清除格式', '确定要清除所有格式吗?', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '确定', onPress: () => updateContent(result?.currentContent || '') },
|
||||
]);
|
||||
}, [updateContent, result?.currentContent]);
|
||||
|
||||
const exportAsImage = useCallback(async () => {
|
||||
Alert.alert('导出成功', '已保存到相册');
|
||||
}, []);
|
||||
|
||||
const saveChanges = useCallback(async (saveAsNew: boolean = false) => {
|
||||
if (!result) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const storedResults = await AsyncStorage.getItem(STORAGE_KEYS.RESULTS);
|
||||
const results: EditableResult[] = storedResults ? JSON.parse(storedResults) : [];
|
||||
|
||||
if (saveAsNew) {
|
||||
const newResult: EditableResult = {
|
||||
...result,
|
||||
id: `${Date.now()}`,
|
||||
originalContent: result.currentContent,
|
||||
history: [result.currentContent],
|
||||
historyIndex: 0,
|
||||
hasChanges: false,
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
results.push(newResult);
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.RESULTS, JSON.stringify(results));
|
||||
Alert.alert('保存成功', '已另存为新版本', [
|
||||
{ text: '确定', onPress: () => router.back() },
|
||||
]);
|
||||
} else {
|
||||
const updatedResults = results.map(r =>
|
||||
r.id === result.id
|
||||
? {
|
||||
...result,
|
||||
originalContent: result.currentContent,
|
||||
history: [result.currentContent],
|
||||
historyIndex: 0,
|
||||
hasChanges: false,
|
||||
}
|
||||
: r
|
||||
);
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.RESULTS, JSON.stringify(updatedResults));
|
||||
Alert.alert('保存成功', '修改已保存', [
|
||||
{ text: '确定', onPress: () => router.back() },
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '保存失败');
|
||||
console.error('保存失败:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (result?.hasChanges) {
|
||||
Alert.alert(
|
||||
'未保存的更改',
|
||||
'您有未保存的更改,确定要离开吗?',
|
||||
[
|
||||
{ text: '继续编辑', style: 'cancel' },
|
||||
{
|
||||
text: '离开',
|
||||
style: 'destructive',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]
|
||||
);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
}, [result?.hasChanges]);
|
||||
|
||||
const handleBackPress = useCallback(() => {
|
||||
handleCancel();
|
||||
}, [handleCancel]);
|
||||
|
||||
if (loading || !result) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const canUndo = result.historyIndex > 0;
|
||||
const canRedo = result.historyIndex < (result.history?.length || 0) - 1;
|
||||
const wordCount = result.currentContent.length;
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 88 : 0}
|
||||
>
|
||||
<CommonHeader
|
||||
title="编辑结果"
|
||||
onBack={handleBackPress}
|
||||
rightContent={
|
||||
<Button
|
||||
title="保存"
|
||||
onPress={() => saveChanges(false)}
|
||||
variant="primary"
|
||||
size="small"
|
||||
disabled={!result.hasChanges || saving}
|
||||
loading={saving}
|
||||
style={styles.headerSaveButton}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.toolbar}>
|
||||
<TouchableOpacity
|
||||
style={[styles.toolButton, !canUndo && styles.toolButtonDisabled]}
|
||||
onPress={undo}
|
||||
disabled={!canUndo}
|
||||
>
|
||||
<Undo2 size={20} color={canUndo ? '#007AFF' : '#CCCCCC'} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.toolButton, !canRedo && styles.toolButtonDisabled]}
|
||||
onPress={redo}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
<Redo2 size={20} color={canRedo ? '#007AFF' : '#CCCCCC'} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.toolButton} onPress={copyToClipboard}>
|
||||
<Copy size={20} color="#007AFF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.toolButton} onPress={clearFormat}>
|
||||
<Eraser size={20} color="#007AFF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.toolButton} onPress={exportAsImage}>
|
||||
<Download size={20} color="#007AFF" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.editorSection}>
|
||||
<View style={styles.typeIndicator}>
|
||||
{result.type === 'text' && <Text style={styles.typeText}>文本编辑</Text>}
|
||||
{result.type === 'image' && <Text style={styles.typeText}>图片编辑</Text>}
|
||||
{result.type === 'video' && <Text style={styles.typeText}>视频编辑</Text>}
|
||||
</View>
|
||||
|
||||
{result.type === 'text' ? (
|
||||
<View style={styles.textEditorContainer}>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
value={result.currentContent}
|
||||
onChangeText={updateContent}
|
||||
multiline
|
||||
placeholder="在此编辑内容..."
|
||||
textAlignVertical="top"
|
||||
maxLength={10000}
|
||||
/>
|
||||
<View style={styles.wordCount}>
|
||||
<Text style={styles.wordCountText}>
|
||||
{wordCount}/10000
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : result.type === 'image' ? (
|
||||
<View style={styles.imageEditorContainer}>
|
||||
<View style={styles.imagePreview}>
|
||||
<ImageIcon size={48} color="#CCCCCC" />
|
||||
<Text style={styles.imageEditorText}>点击编辑图片</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.imageEditButton}
|
||||
onPress={() => setImageEditorVisible(true)}
|
||||
>
|
||||
<Text style={styles.imageEditButtonText}>打开编辑器</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.imageTools}>
|
||||
<TouchableOpacity style={styles.imageToolButton}>
|
||||
<RotateCcw size={20} color="#007AFF" />
|
||||
<Text style={styles.imageToolText}>旋转</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.imageToolButton}>
|
||||
<ImageIcon size={20} color="#007AFF" />
|
||||
<Text style={styles.imageToolText}>裁剪</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.imageToolButton}>
|
||||
<Download size={20} color="#007AFF" />
|
||||
<Text style={styles.imageToolText}>滤镜</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.videoEditorContainer}>
|
||||
<View style={styles.videoPreview}>
|
||||
<Video size={48} color="#CCCCCC" />
|
||||
<Text style={styles.videoEditorText}>点击编辑视频</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.videoEditButton}
|
||||
onPress={() => setVideoEditorVisible(true)}
|
||||
>
|
||||
<Text style={styles.videoEditButtonText}>打开编辑器</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.videoTools}>
|
||||
<TouchableOpacity style={styles.videoToolButton}>
|
||||
<ImageIcon size={20} color="#007AFF" />
|
||||
<Text style={styles.videoToolText}>封面</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.videoToolButton}>
|
||||
<Eraser size={20} color="#007AFF" />
|
||||
<Text style={styles.videoToolText}>裁剪</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.videoToolButton}>
|
||||
<Text style={[styles.videoToolText, { color: '#007AFF' }]}>字幕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{result.hasChanges && (
|
||||
<View style={styles.changesIndicator}>
|
||||
<Text style={styles.changesText}>● 有未保存的更改</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.bottomActions}>
|
||||
<Button
|
||||
title="保存修改"
|
||||
onPress={() => saveChanges(false)}
|
||||
variant="primary"
|
||||
size="large"
|
||||
fullWidth
|
||||
disabled={!result.hasChanges || saving}
|
||||
loading={saving}
|
||||
style={styles.primaryButton}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="另存为新版本"
|
||||
onPress={() => saveChanges(true)}
|
||||
variant="outline"
|
||||
size="large"
|
||||
fullWidth
|
||||
disabled={saving}
|
||||
style={styles.secondaryButton}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="取消"
|
||||
onPress={handleCancel}
|
||||
variant="text"
|
||||
size="medium"
|
||||
fullWidth
|
||||
style={styles.cancelButton}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
loadingText: {
|
||||
textAlign: 'center',
|
||||
marginTop: 100,
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
},
|
||||
headerSaveButton: {
|
||||
width: 80,
|
||||
height: 36,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#EEEEEE',
|
||||
gap: 12,
|
||||
},
|
||||
toolButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#F0F0F0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
toolButtonDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
editorSection: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
margin: 16,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
typeIndicator: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
typeText: {
|
||||
fontSize: 14,
|
||||
color: '#007AFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
textEditorContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
textInput: {
|
||||
minHeight: 300,
|
||||
padding: 16,
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: '#333333',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#EEEEEE',
|
||||
backgroundColor: '#FAFAFA',
|
||||
},
|
||||
wordCount: {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 16,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 4,
|
||||
},
|
||||
wordCountText: {
|
||||
fontSize: 12,
|
||||
color: '#999999',
|
||||
},
|
||||
imageEditorContainer: {
|
||||
gap: 16,
|
||||
},
|
||||
imagePreview: {
|
||||
height: 250,
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
imageEditorText: {
|
||||
fontSize: 14,
|
||||
color: '#666666',
|
||||
},
|
||||
imageEditButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 6,
|
||||
},
|
||||
imageEditButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
imageTools: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
imageToolButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#F0F0F0',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
imageToolText: {
|
||||
fontSize: 12,
|
||||
color: '#007AFF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
videoEditorContainer: {
|
||||
gap: 16,
|
||||
},
|
||||
videoPreview: {
|
||||
height: 250,
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
videoEditorText: {
|
||||
fontSize: 14,
|
||||
color: '#666666',
|
||||
},
|
||||
videoEditButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 6,
|
||||
},
|
||||
videoEditButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
videoTools: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
videoToolButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#F0F0F0',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
videoToolText: {
|
||||
fontSize: 12,
|
||||
color: '#007AFF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
changesIndicator: {
|
||||
margin: 16,
|
||||
padding: 12,
|
||||
backgroundColor: '#FFF3CD',
|
||||
borderRadius: 8,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: '#FFC107',
|
||||
},
|
||||
changesText: {
|
||||
fontSize: 14,
|
||||
color: '#856404',
|
||||
fontWeight: '500',
|
||||
},
|
||||
bottomActions: {
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#EEEEEE',
|
||||
gap: 12,
|
||||
},
|
||||
primaryButton: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
secondaryButton: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
cancelButton: {},
|
||||
});
|
||||
387
app/exchange.tsx
Normal file
387
app/exchange.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
type PurchaseTab = 'subscription' | 'pack';
|
||||
|
||||
type PointsBundle = {
|
||||
id: string;
|
||||
points: number;
|
||||
price: number;
|
||||
};
|
||||
|
||||
const screenPalette = {
|
||||
background: '#050505',
|
||||
surface: '#101010',
|
||||
surfaceRaised: '#1A1B1E',
|
||||
primaryText: '#F5F5F5',
|
||||
secondaryText: '#7F7F7F',
|
||||
mutedText: '#4C4C4C',
|
||||
accent: '#FEB840',
|
||||
energyHalo: 'rgba(254, 184, 64, 0.22)',
|
||||
divider: '#1C1C1C',
|
||||
button: '#D1FE17',
|
||||
buttonText: '#101010',
|
||||
};
|
||||
|
||||
const CURRENT_POINTS = 60;
|
||||
|
||||
const SUBSCRIPTION_TAGLINE = 'No active subscription plans';
|
||||
|
||||
const TABS: { key: PurchaseTab; label: string }[] = [
|
||||
{ key: 'subscription', label: 'Subscription' },
|
||||
{ key: 'pack', label: 'points pack' },
|
||||
];
|
||||
|
||||
const POINT_BUNDLES: PointsBundle[] = [
|
||||
{ id: 'bundle-500', points: 500, price: 35 },
|
||||
{ id: 'bundle-2000', points: 2000, price: 140 },
|
||||
{ id: 'bundle-5000', points: 5000, price: 350 },
|
||||
{ id: 'bundle-10000', points: 10000, price: 700 },
|
||||
];
|
||||
|
||||
export default function PointsExchangeScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [activeTab, setActiveTab] = useState<PurchaseTab>('pack');
|
||||
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
|
||||
|
||||
const visibleBundles = useMemo(
|
||||
() => (activeTab === 'pack' ? POINT_BUNDLES : []),
|
||||
[activeTab],
|
||||
);
|
||||
|
||||
const changeTab = useCallback((nextTab: PurchaseTab) => {
|
||||
setActiveTab(nextTab);
|
||||
if (nextTab !== 'pack') {
|
||||
setSelectedBundleId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const openPointsDetails = useCallback(() => {
|
||||
router.push('/points-details');
|
||||
}, [router]);
|
||||
|
||||
const handleBundleSelect = useCallback((bundleId: string) => {
|
||||
setSelectedBundleId(bundleId);
|
||||
}, []);
|
||||
|
||||
const handlePurchase = useCallback(() => {
|
||||
const bundle = POINT_BUNDLES.find(item => item.id === selectedBundleId);
|
||||
|
||||
if (!bundle) {
|
||||
Alert.alert('Select a bundle', 'Please pick the bundle you wish to purchase.');
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Confirm purchase',
|
||||
`You are purchasing ${bundle.points} points for ¥ ${bundle.price}.`,
|
||||
);
|
||||
}, [selectedBundleId]);
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
|
||||
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
accessibilityRole="button"
|
||||
onPress={handleClose}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="close" size={22} color={screenPalette.primaryText} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryPill}
|
||||
accessibilityRole="button"
|
||||
onPress={openPointsDetails}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.secondaryPillLabel}>Points Details</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
{ paddingBottom: Math.max(insets.bottom + 96, 160) },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.balanceCluster}>
|
||||
<View style={styles.energyOrb}>
|
||||
<Ionicons name="flash" size={22} color={screenPalette.accent} />
|
||||
</View>
|
||||
<Text style={styles.balanceValue}>{CURRENT_POINTS}</Text>
|
||||
<Text style={styles.balanceSubtitle}>{SUBSCRIPTION_TAGLINE}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.tabBar}>
|
||||
{TABS.map(tab => {
|
||||
const isActive = tab.key === activeTab;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
style={styles.tabButton}
|
||||
onPress={() => changeTab(tab.key)}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: isActive }}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.tabLabel, isActive && styles.tabLabelActive]}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
<View
|
||||
style={[styles.tabIndicator, isActive && styles.tabIndicatorActive]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<View style={styles.tabDivider} />
|
||||
|
||||
{activeTab === 'pack' ? (
|
||||
<View style={styles.packGrid}>
|
||||
{visibleBundles.map(bundle => {
|
||||
const isSelected = bundle.id === selectedBundleId;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={bundle.id}
|
||||
style={[
|
||||
styles.packCard,
|
||||
isSelected && styles.packCardSelected,
|
||||
]}
|
||||
onPress={() => handleBundleSelect(bundle.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
activeOpacity={0.88}
|
||||
>
|
||||
<View style={styles.packHeader}>
|
||||
<Ionicons name="flash" size={18} color={screenPalette.accent} />
|
||||
<Text style={styles.packPoints}>{bundle.points}</Text>
|
||||
</View>
|
||||
<Text style={styles.packPrice}>¥ {bundle.price}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.subscriptionEmpty}>
|
||||
<Text style={styles.emptyTitle}>Subscription</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
No subscription tiers are available at the moment.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 16) }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.primaryButton}
|
||||
onPress={handlePurchase}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.primaryButtonLabel}>Purchase points</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: screenPalette.background,
|
||||
},
|
||||
headerRow: {
|
||||
paddingHorizontal: 24,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
secondaryPill: {
|
||||
paddingHorizontal: 16,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
backgroundColor: screenPalette.surface,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: screenPalette.divider,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryPillLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: screenPalette.primaryText,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
balanceCluster: {
|
||||
alignItems: 'center',
|
||||
marginTop: 32,
|
||||
marginBottom: 36,
|
||||
gap: 10,
|
||||
},
|
||||
energyOrb: {
|
||||
width: 58,
|
||||
height: 58,
|
||||
borderRadius: 29,
|
||||
backgroundColor: screenPalette.energyHalo,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: screenPalette.accent,
|
||||
shadowOpacity: 0.28,
|
||||
shadowRadius: 14,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 6,
|
||||
},
|
||||
balanceValue: {
|
||||
fontSize: 44,
|
||||
fontWeight: '700',
|
||||
color: screenPalette.primaryText,
|
||||
letterSpacing: 0.5,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
balanceSubtitle: {
|
||||
fontSize: 13,
|
||||
color: screenPalette.secondaryText,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
tabBar: {
|
||||
flexDirection: 'row',
|
||||
gap: 32,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingBottom: 12,
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 14,
|
||||
color: screenPalette.mutedText,
|
||||
fontWeight: '500',
|
||||
letterSpacing: 0.2,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
tabLabelActive: {
|
||||
color: screenPalette.primaryText,
|
||||
},
|
||||
tabIndicator: {
|
||||
marginTop: 10,
|
||||
height: 2,
|
||||
width: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 1,
|
||||
},
|
||||
tabIndicatorActive: {
|
||||
backgroundColor: screenPalette.accent,
|
||||
},
|
||||
tabDivider: {
|
||||
marginTop: 12,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: screenPalette.divider,
|
||||
},
|
||||
packGrid: {
|
||||
marginTop: 28,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 18,
|
||||
},
|
||||
packCard: {
|
||||
width: '47%',
|
||||
backgroundColor: screenPalette.surfaceRaised,
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 24,
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1,
|
||||
borderColor: screenPalette.surfaceRaised,
|
||||
},
|
||||
packCardSelected: {
|
||||
borderColor: screenPalette.accent,
|
||||
},
|
||||
packHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
packPoints: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: screenPalette.primaryText,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
packPrice: {
|
||||
marginTop: 18,
|
||||
fontSize: 14,
|
||||
color: screenPalette.secondaryText,
|
||||
},
|
||||
subscriptionEmpty: {
|
||||
marginTop: 64,
|
||||
backgroundColor: screenPalette.surface,
|
||||
borderRadius: 22,
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 32,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: screenPalette.primaryText,
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 13,
|
||||
color: screenPalette.secondaryText,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
bottomBar: {
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
primaryButton: {
|
||||
height: 54,
|
||||
borderRadius: 27,
|
||||
backgroundColor: screenPalette.button,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
primaryButtonLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: screenPalette.buttonText,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import type { ListRenderItem } from 'react-native';
|
||||
import {
|
||||
FlatList,
|
||||
StatusBar,
|
||||
@@ -25,14 +26,15 @@ type FilterKey = 'all' | LedgerKind;
|
||||
|
||||
const screenPalette = {
|
||||
background: '#080808',
|
||||
surface: '#111111',
|
||||
surface: '#101010',
|
||||
surfaceActive: '#1C1C1C',
|
||||
divider: '#1A1A1A',
|
||||
surfaceOutline: '#1D1D1D',
|
||||
divider: '#161616',
|
||||
primaryText: '#F5F5F5',
|
||||
secondaryText: '#6C6C6C',
|
||||
accent: '#FEB840',
|
||||
accentHalo: 'rgba(254, 184, 64, 0.18)',
|
||||
negative: '#B3B3B3',
|
||||
accentHalo: 'rgba(254, 184, 64, 0.22)',
|
||||
negative: '#8F8F8F',
|
||||
};
|
||||
|
||||
const FILTERS: { key: FilterKey; label: string }[] = [
|
||||
@@ -106,25 +108,48 @@ export default function PointsDetailsScreen() {
|
||||
return LEDGER.filter(entry => entry.kind === activeFilter);
|
||||
}, [activeFilter]);
|
||||
|
||||
const listContentStyle = useMemo(
|
||||
() => ({
|
||||
paddingBottom: Math.max(insets.bottom + 36, 72),
|
||||
paddingTop: 4,
|
||||
}),
|
||||
[insets.bottom],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((entry: LedgerEntry) => entry.id, []);
|
||||
|
||||
const renderSeparator = useCallback(() => <View style={styles.listDivider} />, []);
|
||||
|
||||
const renderLedgerEntry: ListRenderItem<LedgerEntry> = useCallback(({ item }) => {
|
||||
const amount = Math.abs(item.amount);
|
||||
const isPositive = item.amount > 0;
|
||||
|
||||
return (
|
||||
<View style={styles.recordRow}>
|
||||
<View>
|
||||
<Text style={styles.recordTitle}>{item.title}</Text>
|
||||
<Text style={styles.recordTimestamp}>{item.happenedAt}</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.recordValue,
|
||||
isPositive ? styles.valuePositive : styles.valueNegative,
|
||||
]}
|
||||
>
|
||||
{isPositive ? `+ ${amount}` : `- ${amount}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { paddingTop: insets.top + 12 }]}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.back()}
|
||||
style={styles.iconButton}
|
||||
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={screenPalette.primaryText} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Points Details</Text>
|
||||
<View style={styles.iconButton} />
|
||||
</View>
|
||||
|
||||
|
||||
<View style={styles.balanceCluster}>
|
||||
<View style={styles.energyOrb}>
|
||||
<Ionicons name="flash" size={20} color={screenPalette.accent} />
|
||||
<Ionicons name="flash" size={22} color={screenPalette.accent} />
|
||||
</View>
|
||||
<Text style={styles.balanceValue}>60</Text>
|
||||
</View>
|
||||
@@ -150,32 +175,12 @@ export default function PointsDetailsScreen() {
|
||||
|
||||
<FlatList
|
||||
data={filteredEntries}
|
||||
keyExtractor={item => item.id}
|
||||
keyExtractor={keyExtractor}
|
||||
style={styles.list}
|
||||
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom + 24, 64) }}
|
||||
contentContainerStyle={listContentStyle}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ItemSeparatorComponent={() => <View style={styles.listDivider} />}
|
||||
renderItem={({ item }) => {
|
||||
const amount = Math.abs(item.amount);
|
||||
const isPositive = item.amount > 0;
|
||||
|
||||
return (
|
||||
<View style={styles.recordRow}>
|
||||
<View>
|
||||
<Text style={styles.recordTitle}>{item.title}</Text>
|
||||
<Text style={styles.recordTimestamp}>{item.happenedAt}</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.recordValue,
|
||||
isPositive ? styles.valuePositive : styles.valueNegative,
|
||||
]}
|
||||
>
|
||||
{isPositive ? `+ ${amount}` : `- ${amount}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
ItemSeparatorComponent={renderSeparator}
|
||||
renderItem={renderLedgerEntry}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -185,98 +190,117 @@ const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: screenPalette.background,
|
||||
paddingHorizontal: 20,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 48,
|
||||
},
|
||||
iconButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontSize: 17,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
color: screenPalette.primaryText,
|
||||
letterSpacing: 0.3,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
balanceCluster: {
|
||||
alignItems: 'center',
|
||||
marginTop: 28,
|
||||
marginBottom: 32,
|
||||
gap: 12,
|
||||
marginTop: 32,
|
||||
marginBottom: 28,
|
||||
gap: 10,
|
||||
},
|
||||
energyOrb: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: screenPalette.accentHalo,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: screenPalette.accent,
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 8,
|
||||
},
|
||||
balanceValue: {
|
||||
fontSize: 44,
|
||||
fontSize: 46,
|
||||
lineHeight: 52,
|
||||
fontWeight: '700',
|
||||
color: screenPalette.primaryText,
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 0.5,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
segmentRail: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: screenPalette.surface,
|
||||
padding: 6,
|
||||
padding: 4,
|
||||
borderRadius: 28,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: screenPalette.surfaceOutline,
|
||||
gap: 4,
|
||||
},
|
||||
segmentChip: {
|
||||
flex: 1,
|
||||
borderRadius: 22,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingVertical: 11,
|
||||
},
|
||||
segmentChipActive: {
|
||||
backgroundColor: screenPalette.surfaceActive,
|
||||
},
|
||||
segmentLabel: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: screenPalette.secondaryText,
|
||||
fontWeight: '500',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
segmentLabelActive: {
|
||||
color: screenPalette.primaryText,
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
marginTop: 32,
|
||||
marginTop: 28,
|
||||
},
|
||||
listDivider: {
|
||||
height: 1,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: screenPalette.divider,
|
||||
marginVertical: 6,
|
||||
marginVertical: 0,
|
||||
},
|
||||
recordRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 10,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
recordTitle: {
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
fontWeight: '600',
|
||||
color: screenPalette.primaryText,
|
||||
},
|
||||
recordTimestamp: {
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
color: screenPalette.secondaryText,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
recordValue: {
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
fontWeight: '600',
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
valuePositive: {
|
||||
color: screenPalette.accent,
|
||||
309
app/recharge.tsx
309
app/recharge.tsx
@@ -1,31 +1,310 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, ScrollView } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { X, Zap } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
const pointBundles = [
|
||||
{ id: 'bundle-500', amount: 500, price: 35 },
|
||||
{ id: 'bundle-2000', amount: 2000, price: 140 },
|
||||
{ id: 'bundle-5000', amount: 5000, price: 350 },
|
||||
{ id: 'bundle-10000', amount: 10000, price: 700 },
|
||||
];
|
||||
|
||||
export default function RechargeScreen() {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const insets = useSafeAreaInsets();
|
||||
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
|
||||
|
||||
const handleBundleSelection = (bundleId: string) => {
|
||||
setSelectedBundleId(bundleId);
|
||||
};
|
||||
|
||||
const handlePurchase = () => {
|
||||
if (!selectedBundleId) {
|
||||
Alert.alert(
|
||||
'Choose a pack',
|
||||
'Select the points pack that fits your journey before proceeding.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedBundle = pointBundles.find(bundle => bundle.id === selectedBundleId);
|
||||
if (!selectedBundle) {
|
||||
Alert.alert('Unavailable pack', 'The chosen points pack is no longer available.');
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Purchase in progress',
|
||||
`Preparing ${selectedBundle.amount} points for ¥${selectedBundle.price}.`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor }]}>
|
||||
<ThemedView style={styles.content}>
|
||||
<ThemedText type="title">充值页面</ThemedText>
|
||||
<ThemedText>此页面正在开发中...</ThemedText>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
|
||||
<StatusBar style="light" />
|
||||
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
|
||||
<View style={styles.frame}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close recharge"
|
||||
onPress={() => router.back()}
|
||||
style={styles.dismissButton}
|
||||
>
|
||||
<X color="#FFFFFF" size={22} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="View points details"
|
||||
onPress={() => router.push('/points')}
|
||||
style={styles.pointsDetails}
|
||||
>
|
||||
<Text style={styles.pointsDetailsText}>Points Details</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.balanceBlock}>
|
||||
<View style={styles.balanceRow}>
|
||||
<Zap color="#FFCE38" size={30} strokeWidth={2.2} />
|
||||
<Text style={styles.balanceValue}>60</Text>
|
||||
</View>
|
||||
<Text style={styles.balanceCaption}>No active subscription plans</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.tabStrip}>
|
||||
<Text style={styles.tabInactive}>Subscription</Text>
|
||||
<View style={styles.tabActive}>
|
||||
<Text style={styles.tabActiveText}>points pack</Text>
|
||||
<View style={styles.tabIndicator} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.bundleGrid}>
|
||||
{pointBundles.map(bundle => (
|
||||
<Pressable
|
||||
key={bundle.id}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Purchase ${bundle.amount} points for ¥${bundle.price}`}
|
||||
onPress={() => handleBundleSelection(bundle.id)}
|
||||
style={({ pressed }) => [
|
||||
styles.bundleCard,
|
||||
selectedBundleId === bundle.id && styles.bundleCardActive,
|
||||
pressed && styles.bundleCardPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.bundleHeader}>
|
||||
<Zap color="#FFCE38" size={22} strokeWidth={2.2} />
|
||||
<Text
|
||||
style={[
|
||||
styles.bundleAmount,
|
||||
selectedBundleId === bundle.id && styles.bundleAmountActive,
|
||||
]}
|
||||
>
|
||||
{bundle.amount}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.bundlePrice,
|
||||
selectedBundleId === bundle.id && styles.bundlePriceActive,
|
||||
]}
|
||||
>
|
||||
¥ {bundle.price}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 16) }]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Purchase points"
|
||||
onPress={handlePurchase}
|
||||
style={({ pressed }) => [
|
||||
styles.purchaseButton,
|
||||
pressed && styles.purchaseButtonPressed,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.purchaseButtonText}>Purchase points</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
canvas: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
frame: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 32,
|
||||
},
|
||||
headerRow: {
|
||||
marginTop: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
dismissButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
pointsDetails: {
|
||||
backgroundColor: '#181818',
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2C2C2C',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
pointsDetailsText: {
|
||||
color: '#F1F1F1',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
balanceBlock: {
|
||||
marginTop: 32,
|
||||
alignItems: 'center',
|
||||
},
|
||||
balanceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
balanceValue: {
|
||||
marginLeft: 12,
|
||||
fontSize: 48,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
balanceCaption: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
color: '#8E8E8E',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
tabStrip: {
|
||||
marginTop: 40,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
tabInactive: {
|
||||
marginRight: 32,
|
||||
fontSize: 16,
|
||||
color: '#555555',
|
||||
fontWeight: '500',
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
tabActive: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
tabActiveText: {
|
||||
fontSize: 16,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
tabIndicator: {
|
||||
marginTop: 10,
|
||||
height: 2,
|
||||
width: 70,
|
||||
backgroundColor: '#FFD84E',
|
||||
borderRadius: 2,
|
||||
},
|
||||
bundleGrid: {
|
||||
marginTop: 32,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
bundleCard: {
|
||||
width: '47%',
|
||||
backgroundColor: '#111111',
|
||||
borderRadius: 24,
|
||||
paddingVertical: 24,
|
||||
paddingHorizontal: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1F1F1F',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
},
|
||||
bundleCardActive: {
|
||||
borderColor: '#FFD84E',
|
||||
},
|
||||
bundleCardPressed: {
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
bundleHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
bundleAmount: {
|
||||
marginLeft: 12,
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
bundleAmountActive: {
|
||||
color: '#FFD84E',
|
||||
},
|
||||
bundlePrice: {
|
||||
marginTop: 24,
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
color: '#A4A4A4',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
bundlePriceActive: {
|
||||
color: '#FFD84E',
|
||||
},
|
||||
footer: {
|
||||
paddingTop: 16,
|
||||
},
|
||||
purchaseButton: {
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: '#CFFF21',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
purchaseButtonPressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
purchaseButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#101010',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
});
|
||||
|
||||
542
app/result.tsx
Normal file
542
app/result.tsx
Normal file
@@ -0,0 +1,542 @@
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ResizeMode, Video } from 'expo-av';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
Maximize2,
|
||||
Plus,
|
||||
X,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Dimensions,
|
||||
Image,
|
||||
ImageStyle,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
StyleProp,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const HERO_HEIGHT = screenWidth * 0.58;
|
||||
const DETAIL_HEIGHT = screenWidth * 0.95;
|
||||
|
||||
interface ResultWithTemplate {
|
||||
id: string;
|
||||
userId: string;
|
||||
templateId: string;
|
||||
type: 'TEXT' | 'IMAGE' | 'VIDEO';
|
||||
resultUrl: string[];
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
creditsCost?: number;
|
||||
creditsTransactionId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
templateName?: string;
|
||||
templateThumbnail?: string;
|
||||
templateDescription?: string;
|
||||
}
|
||||
|
||||
export default function ResultPage() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [result, setResult] = useState<ResultWithTemplate | null>(null);
|
||||
const [fullscreenVisible, setFullscreenVisible] = useState(false);
|
||||
const [fullscreenContent, setFullscreenContent] = useState<{
|
||||
type: 'image' | 'video';
|
||||
url: string;
|
||||
} | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadResult();
|
||||
}, [id]);
|
||||
|
||||
const loadResult = async () => {
|
||||
try {
|
||||
const typeParam = typeof id === 'string' ? id.split('_')[1] : 'text';
|
||||
const resultId = typeof id === 'string' ? id : 'res1_text';
|
||||
|
||||
const getResultByType = () => {
|
||||
switch (typeParam) {
|
||||
case 'image':
|
||||
return {
|
||||
id: resultId,
|
||||
userId: 'user1',
|
||||
templateId: 'tpl2',
|
||||
type: 'IMAGE' as const,
|
||||
resultUrl: ['https://picsum.photos/800/600?random=1'],
|
||||
status: 'completed' as const,
|
||||
creditsCost: 20,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
templateName: 'AI图片生成',
|
||||
templateThumbnail: 'https://picsum.photos/60/60?random=2',
|
||||
templateDescription: '使用AI生成高质量图片',
|
||||
};
|
||||
case 'video':
|
||||
return {
|
||||
id: resultId,
|
||||
userId: 'user1',
|
||||
templateId: 'tpl3',
|
||||
type: 'VIDEO' as const,
|
||||
resultUrl: ['https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'],
|
||||
status: 'completed' as const,
|
||||
creditsCost: 50,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
templateName: 'AI视频生成',
|
||||
templateThumbnail: 'https://picsum.photos/60/60?random=3',
|
||||
templateDescription: '基于文本生成视频内容',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
id: resultId,
|
||||
userId: 'user1',
|
||||
templateId: 'tpl1',
|
||||
type: 'TEXT' as const,
|
||||
resultUrl: [
|
||||
'这是一段AI生成的营销文案,专门为您的产品精心打造。它突出了产品的核心优势,采用了吸引人的语言风格,能够有效提升用户的购买欲望和品牌认知度。通过深入分析目标受众的需求和痛点,我们精心设计了这份文案,旨在最大化品牌影响力和转化效果。',
|
||||
],
|
||||
status: 'completed' as const,
|
||||
creditsCost: 10,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
templateName: 'AI文案生成',
|
||||
templateThumbnail: 'https://picsum.photos/60/60?random=1',
|
||||
templateDescription: '基于关键词生成营销文案',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
setResult(getResultByType());
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '无法加载结果详情');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRerun = () => {
|
||||
router.push(`/templates/${result?.templateId}/form`);
|
||||
};
|
||||
|
||||
const handleSaveToGallery = async () => {
|
||||
if (!result || result.type === 'TEXT') return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('权限请求', '需要媒体库权限才能保存文件');
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert('保存成功', `${result.type === 'IMAGE' ? '图片' : '视频'}已保存到相册`);
|
||||
} catch (error) {
|
||||
Alert.alert('保存失败', '无法保存文件,请检查网络连接');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyText = async () => {
|
||||
if (!result || result.type !== 'TEXT') return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard?.writeText?.(result.resultUrl[0]);
|
||||
Alert.alert('复制成功', '文本已复制到剪贴板');
|
||||
} catch (error) {
|
||||
Alert.alert('复制失败', '无法复制文本');
|
||||
}
|
||||
};
|
||||
|
||||
const openFullscreen = (type: 'image' | 'video', url: string) => {
|
||||
setFullscreenContent({ type, url });
|
||||
setFullscreenVisible(true);
|
||||
};
|
||||
|
||||
const handleDownloadPress = () => {
|
||||
if (!result) return;
|
||||
|
||||
if (result.type === 'TEXT') {
|
||||
handleCopyText();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSaveToGallery();
|
||||
};
|
||||
|
||||
const renderVisualMedia = (url: string, style: StyleProp<ImageStyle> | StyleProp<ViewStyle>) => {
|
||||
if (!url) return null;
|
||||
|
||||
if (result?.type === 'VIDEO') {
|
||||
return (
|
||||
<Video
|
||||
source={{ uri: url }}
|
||||
style={style}
|
||||
resizeMode={ResizeMode.COVER}
|
||||
shouldPlay={false}
|
||||
isMuted
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
style={style}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (!result) {
|
||||
return (
|
||||
<ThemedView style={styles.screen}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const isTextResult = result.type === 'TEXT';
|
||||
const primaryPreviewUrl = result.resultUrl[0] ?? '';
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.screen}>
|
||||
<View style={[styles.safeGuard, { paddingTop: insets.top || 16 }]} />
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
activeOpacity={0.7}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={22} color="#F6F6F8" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>
|
||||
INSEAR YOUR PRODUCT INTO AN ASMR VIDEO
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.
|
||||
</Text>
|
||||
|
||||
<View style={styles.heroCard}>
|
||||
{isTextResult ? (
|
||||
<ScrollView style={styles.textScroll} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.generatedText}>{primaryPreviewUrl}</Text>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<>
|
||||
{renderVisualMedia(primaryPreviewUrl, styles.heroMedia)}
|
||||
{result.templateThumbnail && (
|
||||
<View style={styles.referencePreview}>
|
||||
<Image
|
||||
source={{ uri: result.templateThumbnail }}
|
||||
style={styles.referenceImage}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!isTextResult && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={styles.detailCard}
|
||||
onPress={() => openFullscreen(result.type === 'VIDEO' ? 'video' : 'image', primaryPreviewUrl)}
|
||||
>
|
||||
<View style={styles.expandIcon}>
|
||||
<Maximize2 size={18} color="#F5F5F7" />
|
||||
</View>
|
||||
<View style={styles.detailMediaWrapper}>
|
||||
{renderVisualMedia(primaryPreviewUrl, styles.detailMedia)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<Modal
|
||||
visible={fullscreenVisible}
|
||||
transparent={false}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setFullscreenVisible(false)}
|
||||
>
|
||||
<View style={styles.fullscreenContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={() => setFullscreenVisible(false)}
|
||||
>
|
||||
<X size={30} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{fullscreenContent && (
|
||||
<View style={styles.fullscreenMedia}>
|
||||
{fullscreenContent.type === 'image' ? (
|
||||
<Image
|
||||
source={{ uri: fullscreenContent.url }}
|
||||
style={styles.fullscreenMediaContent}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
<Video
|
||||
source={{ uri: fullscreenContent.url }}
|
||||
style={styles.fullscreenMediaContent}
|
||||
shouldPlay
|
||||
useNativeControls
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
{
|
||||
paddingBottom: Math.max(insets.bottom, 20),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
style={styles.secondaryAction}
|
||||
onPress={handleRerun}
|
||||
>
|
||||
<Plus size={20} color="#F6F6F8" />
|
||||
<Text style={styles.secondaryText}>Regenerate</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.primaryAction,
|
||||
saving && { opacity: 0.7 },
|
||||
]}
|
||||
onPress={handleDownloadPress}
|
||||
disabled={saving}
|
||||
>
|
||||
{isTextResult ? (
|
||||
<Copy size={20} color="#1C1C1E" />
|
||||
) : (
|
||||
<Download size={20} color="#1C1C1E" />
|
||||
)}
|
||||
<Text style={styles.primaryText}>
|
||||
{isTextResult ? 'Copy Text' : 'Download'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: '#09090B',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#09090B',
|
||||
},
|
||||
loadingText: {
|
||||
color: '#F5F5F7',
|
||||
fontSize: 16,
|
||||
},
|
||||
safeGuard: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 160,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(32, 32, 36, 0.6)',
|
||||
},
|
||||
title: {
|
||||
color: '#F5F5F7',
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
lineHeight: 30,
|
||||
marginBottom: 12,
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
subtitle: {
|
||||
color: '#9A9AA2',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
heroCard: {
|
||||
marginTop: 32,
|
||||
borderRadius: 32,
|
||||
backgroundColor: '#131317',
|
||||
padding: 24,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
heroMedia: {
|
||||
width: '100%',
|
||||
height: HERO_HEIGHT,
|
||||
borderRadius: 28,
|
||||
},
|
||||
textScroll: {
|
||||
maxHeight: HERO_HEIGHT,
|
||||
},
|
||||
generatedText: {
|
||||
color: '#F5F5F7',
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
referencePreview: {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
left: 24,
|
||||
width: 74,
|
||||
height: 74,
|
||||
borderRadius: 20,
|
||||
padding: 4,
|
||||
backgroundColor: '#050506',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
referenceImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 16,
|
||||
},
|
||||
detailCard: {
|
||||
marginTop: 28,
|
||||
borderRadius: 28,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backgroundColor: '#101012',
|
||||
padding: 20,
|
||||
position: 'relative',
|
||||
},
|
||||
detailMediaWrapper: {
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
detailMedia: {
|
||||
width: '100%',
|
||||
height: DETAIL_HEIGHT,
|
||||
},
|
||||
expandIcon: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
top: 20,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(24, 24, 28, 0.86)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
fullscreenContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000000',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
right: 20,
|
||||
zIndex: 1,
|
||||
width: 50,
|
||||
height: 50,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
fullscreenMedia: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
fullscreenMediaContent: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 16,
|
||||
backgroundColor: 'rgba(6, 6, 7, 0.94)',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryAction: {
|
||||
flex: 1,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#151519',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
marginRight: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
secondaryText: {
|
||||
color: '#F5F5F7',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
},
|
||||
primaryAction: {
|
||||
flex: 1,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#D9FF3F',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
primaryText: {
|
||||
color: '#1C1C1E',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginLeft: 10,
|
||||
},
|
||||
});
|
||||
217
app/settings/index.tsx
Normal file
217
app/settings/index.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { authClient } from '@/lib/auth/client';
|
||||
|
||||
type Palette = {
|
||||
canvas: string;
|
||||
card: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
icon: string;
|
||||
stroke: string;
|
||||
buttonBackground: string;
|
||||
buttonText: string;
|
||||
};
|
||||
|
||||
type SettingDestination = {
|
||||
key: string;
|
||||
label: string;
|
||||
route?: string;
|
||||
};
|
||||
|
||||
const palettes: Record<'dark' | 'light', Palette> = {
|
||||
dark: {
|
||||
canvas: '#050505',
|
||||
card: '#141417',
|
||||
textPrimary: '#F5F5F7',
|
||||
textSecondary: '#8E8E93',
|
||||
icon: '#F5F5F7',
|
||||
stroke: '#232327',
|
||||
buttonBackground: '#FFFFFF',
|
||||
buttonText: '#050505',
|
||||
},
|
||||
light: {
|
||||
canvas: '#F7F7F9',
|
||||
card: '#FFFFFF',
|
||||
textPrimary: '#131318',
|
||||
textSecondary: '#5C5C67',
|
||||
icon: '#131318',
|
||||
stroke: '#E4E4EA',
|
||||
buttonBackground: '#131318',
|
||||
buttonText: '#FFFFFF',
|
||||
},
|
||||
};
|
||||
|
||||
const destinations: SettingDestination[] = [
|
||||
{ key: 'change-password', label: 'Change Password', route: '/settings/account' },
|
||||
{ key: 'terms', label: 'Terms and Policies' },
|
||||
{ key: 'support', label: 'Feedback and Support' },
|
||||
];
|
||||
|
||||
export default function SettingsHomeScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const colorScheme = useColorScheme();
|
||||
const palette = palettes[colorScheme === 'dark' ? 'dark' : 'light'];
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
const topInset = Math.max(insets.top, 16);
|
||||
const bottomInset = Math.max(insets.bottom + 16, 40);
|
||||
|
||||
const handleNavigate = (destination: SettingDestination) => {
|
||||
if (!destination.route) {
|
||||
return;
|
||||
}
|
||||
router.push(destination.route);
|
||||
};
|
||||
|
||||
const handleLogOut = async () => {
|
||||
if (isLoggingOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoggingOut(true);
|
||||
|
||||
try {
|
||||
await authClient.signOut();
|
||||
router.replace('/(auth)/login');
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Please try again in a moment.';
|
||||
Alert.alert('Unable to log out', message);
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { paddingTop: topInset, backgroundColor: palette.canvas }]}>
|
||||
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
|
||||
<View style={styles.headerRow}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={styles.headerControl}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color={palette.icon} />
|
||||
</Pressable>
|
||||
<Text style={[styles.headerTitle, { color: palette.textPrimary }]}>set up</Text>
|
||||
<View style={styles.headerControl} />
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={[styles.card, { backgroundColor: palette.card }]}>
|
||||
{destinations.map((destination, index) => {
|
||||
const isLast = index === destinations.length - 1;
|
||||
return (
|
||||
<Pressable
|
||||
key={destination.key}
|
||||
accessibilityRole={destination.route ? 'button' : 'none'}
|
||||
disabled={!destination.route}
|
||||
onPress={() => handleNavigate(destination)}
|
||||
style={({ pressed }) => [
|
||||
styles.optionRow,
|
||||
!isLast && { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: palette.stroke },
|
||||
pressed && destination.route && { opacity: 0.6 },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.optionLabel, { color: palette.textPrimary }]}>
|
||||
{destination.label}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color={palette.textSecondary} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.footer, { paddingBottom: bottomInset }]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={isLoggingOut}
|
||||
onPress={handleLogOut}
|
||||
style={({ pressed }) => [
|
||||
styles.logoutButton,
|
||||
{ backgroundColor: palette.buttonBackground },
|
||||
pressed && { opacity: 0.9 },
|
||||
]}
|
||||
>
|
||||
{isLoggingOut ? (
|
||||
<ActivityIndicator size="small" color={palette.buttonText} />
|
||||
) : (
|
||||
<Text style={[styles.logoutLabel, { color: palette.buttonText }]}>Log out</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 32,
|
||||
},
|
||||
headerControl: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 64,
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
footer: {
|
||||
paddingTop: 32,
|
||||
},
|
||||
logoutButton: {
|
||||
borderRadius: 26,
|
||||
height: 56,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logoutLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,54 +1,46 @@
|
||||
import { DynamicForm } from '@/components/forms/dynamic-form';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
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 { 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 { RunTemplateData } from '@/lib/types/template-run';
|
||||
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';
|
||||
import { Stack, useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, BackHandler, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
type RunStep = 'form' | 'progress' | 'result';
|
||||
type RunStep = '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'
|
||||
type: 'image',
|
||||
};
|
||||
} else if (fieldName.startsWith('text_')) {
|
||||
// 文本字段转换为对象格式
|
||||
transformed[fieldName] = {
|
||||
content: value,
|
||||
type: 'text'
|
||||
type: 'text',
|
||||
};
|
||||
} else if (fieldName.startsWith('video_')) {
|
||||
// 视频字段转换为对象格式
|
||||
transformed[fieldName] = {
|
||||
url: value,
|
||||
type: 'video'
|
||||
type: 'video',
|
||||
};
|
||||
} else if (fieldName.startsWith('color_')) {
|
||||
// 颜色字段转换为对象格式
|
||||
transformed[fieldName] = {
|
||||
value: value,
|
||||
type: 'color'
|
||||
value,
|
||||
type: 'color',
|
||||
};
|
||||
} else {
|
||||
// 其他类型保持原样
|
||||
transformed[fieldName] = value;
|
||||
}
|
||||
});
|
||||
@@ -59,10 +51,14 @@ function transformFormData(formData: RunTemplateData): RunTemplateData {
|
||||
export default function TemplateRunScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const globalParams = useGlobalSearchParams<{ formData?: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
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 [currentStep, setCurrentStep] = useState<RunStep>('progress');
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
const [formImageUrls, setFormImageUrls] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
progress,
|
||||
@@ -71,20 +67,16 @@ export default function TemplateRunScreen() {
|
||||
isLoading,
|
||||
executeTemplate,
|
||||
cancelRun,
|
||||
reset,
|
||||
cleanup,
|
||||
} = useTemplateRun({
|
||||
onSuccess: (result) => {
|
||||
onSuccess: () => {
|
||||
setCurrentStep('result');
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert('运行失败', error.message);
|
||||
onError: (runError) => {
|
||||
Alert.alert('运行失败', runError.message);
|
||||
},
|
||||
});
|
||||
|
||||
const { formData, errors, updateField } = useTemplateFormData();
|
||||
|
||||
// 获取模板信息
|
||||
useEffect(() => {
|
||||
const loadTemplate = async () => {
|
||||
if (!id) return;
|
||||
@@ -94,43 +86,12 @@ export default function TemplateRunScreen() {
|
||||
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('错误', '无法加载模板,请稍后重试');
|
||||
} catch (templateError) {
|
||||
console.error('加载模板失败:', templateError);
|
||||
Alert.alert('错误', '无法加载模板,请稍后重试。');
|
||||
router.back();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -140,298 +101,324 @@ export default function TemplateRunScreen() {
|
||||
loadTemplate();
|
||||
}, [id, router]);
|
||||
|
||||
// 处理返回键
|
||||
useEffect(() => {
|
||||
const handleBackPress = () => {
|
||||
if (currentStep === 'progress' && isLoading) {
|
||||
Alert.alert(
|
||||
'确认退出',
|
||||
'任务正在运行中,确定要退出吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '退出',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
cancelRun();
|
||||
router.back();
|
||||
if (template && !hasStarted && globalParams.formData) {
|
||||
try {
|
||||
const formData = JSON.parse(globalParams.formData) as RunTemplateData;
|
||||
const images = Object.entries(formData)
|
||||
.filter(([fieldName, value]) => fieldName.startsWith('image_') && typeof value === 'string' && value.length > 0)
|
||||
.map(([, value]) => value as string);
|
||||
setFormImageUrls(images);
|
||||
setHasStarted(true);
|
||||
|
||||
const executeWithData = async () => {
|
||||
try {
|
||||
const list = await subscription.list();
|
||||
const listData = list.data ?? [];
|
||||
if (listData.length === 0) {
|
||||
Alert.alert('未检测到订阅', '请先完成订阅后再尝试生成内容。');
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionId = listData[0]?.stripeSubscriptionId;
|
||||
if (!subscriptionId) {
|
||||
Alert.alert('订阅信息缺失', '当前订阅缺少 Stripe 订阅标识,无法记录用量。');
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
await subscription.credit.summary({
|
||||
subscriptionId,
|
||||
filter: {
|
||||
type: 'applicability_scope',
|
||||
applicability_scope: {
|
||||
price_type: 'metered',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
});
|
||||
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress);
|
||||
return () => subscription.remove();
|
||||
}, [currentStep, isLoading, cancelRun, router]);
|
||||
const identify = await subscription.meterEvent({
|
||||
event_name: 'token_usage',
|
||||
payload: {
|
||||
value: '100',
|
||||
},
|
||||
});
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return cleanup;
|
||||
}, [cleanup]);
|
||||
const identifier = identify.data?.identifier;
|
||||
if (!identifier) {
|
||||
Alert.alert('用量记录失败', '无法生成用量凭证,请稍后重试。');
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证表单数据
|
||||
const validateFormData = useCallback((): { isValid: boolean; message?: string } => {
|
||||
if (!template) {
|
||||
return { isValid: false, message: '模板信息未加载完成' };
|
||||
}
|
||||
const transformedData = transformFormData(formData);
|
||||
await executeTemplate(template.id, transformedData);
|
||||
} catch (executeError) {
|
||||
console.error('执行模板失败:', executeError);
|
||||
Alert.alert('执行失败', '运行模板时出现异常,请稍后重试。');
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
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}` };
|
||||
executeWithData();
|
||||
} catch (parseError) {
|
||||
console.error('解析表单数据失败:', parseError);
|
||||
Alert.alert('数据错误', '表单数据格式错误,请重新提交。');
|
||||
router.back();
|
||||
}
|
||||
}
|
||||
}, [template, hasStarted, globalParams.formData, executeTemplate, router]);
|
||||
|
||||
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 {
|
||||
const handleRequestExit = useCallback(() => {
|
||||
if (currentStep === 'progress' && isLoading) {
|
||||
Alert.alert(
|
||||
'确认提交',
|
||||
'确定要开始生成任务吗?',
|
||||
'确认退出',
|
||||
'任务正在运行中,确定要退出吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '确定', onPress: onConfirm }
|
||||
{ text: '继续等待', style: 'cancel' },
|
||||
{
|
||||
text: '停止并返回',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
cancelRun();
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = useCallback(() => {
|
||||
const validation = validateFormData();
|
||||
|
||||
if (!validation.isValid) {
|
||||
Alert.alert('表单错误', validation.message);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
const transformedData = transformFormData(formData);
|
||||
console.log('转换后的数据:', transformedData);
|
||||
router.back();
|
||||
return true;
|
||||
}, [cancelRun, currentStep, isLoading, router]);
|
||||
|
||||
const list = await subscription.list();
|
||||
const listData = list.data ?? [];
|
||||
if (listData.length === 0) {
|
||||
Alert.alert('未检测到订阅', '请先完成订阅后再尝试生成内容。');
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', handleRequestExit);
|
||||
return () => subscription.remove();
|
||||
}, [handleRequestExit]);
|
||||
|
||||
const subscriptionId = listData[0]?.stripeSubscriptionId;
|
||||
if (!subscriptionId) {
|
||||
Alert.alert('订阅信息缺失', '当前订阅缺少 Stripe 订阅标识,无法记录用量。');
|
||||
return;
|
||||
}
|
||||
useEffect(() => cleanup, [cleanup]);
|
||||
|
||||
await subscription.credit.summary({
|
||||
subscriptionId,
|
||||
filter: {
|
||||
type: 'applicability_scope',
|
||||
applicability_scope: {
|
||||
price_type: 'metered',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const identify = await subscription.meterEvent({
|
||||
event_name: 'token_usage',
|
||||
payload: {
|
||||
value: '100',
|
||||
},
|
||||
});
|
||||
|
||||
const identifier = identify.data?.identifier;
|
||||
if (!identifier) {
|
||||
Alert.alert('用量记录失败', '无法生成用量凭证,请稍后重试。');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCurrentStep('progress');
|
||||
await executeTemplate(template!.id, transformedData);
|
||||
} catch (error) {
|
||||
console.error('执行模板失败:', error);
|
||||
Alert.alert('执行失败', '运行模板时出现异常,请稍后重试。');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交表单失败:', error);
|
||||
Alert.alert('提交失败', '提交生成请求时出现问题,请稍后重试。');
|
||||
}
|
||||
};
|
||||
|
||||
showConfirmDialog(handleConfirm);
|
||||
}, [validateFormData, showConfirmDialog, executeTemplate, template, formData]);
|
||||
|
||||
// 重新运行
|
||||
const handleRerun = useCallback(() => {
|
||||
setCurrentStep('form');
|
||||
reset();
|
||||
}, [reset]);
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
// 渲染表单步骤
|
||||
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>
|
||||
)}
|
||||
const progressMeta = useMemo(() => {
|
||||
const rawProgress = typeof progress.progress === 'number' ? progress.progress : 0;
|
||||
const normalized = Math.max(0, Math.min(rawProgress, 100));
|
||||
return {
|
||||
normalized,
|
||||
percentLabel: Math.round(normalized),
|
||||
fillWidth: normalized <= 0 ? 0 : Math.min(Math.max(normalized, 8), 100),
|
||||
};
|
||||
}, [progress.progress]);
|
||||
|
||||
{/* 表单内容 */}
|
||||
<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);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
const narrative = useMemo(() => {
|
||||
const fallbackTitle = 'Insert Your Product Into An ASMR Video';
|
||||
const fallbackDescription =
|
||||
'ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.';
|
||||
|
||||
const rawTitle = template?.titleEn ?? template?.title ?? fallbackTitle;
|
||||
const rawDescription = template?.descriptionEn ?? template?.description ?? fallbackDescription;
|
||||
|
||||
return {
|
||||
title: rawTitle.toUpperCase(),
|
||||
description: rawDescription,
|
||||
};
|
||||
}, [template]);
|
||||
|
||||
const imagery = useMemo(() => {
|
||||
const fallback = template?.previewUrl ?? template?.coverImageUrl ?? undefined;
|
||||
const primary = formImageUrls[0] ?? fallback;
|
||||
const secondary = formImageUrls[1] ?? template?.previewUrl ?? primary;
|
||||
|
||||
return {
|
||||
hero: primary,
|
||||
overlay: secondary,
|
||||
preview: template?.previewUrl ?? primary ?? fallback,
|
||||
};
|
||||
}, [formImageUrls, template]);
|
||||
|
||||
const renderProgressStep = () => (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.progressContent,
|
||||
{
|
||||
paddingTop: insets.top + 16,
|
||||
paddingBottom: insets.bottom + 40,
|
||||
},
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={handleRequestExit}
|
||||
activeOpacity={0.8}
|
||||
style={[styles.backButton, styles.backButtonProgress]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.titleSection}>
|
||||
<ThemedText style={styles.title} lightColor="#FFFFFF" darkColor="#FFFFFF">
|
||||
{narrative.title}
|
||||
</ThemedText>
|
||||
<ThemedText
|
||||
style={styles.subtitle}
|
||||
lightColor="rgba(255,255,255,0.68)"
|
||||
darkColor="rgba(255,255,255,0.68)"
|
||||
>
|
||||
{narrative.description}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.heroContainer}>
|
||||
{imagery.hero ? (
|
||||
<Image source={{ uri: imagery.hero }} style={styles.heroImage} contentFit="cover" />
|
||||
) : (
|
||||
<View style={styles.noConfigContainer}>
|
||||
<ThemedText style={styles.noConfigText}>
|
||||
此模板无需配置,直接使用默认参数
|
||||
<View style={styles.heroPlaceholder} />
|
||||
)}
|
||||
|
||||
{imagery.overlay && (
|
||||
<View style={styles.overlayImageFrame}>
|
||||
<Image source={{ uri: imagery.overlay }} style={styles.overlayImage} contentFit="cover" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<LinearGradient colors={['#1E1E21', '#101015']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.progressCard}>
|
||||
{imagery.preview ? (
|
||||
<View style={styles.previewFrame}>
|
||||
<Image source={{ uri: imagery.preview }} style={styles.previewImage} contentFit="cover" />
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.previewFrame, styles.previewPlaceholder]} />
|
||||
)}
|
||||
|
||||
<View style={styles.progressCopy}>
|
||||
<ThemedText style={styles.progressHeadline} lightColor="#FFFFFF" darkColor="#FFFFFF">
|
||||
✨ 正在加载生成中...
|
||||
</ThemedText>
|
||||
<ThemedText
|
||||
style={styles.progressMessage}
|
||||
lightColor="rgba(255,255,255,0.72)"
|
||||
darkColor="rgba(255,255,255,0.72)"
|
||||
>
|
||||
{progress.message || '正在为你织造 ASMR 场景,请稍候片刻。'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressBarArea}>
|
||||
<View style={styles.progressTrack}>
|
||||
{progressMeta.fillWidth > 0 && (
|
||||
<View style={[styles.progressFill, { width: `${progressMeta.fillWidth}%` }]}>
|
||||
<LinearGradient
|
||||
colors={['#78F8FF', '#49A7FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<ThemedText
|
||||
style={styles.progressPercent}
|
||||
lightColor="rgba(255,255,255,0.6)"
|
||||
darkColor="rgba(255,255,255,0.6)"
|
||||
>
|
||||
{progressMeta.percentLabel}%
|
||||
</ThemedText>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={styles.primaryButton}
|
||||
onPress={() => {
|
||||
Alert.alert('生成进行中', '系统正在为你制作视频,请耐心等待。');
|
||||
}}
|
||||
>
|
||||
<ThemedText style={styles.primaryButtonText} lightColor="#050505" darkColor="#050505">
|
||||
Generate Video
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const renderResultStep = () => (
|
||||
<View
|
||||
style={[
|
||||
styles.resultContainer,
|
||||
{
|
||||
paddingTop: insets.top + 16,
|
||||
paddingBottom: Math.max(insets.bottom, 24),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.resultHeader}>
|
||||
<TouchableOpacity
|
||||
onPress={handleRequestExit}
|
||||
activeOpacity={0.8}
|
||||
style={[styles.backButton, styles.backButtonResult]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
<ThemedText style={styles.resultTitle} lightColor="#FFFFFF" darkColor="#FFFFFF">
|
||||
生成结果
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.resultBody}>
|
||||
{result ? (
|
||||
<ResultDisplay result={result} onRerun={handleRerun} />
|
||||
) : (
|
||||
<View style={styles.stateContainer}>
|
||||
<ThemedText style={styles.stateText} lightColor="rgba(255,255,255,0.7)" darkColor="rgba(255,255,255,0.7)">
|
||||
正在整理结果,请稍候...
|
||||
</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 style={styles.screen} lightColor="#050505" darkColor="#050505">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<View style={styles.stateContainer}>
|
||||
<ThemedText style={styles.stateText} lightColor="rgba(255,255,255,0.7)" darkColor="rgba(255,255,255,0.7)">
|
||||
加载中...
|
||||
</ThemedText>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error && !template) {
|
||||
return (
|
||||
<ThemedView style={styles.errorContainer}>
|
||||
<ThemedText style={styles.errorText}>
|
||||
{error.message}
|
||||
</ThemedText>
|
||||
<ThemedView style={styles.screen} lightColor="#050505" darkColor="#050505">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<View style={styles.stateContainer}>
|
||||
<ThemedText style={styles.stateText} lightColor="rgba(255,255,255,0.7)" darkColor="rgba(255,255,255,0.7)">
|
||||
{error.message}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染当前步骤
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedView style={styles.screen} lightColor="#050505" darkColor="#050505">
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: currentStep === 'form' ? '配置模板' :
|
||||
currentStep === 'progress' ? '生成中' : '生成结果',
|
||||
headerBackVisible: currentStep !== 'progress' || !isLoading,
|
||||
headerShown: false,
|
||||
gestureEnabled: currentStep !== 'progress' || !isLoading,
|
||||
}}
|
||||
/>
|
||||
|
||||
{currentStep === 'form' && renderFormStep()}
|
||||
{currentStep === 'progress' && renderProgressStep()}
|
||||
{currentStep === 'result' && renderResultStep()}
|
||||
</ThemedView>
|
||||
@@ -439,112 +426,182 @@ export default function TemplateRunScreen() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: '#050505',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
progressContent: {
|
||||
paddingHorizontal: 24,
|
||||
gap: 28,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
backButtonProgress: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
templateHeader: {
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
backButtonResult: {
|
||||
marginRight: 12,
|
||||
},
|
||||
titleSection: {
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.8,
|
||||
lineHeight: 28,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
},
|
||||
heroContainer: {
|
||||
position: 'relative',
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#121212',
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
aspectRatio: 1.58,
|
||||
},
|
||||
heroPlaceholder: {
|
||||
width: '100%',
|
||||
aspectRatio: 1.58,
|
||||
backgroundColor: '#1F1F20',
|
||||
},
|
||||
overlayImageFrame: {
|
||||
position: 'absolute',
|
||||
left: 18,
|
||||
bottom: 18,
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 2,
|
||||
borderColor: '#050505',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
},
|
||||
headerGradient: {
|
||||
paddingTop: 20,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 20,
|
||||
overlayImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
templateInfo: {
|
||||
progressCard: {
|
||||
borderRadius: 28,
|
||||
paddingVertical: 28,
|
||||
paddingHorizontal: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.06)',
|
||||
gap: 24,
|
||||
},
|
||||
previewFrame: {
|
||||
alignSelf: 'center',
|
||||
width: 96,
|
||||
height: 148,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.12)',
|
||||
backgroundColor: '#0F0F12',
|
||||
},
|
||||
previewImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
previewPlaceholder: {
|
||||
backgroundColor: '#18181C',
|
||||
},
|
||||
progressCopy: {
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
progressHeadline: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
progressMessage: {
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
progressBarArea: {
|
||||
gap: 12,
|
||||
},
|
||||
progressTrack: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressPercent: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
textAlign: 'right',
|
||||
},
|
||||
primaryButton: {
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#D7FF1F',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#D7FF1F',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
elevation: 4,
|
||||
},
|
||||
primaryButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
resultContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 24,
|
||||
gap: 24,
|
||||
},
|
||||
resultHeader: {
|
||||
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: {
|
||||
resultTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
resultBody: {
|
||||
flex: 1,
|
||||
},
|
||||
stateContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
stateText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
295
app/templates/[id].tsx
Normal file
295
app/templates/[id].tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
type TemplateFrame = {
|
||||
id: string;
|
||||
title: string;
|
||||
uri: string;
|
||||
accent: string;
|
||||
};
|
||||
|
||||
const curatedLooks: TemplateFrame[] = [
|
||||
{
|
||||
id: 'earth-zoom',
|
||||
title: 'Earth Zoom Our',
|
||||
uri: 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=1200&q=80',
|
||||
accent: '#D1FF00',
|
||||
},
|
||||
{
|
||||
id: 'midnight-atelier',
|
||||
title: 'Midnight Atelier',
|
||||
uri: 'https://images.unsplash.com/photo-1500336624523-d727130c3328?auto=format&fit=crop&w=1200&q=80',
|
||||
accent: '#FF7A45',
|
||||
},
|
||||
{
|
||||
id: 'flora-couture',
|
||||
title: 'Flora Couture',
|
||||
uri: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=1200&q=80',
|
||||
accent: '#7A8BFF',
|
||||
},
|
||||
{
|
||||
id: 'noir-silhouette',
|
||||
title: 'Noir Silhouette',
|
||||
uri: 'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&w=1200&q=80',
|
||||
accent: '#FF5F6D',
|
||||
},
|
||||
{
|
||||
id: 'aurora-thread',
|
||||
title: 'Aurora Thread',
|
||||
uri: 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=1200&q=80',
|
||||
accent: '#21D4FD',
|
||||
},
|
||||
{
|
||||
id: 'velvet-echo',
|
||||
title: 'Velvet Echo',
|
||||
uri: 'https://images.unsplash.com/photo-1512436991641-6745cdb1723f?auto=format&fit=crop&w=1200&q=80',
|
||||
accent: '#FFAA00',
|
||||
},
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ id: 'upscale', label: 'Upscale' },
|
||||
{ id: 'collect', label: 'Collect' },
|
||||
{ id: 'download', label: 'Download' },
|
||||
];
|
||||
|
||||
export default function TemplateDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const [selectedId, setSelectedId] = useState<string>(curatedLooks[0].id);
|
||||
const [menuVisible, setMenuVisible] = useState(true);
|
||||
|
||||
const current = useMemo(
|
||||
() => curatedLooks.find(look => look.id === selectedId) ?? curatedLooks[0],
|
||||
[selectedId],
|
||||
);
|
||||
|
||||
const resolvedTemplateId = typeof id === 'string' && id.length > 0 ? id : current.id;
|
||||
|
||||
const handleGenerate = () => {
|
||||
router.push(`/templates/${resolvedTemplateId}/form`);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.canvas}>
|
||||
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<View style={styles.topCarousel}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.carouselContent}
|
||||
>
|
||||
{curatedLooks.map(look => {
|
||||
const isActive = look.id === selectedId;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={look.id}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => setSelectedId(look.id)}
|
||||
style={[
|
||||
styles.previewFrame,
|
||||
{ borderColor: isActive ? look.accent : 'rgba(255,255,255,0.08)' },
|
||||
isActive && styles.previewFrameActive,
|
||||
]}
|
||||
>
|
||||
<Image source={{ uri: look.uri }} style={styles.previewImage} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<View style={styles.heroStage}>
|
||||
<Image source={{ uri: current.uri }} style={styles.heroImage} />
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomBar}>
|
||||
<View style={styles.infoCard}>
|
||||
<Image source={{ uri: current.uri }} style={styles.infoThumbnail} />
|
||||
<Text style={styles.infoTitle} numberOfLines={1}>
|
||||
{current.title}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.generateButton}
|
||||
activeOpacity={0.9}
|
||||
onPress={handleGenerate}
|
||||
>
|
||||
<Text style={styles.generateLabel}>Generate Video</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.menuTrigger}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => setMenuVisible(prev => !prev)}
|
||||
>
|
||||
<View style={styles.menuDots}>
|
||||
<View style={styles.dot} />
|
||||
<View style={[styles.dot, styles.dotMiddle]} />
|
||||
<View style={styles.dot} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{menuVisible && (
|
||||
<View style={styles.menuPanel}>
|
||||
{quickActions.map(action => (
|
||||
<TouchableOpacity
|
||||
key={action.id}
|
||||
activeOpacity={0.85}
|
||||
style={styles.menuItem}
|
||||
onPress={() => setMenuVisible(false)}
|
||||
>
|
||||
<Text style={styles.menuLabel}>{action.label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
canvas: {
|
||||
flex: 1,
|
||||
backgroundColor: '#040404',
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 120,
|
||||
},
|
||||
topCarousel: {
|
||||
marginTop: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.04)',
|
||||
},
|
||||
carouselContent: {
|
||||
paddingHorizontal: 12,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
previewFrame: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
previewFrameActive: {
|
||||
transform: [{ scale: 1.05 }],
|
||||
},
|
||||
previewImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
heroStage: {
|
||||
marginTop: 32,
|
||||
borderRadius: 36,
|
||||
backgroundColor: '#111111',
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 32,
|
||||
shadowOffset: { width: 0, height: 18 },
|
||||
elevation: 24,
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: 456,
|
||||
borderRadius: 36,
|
||||
},
|
||||
bottomBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
marginTop: 32,
|
||||
},
|
||||
infoCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
infoThumbnail: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
marginRight: 12,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
maxWidth: 120,
|
||||
},
|
||||
generateButton: {
|
||||
flex: 1,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: '#D1FF00',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
generateLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#050505',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
menuTrigger: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
menuDots: {
|
||||
justifyContent: 'space-between',
|
||||
height: 24,
|
||||
},
|
||||
dot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
dotMiddle: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
menuPanel: {
|
||||
position: 'absolute',
|
||||
right: 24,
|
||||
bottom: 160,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(10, 10, 10, 0.94)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
gap: 12,
|
||||
},
|
||||
menuItem: {
|
||||
paddingVertical: 4,
|
||||
},
|
||||
menuLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
709
app/templates/[id]/form.tsx
Normal file
709
app/templates/[id]/form.tsx
Normal file
@@ -0,0 +1,709 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { ArrowLeft, UploadCloud } from 'lucide-react';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
|
||||
import { DynamicForm } from '@/components/forms/dynamic-form';
|
||||
import { getTemplateById } from '@/lib/api/templates';
|
||||
import { Template } from '@/lib/types/template';
|
||||
import { RunFormSchema, RunTemplateData } from '@/lib/types/template-run';
|
||||
import { transformWorkflowToFormSchema, validateFormSchema } from '@/lib/utils/form-schema-transformer';
|
||||
|
||||
interface Step1FormData {
|
||||
characterImage: string;
|
||||
productImage: string;
|
||||
script: string;
|
||||
}
|
||||
|
||||
interface Step2FormData extends RunTemplateData {}
|
||||
|
||||
interface FormData {
|
||||
step1: Step1FormData;
|
||||
step2: Step2FormData;
|
||||
}
|
||||
|
||||
type UploadFieldKey = 'characterImage' | 'productImage';
|
||||
|
||||
const FALLBACK_PREVIEW =
|
||||
'https://images.unsplash.com/photo-1542204165-0198c1e21a3b?auto=format&fit=crop&w=1200&q=80';
|
||||
const FALLBACK_INSET =
|
||||
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=640&q=80';
|
||||
|
||||
export default function TemplateFormScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [template, setTemplate] = useState<Template | null>(null);
|
||||
const [formSchema, setFormSchema] = useState<RunFormSchema>({ fields: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
step1: {
|
||||
characterImage: '',
|
||||
productImage: '',
|
||||
script: '',
|
||||
},
|
||||
step2: {},
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
}, [id]);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const templateResponse = await getTemplateById(id);
|
||||
|
||||
if (templateResponse.success && templateResponse.data) {
|
||||
setTemplate(templateResponse.data);
|
||||
|
||||
if (templateResponse.data.formSchema) {
|
||||
try {
|
||||
const rawData =
|
||||
typeof templateResponse.data.formSchema === 'string'
|
||||
? JSON.parse(templateResponse.data.formSchema as string)
|
||||
: templateResponse.data.formSchema;
|
||||
|
||||
const formConfig = transformWorkflowToFormSchema(rawData);
|
||||
|
||||
if (validateFormSchema(formConfig)) {
|
||||
setFormSchema(formConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse form schema:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load template:', error);
|
||||
Alert.alert('Error', 'Failed to load data. Please retry later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateStep1 = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.step1.characterImage) {
|
||||
newErrors.characterImage = 'Please upload the character image';
|
||||
}
|
||||
|
||||
if (!formData.step1.productImage) {
|
||||
newErrors.productImage = 'Please upload the product image';
|
||||
}
|
||||
|
||||
if (!formData.step1.script.trim()) {
|
||||
newErrors.script = 'Please provide the character script';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleStep1Next = () => {
|
||||
if (validateStep1()) {
|
||||
setCurrentStep(2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStep2Prev = () => {
|
||||
setCurrentStep(1);
|
||||
};
|
||||
|
||||
const handleStep2Submit = async () => {
|
||||
if (!template) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const combinedData = {
|
||||
...formData.step1,
|
||||
...formData.step2,
|
||||
};
|
||||
|
||||
console.log('Submitting template payload:', combinedData);
|
||||
|
||||
Alert.alert('Success', 'Template configuration saved.', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
router.push(`/templates/${id}/run`);
|
||||
},
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Submission failed:', error);
|
||||
Alert.alert('Error', 'Submission failed. Please retry later.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImagePick = async (field: UploadFieldKey) => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (!permission.granted) {
|
||||
Alert.alert(
|
||||
'Permission Required',
|
||||
'Please allow photo library access in Settings to upload images.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
if (result.canceled || !result.assets || result.assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uri = result.assets[0].uri;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
step1: {
|
||||
...prev.step1,
|
||||
[field]: uri,
|
||||
},
|
||||
}));
|
||||
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[field]: '',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick image:', error);
|
||||
Alert.alert('Error', 'Unable to pick image. Please try again later.');
|
||||
}
|
||||
};
|
||||
|
||||
const renderUploadCard = (
|
||||
field: UploadFieldKey,
|
||||
label: string,
|
||||
description: string,
|
||||
) => {
|
||||
const value = formData.step1[field];
|
||||
|
||||
return (
|
||||
<View style={styles.uploadItem}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={() => handleImagePick(field)}
|
||||
style={[
|
||||
styles.uploadCard,
|
||||
value && styles.uploadCardFilled,
|
||||
errors[field] && styles.uploadCardError,
|
||||
]}
|
||||
>
|
||||
{value ? (
|
||||
<Image source={{ uri: value }} style={styles.uploadImage} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.uploadIconWrap}>
|
||||
<UploadCloud size={26} color="#D1FF00" strokeWidth={1.4} />
|
||||
</View>
|
||||
<Text style={styles.uploadLabel}>{label}</Text>
|
||||
<Text style={styles.uploadDescription}>{description}</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{errors[field] ? (
|
||||
<Text style={styles.inlineError}>{errors[field]}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStep1 = () => {
|
||||
const heroImage = template?.previewUrl || template?.coverImageUrl || FALLBACK_PREVIEW;
|
||||
const insetImage = template?.coverImageUrl || FALLBACK_INSET;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.stepScroll}
|
||||
contentContainerStyle={styles.step1Content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={() => router.back()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={22} color="#FFFFFF" strokeWidth={2.4} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={styles.heroTitle}>
|
||||
{(template?.title ?? 'Insert your product into an ASMR video').toUpperCase()}
|
||||
</Text>
|
||||
<Text style={styles.heroSubtitle}>
|
||||
{template?.description ||
|
||||
'ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.'}
|
||||
</Text>
|
||||
|
||||
<View style={styles.previewStage}>
|
||||
<Image source={{ uri: heroImage }} style={styles.previewImage} />
|
||||
<View style={styles.previewInset}>
|
||||
<Image source={{ uri: insetImage }} style={styles.previewInsetImage} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.uploadRow}>
|
||||
{renderUploadCard(
|
||||
'characterImage',
|
||||
'UPLOAD CHARACTER IMAGE',
|
||||
'PNG, JPG or Paste from clipboard',
|
||||
)}
|
||||
{renderUploadCard(
|
||||
'productImage',
|
||||
'UPLOAD CHARACTER IMAGE',
|
||||
'PNG, JPG or Paste from clipboard',
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.promptField}>
|
||||
<TextInput
|
||||
style={[styles.promptInput, errors.script && styles.promptInputError]}
|
||||
placeholder="What should your character say?"
|
||||
placeholderTextColor="rgba(255, 255, 255, 0.5)"
|
||||
value={formData.step1.script}
|
||||
onChangeText={text => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
step1: {
|
||||
...prev.step1,
|
||||
script: text,
|
||||
},
|
||||
}));
|
||||
if (errors.script) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
script: '',
|
||||
}));
|
||||
}
|
||||
}}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
{errors.script ? <Text style={styles.inlineError}>{errors.script}</Text> : null}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.generateButton}
|
||||
activeOpacity={0.88}
|
||||
onPress={handleStep1Next}
|
||||
>
|
||||
<Feather name="star" size={20} color="#050505" />
|
||||
<Text style={styles.generateLabel}>Generate Video</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStep2 = () => (
|
||||
<View style={styles.step2Wrapper}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={handleStep2Prev}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={22} color="#FFFFFF" strokeWidth={2.4} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.stepIndicator}>STEP 2 OF 2</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.step2Title}>Run Parameters</Text>
|
||||
<Text style={styles.step2Subtitle}>
|
||||
Fine-tune the details so the ASMR video delivers the mood you expect.
|
||||
</Text>
|
||||
|
||||
<View style={styles.step2FormShell}>
|
||||
{formSchema.fields.length > 0 ? (
|
||||
<DynamicForm
|
||||
schema={formSchema}
|
||||
initialData={formData.step2}
|
||||
onDataChange={data => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
step2: data,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>📋</Text>
|
||||
<Text style={styles.emptyText}>No extra parameters</Text>
|
||||
<Text style={styles.emptyDescription}>
|
||||
This template is ready to go—start generating the ASMR experience instantly.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderBottomActions = () => {
|
||||
if (currentStep !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={['bottom']} style={styles.bottomSafeArea}>
|
||||
<View style={styles.bottomActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
activeOpacity={0.85}
|
||||
onPress={handleStep2Prev}
|
||||
>
|
||||
<Text style={styles.secondaryLabel}>Back</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.submitButton}
|
||||
activeOpacity={0.9}
|
||||
onPress={handleStep2Submit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color="#050505" />
|
||||
) : (
|
||||
<>
|
||||
<Feather name="zap" size={18} color="#050505" />
|
||||
<Text style={styles.submitLabel}>Generate Video</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.loadingCanvas}>
|
||||
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#D1FF00" />
|
||||
<Text style={styles.loadingText}>Loading...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.canvas}>
|
||||
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
|
||||
{currentStep === 1 ? renderStep1() : renderStep2()}
|
||||
</SafeAreaView>
|
||||
{renderBottomActions()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
canvas: {
|
||||
flex: 1,
|
||||
backgroundColor: '#050505',
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingCanvas: {
|
||||
flex: 1,
|
||||
backgroundColor: '#050505',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
stepScroll: {
|
||||
flex: 1,
|
||||
},
|
||||
step1Content: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 28,
|
||||
},
|
||||
backButton: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
backgroundColor: 'rgba(18, 18, 18, 0.78)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
stepIndicator: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
fontSize: 13,
|
||||
letterSpacing: 2,
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 26,
|
||||
lineHeight: 32,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 16,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: 'rgba(255, 255, 255, 0.68)',
|
||||
marginBottom: 24,
|
||||
},
|
||||
previewStage: {
|
||||
borderRadius: 32,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#111318',
|
||||
marginBottom: 28,
|
||||
},
|
||||
previewImage: {
|
||||
width: '100%',
|
||||
height: 220,
|
||||
},
|
||||
previewInset: {
|
||||
position: 'absolute',
|
||||
bottom: 18,
|
||||
left: 18,
|
||||
width: 74,
|
||||
height: 74,
|
||||
borderRadius: 24,
|
||||
borderWidth: 2,
|
||||
borderColor: '#050505',
|
||||
backgroundColor: '#050505',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
previewInsetImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
uploadRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 18,
|
||||
marginBottom: 24,
|
||||
},
|
||||
uploadItem: {
|
||||
flex: 1,
|
||||
},
|
||||
uploadCard: {
|
||||
borderRadius: 28,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backgroundColor: 'rgba(17, 19, 24, 0.95)',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 188,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
uploadCardFilled: {
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
},
|
||||
uploadCardError: {
|
||||
borderColor: '#FF6B6B',
|
||||
},
|
||||
uploadIconWrap: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(209, 255, 0, 0.12)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
uploadLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.6,
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 6,
|
||||
},
|
||||
uploadDescription: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
color: 'rgba(255, 255, 255, 0.55)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
uploadImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
promptField: {
|
||||
marginBottom: 18,
|
||||
},
|
||||
promptInput: {
|
||||
borderRadius: 28,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backgroundColor: 'rgba(17, 19, 24, 0.95)',
|
||||
minHeight: 140,
|
||||
paddingHorizontal: 22,
|
||||
paddingVertical: 22,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
promptInputError: {
|
||||
borderColor: '#FF6B6B',
|
||||
},
|
||||
inlineError: {
|
||||
marginTop: 8,
|
||||
fontSize: 12,
|
||||
color: '#FF6B6B',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
generateButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 58,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#D1FF00',
|
||||
gap: 10,
|
||||
},
|
||||
generateLabel: {
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.4,
|
||||
color: '#050505',
|
||||
},
|
||||
step2Wrapper: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
step2Title: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.6,
|
||||
marginBottom: 10,
|
||||
},
|
||||
step2Subtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
marginBottom: 20,
|
||||
},
|
||||
step2FormShell: {
|
||||
flex: 1,
|
||||
borderRadius: 32,
|
||||
backgroundColor: 'rgba(17, 19, 24, 0.78)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 16,
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyDescription: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
bottomSafeArea: {
|
||||
backgroundColor: '#050505',
|
||||
},
|
||||
bottomActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 24,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
secondaryButton: {
|
||||
flex: 1,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
secondaryLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: 'rgba(255, 255, 255, 0.78)',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
submitButton: {
|
||||
flex: 1.6,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: '#D1FF00',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
submitLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#050505',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user