✨ feat: 完整应用重构 - 优化界面架构与用户体验
主要变更: - 重构应用界面:优化首页、登录、认证流程 - 新增用户档案模块:包含完整的档案管理组件 - 优化路由结构:重新组织页面布局和导航 - 改进API集成:新增活动、内容分类等API模块 - 删除冗余组件:清理不必要的文件和依赖 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Image } from 'react-native';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
@@ -26,21 +26,36 @@ export default function TabLayout() {
|
||||
name="home"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
tabBarIcon: ({ color }) => (
|
||||
<Image
|
||||
source={require('@/assets/icons/home.svg')}
|
||||
style={{ width: 28, height: 28, tintColor: color as any }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="history"
|
||||
options={{
|
||||
title: 'Content',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
tabBarIcon: ({ color }) => (
|
||||
<Image
|
||||
source={require('@/assets/icons/content.svg')}
|
||||
style={{ width: 28, height: 28, tintColor: color as any }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="person.fill" color={color} />,
|
||||
tabBarIcon: ({ color }) => (
|
||||
<Image
|
||||
source={require('@/assets/icons/user.svg')}
|
||||
style={{ width: 28, height: 28, tintColor: color as any }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
233
app/(tabs)/home.tsx
Normal file
233
app/(tabs)/home.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useEffect, 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 { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { getActivities, type Activity } from '@/lib/api/activities';
|
||||
import { categoriesWithChildren } from '@/lib/api/categories-with-children';
|
||||
import type { CategoryTemplate, CategoryWithChildren } from '@/lib/types/template';
|
||||
|
||||
function coalesceText(...values: Array<string | null | undefined>): string {
|
||||
for (const value of values) {
|
||||
const trimmed = (value ?? '').trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function translateTemplateToCommunity(template: CategoryTemplate): CommunityItem | null {
|
||||
const image = coalesceText(template.coverImageUrl, template.previewUrl);
|
||||
const title = coalesceText(template.titleEn, template.title);
|
||||
if (!image || !title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primaryTag = template.tags?.[0];
|
||||
const chip = coalesceText(primaryTag?.nameEn, primaryTag?.name, template.aspectRatio) || 'Featured';
|
||||
|
||||
return {
|
||||
id: template.id,
|
||||
title,
|
||||
image,
|
||||
chip,
|
||||
actionLabel: 'Generate',
|
||||
};
|
||||
}
|
||||
|
||||
function translateActivity(activity: Activity): FeatureItem | null {
|
||||
const image = (activity.coverUrl || '').trim();
|
||||
const title = (activity.titleEn || '').trim() || (activity.title || '').trim();
|
||||
if (!image || !title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subtitle = (activity.descEn || '').trim() || (activity.desc || '').trim();
|
||||
|
||||
return {
|
||||
id: activity.id,
|
||||
title,
|
||||
subtitle,
|
||||
image,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { requireAuth } = useAuthGuard();
|
||||
const [activeCategory, setActiveCategory] = useState<string>();
|
||||
const [activityFeatures, setActivityFeatures] = useState<FeatureItem[]>([]);
|
||||
const [categoryCollection, setCategoryCollection] = useState<CategoryWithChildren[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const hydrateFeatureCarousel = async () => {
|
||||
try {
|
||||
const activities = await getActivities({ isActive: true });
|
||||
console.log({ activities })
|
||||
|
||||
const curatedFeatures = activities
|
||||
.map(translateActivity)
|
||||
.filter((feature): feature is FeatureItem => feature !== null);
|
||||
if (curatedFeatures.length > 0) {
|
||||
setActivityFeatures(curatedFeatures);
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
console.warn('FeatureCarousel activities feed unavailable:', detail);
|
||||
}
|
||||
};
|
||||
|
||||
hydrateFeatureCarousel();
|
||||
|
||||
return () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const hydrateCategories = async () => {
|
||||
try {
|
||||
const response = await categoriesWithChildren();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = response.success && Array.isArray(response.data) ? response.data : [];
|
||||
const curated = payload.filter((category) => category.id && (category.isActive ?? true));
|
||||
|
||||
setCategoryCollection(curated.length > 0 ? curated : payload);
|
||||
} catch (error) {
|
||||
if (isActive) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
console.warn('Categories feed unavailable:', detail);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
hydrateCategories();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryCollection.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasActive = categoryCollection.some((category) => category.id === activeCategory);
|
||||
if (!hasActive) {
|
||||
setActiveCategory(categoryCollection[0].id);
|
||||
}
|
||||
}, [categoryCollection, activeCategory]);
|
||||
|
||||
const categoryOptions = useMemo(() => {
|
||||
if (categoryCollection.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = categoryCollection
|
||||
.map((category) => {
|
||||
const label = coalesceText(category.nameEn, category.name);
|
||||
return label
|
||||
? {
|
||||
id: category.id,
|
||||
label,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter((category): category is { id: string; label: string } => category !== null);
|
||||
|
||||
return options.length > 0 ? options : [];
|
||||
}, [categoryCollection]);
|
||||
|
||||
|
||||
const activeCategoryRecord = useMemo(() => {
|
||||
return categoryCollection.find((category) => category.id === activeCategory) ?? null;
|
||||
}, [categoryCollection, activeCategory]);
|
||||
|
||||
const templateCommunityItems = useMemo(() => {
|
||||
if (!activeCategoryRecord) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (activeCategoryRecord.templates ?? [])
|
||||
.map(translateTemplateToCommunity)
|
||||
.filter((item): item is CommunityItem => item !== null);
|
||||
}, [activeCategoryRecord]);
|
||||
|
||||
const featureItems = useMemo(() => {
|
||||
return activityFeatures.map((item, index) => ({
|
||||
...item,
|
||||
id: `${activeCategory}-${item.id || index}`,
|
||||
}));
|
||||
}, [activityFeatures]);
|
||||
|
||||
const communityItems = useMemo(() => {
|
||||
const source: CommunityItem[] =
|
||||
templateCommunityItems.length > 0 ? templateCommunityItems : [];
|
||||
|
||||
return source.map((item, index) => ({
|
||||
...item,
|
||||
id: `${activeCategory}-${item.id || index}`,
|
||||
}));
|
||||
}, [activeCategory, templateCommunityItems]);
|
||||
|
||||
const handleGeneratePress = (item: CommunityItem) => {
|
||||
requireAuth(() => {
|
||||
console.log('用户已登录,执行生成操作', item.id);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFeaturePress = (item: FeatureItemType) => {
|
||||
requireAuth(() => {
|
||||
console.log('用户已登录,使用功能:', item.title);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout backgroundColor="#050505">
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<StatusBarSpacer />
|
||||
<Header />
|
||||
<CategoryTabs categories={categoryOptions} activeId={activeCategory || ``} onChange={setActiveCategory} />
|
||||
{featureItems.length > 0 && <FeatureCarousel items={featureItems} onPress={handleFeaturePress} />}
|
||||
<SectionHeader title={activeCategoryRecord?.name || ``} />
|
||||
<CommunityGrid items={communityItems} onPressAction={handleGeneratePress} />
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingTop: 16,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
bottomSpacer: {
|
||||
height: 32,
|
||||
},
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
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 { 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('用户已登录,执行生成操作');
|
||||
});
|
||||
};
|
||||
|
||||
const handleFeaturePress = (item: FeatureItemType) => {
|
||||
requireAuth(() => {
|
||||
console.log('用户已登录,使用功能:', item.title);
|
||||
});
|
||||
};
|
||||
|
||||
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} onPress={handleFeaturePress} />
|
||||
<SectionHeader title="WAN 2.5 COMMUNITY" />
|
||||
<CommunityGrid items={communityItems} onPressAction={handleGeneratePress} />
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingTop: 16,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
bottomSpacer: {
|
||||
height: 32,
|
||||
},
|
||||
});
|
||||
@@ -1,651 +1,3 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Image,
|
||||
type ImageSourcePropType,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { ProfileScreen } from '@/components/profile/profile-screen';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
type TabKey = 'all' | 'image' | 'video';
|
||||
type BillingMode = 'monthly' | 'lifetime';
|
||||
|
||||
type Palette = {
|
||||
background: string;
|
||||
surface: string;
|
||||
elevated: string;
|
||||
border: string;
|
||||
pill: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
accent: string;
|
||||
onAccent: string;
|
||||
tabActive: string;
|
||||
avatarBackdrop: string;
|
||||
glyphBackdrop: string;
|
||||
};
|
||||
|
||||
type IdentityStat = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'image', label: 'Image' },
|
||||
{ key: 'video', label: 'Video' },
|
||||
];
|
||||
|
||||
const BILLING_OPTIONS: { key: BillingMode; label: string }[] = [
|
||||
{ key: 'monthly', label: '月付' },
|
||||
];
|
||||
|
||||
const palettes: Record<'dark' | 'light', Palette> = {
|
||||
dark: {
|
||||
background: '#050505',
|
||||
surface: '#121216',
|
||||
elevated: '#101014',
|
||||
border: '#1D1E24',
|
||||
pill: '#16171C',
|
||||
textPrimary: '#F6F7FA',
|
||||
textSecondary: '#8E9098',
|
||||
accent: '#B7FF2F',
|
||||
onAccent: '#050505',
|
||||
tabActive: '#FFFFFF',
|
||||
avatarBackdrop: '#1F2026',
|
||||
glyphBackdrop: '#18181D',
|
||||
},
|
||||
light: {
|
||||
background: '#F7F8FB',
|
||||
surface: '#FFFFFF',
|
||||
elevated: '#F0F2F8',
|
||||
border: '#E2E5ED',
|
||||
pill: '#E8EBF4',
|
||||
textPrimary: '#0F1320',
|
||||
textSecondary: '#5E6474',
|
||||
accent: '#405CFF',
|
||||
onAccent: '#FFFFFF',
|
||||
tabActive: '#FFFFFF',
|
||||
avatarBackdrop: '#D5D8E2',
|
||||
glyphBackdrop: '#E8EBF4',
|
||||
},
|
||||
};
|
||||
|
||||
const STAT_BLUEPRINT: IdentityStat[] = [
|
||||
{ key: 'likes', label: 'likes', value: 0 },
|
||||
{ key: 'posts', label: 'posts', value: 0 },
|
||||
{ key: 'views', label: 'views', value: 0 },
|
||||
];
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user } = useAuth();
|
||||
const colorScheme = useColorScheme();
|
||||
const palette = palettes[colorScheme === 'dark' ? 'dark' : 'light'];
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width } = useWindowDimensions();
|
||||
const [billingMode, setBillingMode] = useState<BillingMode>('monthly');
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
||||
|
||||
const displayName = deriveDisplayName(user) ?? 'prairie_pufferfish_the';
|
||||
const avatarSource = deriveAvatarSource(user);
|
||||
const creditBalance = deriveNumericValue((user as Record<string, unknown>)?.credits);
|
||||
const stats = useMemo(() => createStats(user), [user]);
|
||||
|
||||
const horizontalPadding = width < 400 ? 20 : 24;
|
||||
const avatarSize = width < 400 ? 74 : 82;
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: palette.background }]}>
|
||||
{insets.top > 0 && <View style={{ height: insets.top }} />}
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: horizontalPadding,
|
||||
paddingTop: 20,
|
||||
paddingBottom: Math.max(insets.bottom + 36, 96),
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<HeaderRow
|
||||
palette={palette}
|
||||
billingMode={billingMode}
|
||||
credits={creditBalance}
|
||||
onChangeBilling={setBillingMode}
|
||||
/>
|
||||
<IdentitySection
|
||||
palette={palette}
|
||||
displayName={displayName}
|
||||
avatarSource={avatarSource}
|
||||
avatarSize={avatarSize}
|
||||
stats={stats}
|
||||
/>
|
||||
<Divider palette={palette} />
|
||||
<ContentTabs palette={palette} activeTab={activeTab} onChangeTab={setActiveTab} />
|
||||
<Divider palette={palette} />
|
||||
<EmptyGallery palette={palette} activeTab={activeTab} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderRow({
|
||||
palette,
|
||||
billingMode,
|
||||
onChangeBilling,
|
||||
credits,
|
||||
}: {
|
||||
palette: Palette;
|
||||
billingMode: BillingMode;
|
||||
onChangeBilling: (mode: BillingMode) => void;
|
||||
credits: number;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/settings/account')}
|
||||
style={[styles.settingsButton, { backgroundColor: palette.pill }]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons name="settings" size={24} color={palette.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<BillingBadge palette={palette} billingMode={billingMode} onChangeBilling={onChangeBilling} credits={credits} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingBadge({
|
||||
palette,
|
||||
billingMode,
|
||||
onChangeBilling,
|
||||
credits,
|
||||
}: {
|
||||
palette: Palette;
|
||||
billingMode: BillingMode;
|
||||
onChangeBilling: (mode: BillingMode) => void;
|
||||
credits: number;
|
||||
}) {
|
||||
return (
|
||||
<View style={[styles.billingShell, { backgroundColor: palette.pill, borderColor: palette.border }]}>
|
||||
<View style={styles.billingOptions}>
|
||||
{BILLING_OPTIONS.map(option => {
|
||||
const isActive = option.key === billingMode;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.key}
|
||||
onPress={() => onChangeBilling(option.key)}
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.billingOption,
|
||||
{
|
||||
backgroundColor: isActive ? palette.tabActive : 'transparent',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.billingLabel,
|
||||
{
|
||||
color: isActive ? palette.onAccent : palette.textSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.lightningShell,
|
||||
{
|
||||
backgroundColor: palette.elevated,
|
||||
borderColor: palette.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="flash-on" size={16} color={palette.accent} />
|
||||
<ThemedText style={[styles.lightningValue, { color: palette.textPrimary }]}>{credits}</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentitySection({
|
||||
palette,
|
||||
displayName,
|
||||
avatarSource,
|
||||
avatarSize,
|
||||
stats,
|
||||
}: {
|
||||
palette: Palette;
|
||||
displayName: string;
|
||||
avatarSource?: ImageSourcePropType;
|
||||
avatarSize: number;
|
||||
stats: IdentityStat[];
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.identityRow}>
|
||||
<Avatar palette={palette} size={avatarSize} source={avatarSource} fallback={displayName} />
|
||||
<View style={styles.identityDetails}>
|
||||
<View style={styles.nameRow}>
|
||||
<ThemedText numberOfLines={1} style={[styles.username, { color: palette.textPrimary }]}>
|
||||
{displayName}
|
||||
</ThemedText>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/settings/account')}
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.editButton,
|
||||
{
|
||||
borderColor: palette.border,
|
||||
backgroundColor: palette.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="edit" size={16} color={palette.textPrimary} style={styles.editIcon} />
|
||||
<ThemedText style={[styles.editLabel, { color: palette.textPrimary }]}>Edit</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.statRow}>
|
||||
{stats.map(stat => (
|
||||
<StatItem key={stat.key} palette={palette} label={stat.label} value={stat.value} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({
|
||||
palette,
|
||||
size,
|
||||
source,
|
||||
fallback,
|
||||
}: {
|
||||
palette: Palette;
|
||||
size: number;
|
||||
source?: ImageSourcePropType;
|
||||
fallback: string;
|
||||
}) {
|
||||
const initials = getInitials(fallback);
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.avatarShell,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: palette.avatarBackdrop,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{source ? (
|
||||
<Image source={source} style={{ width: size, height: size, borderRadius: size / 2 }} resizeMode="cover" />
|
||||
) : (
|
||||
<ThemedText style={[styles.avatarInitials, { color: palette.textPrimary }]}>{initials}</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ palette, label, value }: { palette: Palette; label: string; value: number }) {
|
||||
return (
|
||||
<View style={styles.statItem}>
|
||||
<ThemedText style={[styles.statValue, { color: palette.textPrimary }]}>{value}</ThemedText>
|
||||
<ThemedText style={[styles.statLabel, { color: palette.textSecondary }]}>{label}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider({ palette }: { palette: Palette }) {
|
||||
return <View style={[styles.divider, { backgroundColor: palette.border }]} />;
|
||||
}
|
||||
|
||||
function ContentTabs({
|
||||
palette,
|
||||
activeTab,
|
||||
onChangeTab,
|
||||
}: {
|
||||
palette: Palette;
|
||||
activeTab: TabKey;
|
||||
onChangeTab: (tab: TabKey) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={[styles.tabsBar, { backgroundColor: palette.pill, borderColor: palette.border }]}>
|
||||
{TABS.map(tab => {
|
||||
const isActive = tab.key === activeTab;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => onChangeTab(tab.key)}
|
||||
style={[
|
||||
styles.tabButton,
|
||||
{
|
||||
backgroundColor: isActive ? palette.tabActive : 'transparent',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
{
|
||||
color: isActive ? palette.onAccent : palette.textSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyGallery({ palette, activeTab }: { palette: Palette; activeTab: TabKey }) {
|
||||
const copy = deriveEmptyCopy(activeTab);
|
||||
return (
|
||||
<View style={styles.emptyWrap}>
|
||||
<View
|
||||
style={[
|
||||
styles.emptyGlyph,
|
||||
{
|
||||
backgroundColor: palette.glyphBackdrop,
|
||||
borderColor: palette.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="image" size={40} color={palette.accent} />
|
||||
</View>
|
||||
<ThemedText style={[styles.emptyTitle, { color: palette.textPrimary }]}>{copy.title}</ThemedText>
|
||||
<ThemedText style={[styles.emptySubtitle, { color: palette.textSecondary }]}>{copy.subtitle}</ThemedText>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={() => router.push('/(tabs)/explore')}
|
||||
style={[
|
||||
styles.generateButton,
|
||||
{
|
||||
borderColor: palette.accent,
|
||||
backgroundColor: palette.elevated,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={18} color={palette.accent} style={styles.generateIcon} />
|
||||
<ThemedText style={[styles.generateLabel, { color: palette.accent }]}>Generate</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function deriveEmptyCopy(activeTab: TabKey) {
|
||||
if (activeTab === 'image') {
|
||||
return {
|
||||
title: 'No Images yet',
|
||||
subtitle: 'Pick a style, enter a prompt, and generate your image.',
|
||||
};
|
||||
}
|
||||
if (activeTab === 'video') {
|
||||
return {
|
||||
title: 'No Videos yet',
|
||||
subtitle: 'Pick a style, enter a prompt, and craft your first clip.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'No Content yet',
|
||||
subtitle: 'Pick a style, enter a prompt, and generate your image.',
|
||||
};
|
||||
}
|
||||
|
||||
function createStats(user: unknown): IdentityStat[] {
|
||||
const source = (user as Record<string, unknown>) ?? {};
|
||||
return STAT_BLUEPRINT.map(stat => ({
|
||||
...stat,
|
||||
value: deriveNumericValue(source[stat.key]),
|
||||
}));
|
||||
}
|
||||
|
||||
function deriveDisplayName(user: unknown): string | null {
|
||||
if (!user || typeof user !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const data = user as Record<string, unknown>;
|
||||
return ensureString(data.username) ?? ensureString(data.name) ?? null;
|
||||
}
|
||||
|
||||
function deriveAvatarSource(user: unknown): ImageSourcePropType | undefined {
|
||||
if (!user || typeof user !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const image = ensureString((user as Record<string, unknown>).image);
|
||||
if (!image) {
|
||||
return undefined;
|
||||
}
|
||||
return { uri: image };
|
||||
}
|
||||
|
||||
function ensureString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function deriveNumericValue(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const tokens = name
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (tokens.length === 0) {
|
||||
return 'AI';
|
||||
}
|
||||
const initials = tokens.slice(0, 2).map(part => part[0]?.toUpperCase() ?? '');
|
||||
return initials.join('') || tokens[0][0]?.toUpperCase() || 'AI';
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
scroll: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 28,
|
||||
},
|
||||
settingsButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
billingShell: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 4,
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
},
|
||||
billingOptions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
billingOption: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
},
|
||||
billingLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
lightningShell: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
marginLeft: 8,
|
||||
},
|
||||
lightningValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
},
|
||||
identityRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
avatarShell: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarInitials: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
},
|
||||
identityDetails: {
|
||||
flex: 1,
|
||||
marginLeft: 18,
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
username: {
|
||||
flex: 1,
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginRight: 12,
|
||||
},
|
||||
editButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
editIcon: {
|
||||
marginRight: 6,
|
||||
},
|
||||
editLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
statRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginRight: 18,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
marginRight: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
textTransform: 'lowercase',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
marginVertical: 24,
|
||||
},
|
||||
tabsBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
padding: 4,
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
borderRadius: 999,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyWrap: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 32,
|
||||
},
|
||||
emptyGlyph: {
|
||||
width: 112,
|
||||
height: 112,
|
||||
borderRadius: 28,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 6,
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
marginBottom: 28,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
generateButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
generateIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
generateLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
export default ProfileScreen
|
||||
@@ -4,14 +4,20 @@ import 'react-native-reanimated';
|
||||
|
||||
import { AuthProvider } from '@/components/auth/auth-provider';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
import * as Clarity from '@microsoft/react-native-clarity';
|
||||
import { useEffect } from 'react';
|
||||
Clarity.initialize('tyq6bmjzo1', {
|
||||
logLevel: Clarity.LogLevel.Verbose, // Note: Use "LogLevel.Verbose" value while testing to debug initialization issues.
|
||||
});
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
useEffect(()=>{
|
||||
console.log(`app start`)
|
||||
}, [])
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<AuthProvider>
|
||||
|
||||
@@ -1,621 +0,0 @@
|
||||
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: {},
|
||||
});
|
||||
@@ -72,7 +72,7 @@ export default function PointsExchangeScreen() {
|
||||
}, [router]);
|
||||
|
||||
const openPointsDetails = useCallback(() => {
|
||||
router.push('/points-details');
|
||||
router.push('/points');
|
||||
}, [router]);
|
||||
|
||||
const handleBundleSelect = useCallback((bundleId: string) => {
|
||||
|
||||
5
app/index.tsx
Normal file
5
app/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
export default function Index() {
|
||||
return <Redirect href="/home" />;
|
||||
}
|
||||
@@ -176,14 +176,17 @@ export default function ResultPage() {
|
||||
handleSaveToGallery();
|
||||
};
|
||||
|
||||
const renderVisualMedia = (url: string, style: StyleProp<ImageStyle> | StyleProp<ViewStyle>) => {
|
||||
const renderVisualMedia = (
|
||||
url: string,
|
||||
mediaStyles: { image: StyleProp<ImageStyle>; video: StyleProp<ViewStyle> }
|
||||
) => {
|
||||
if (!url) return null;
|
||||
|
||||
if (result?.type === 'VIDEO') {
|
||||
return (
|
||||
<Video
|
||||
source={{ uri: url }}
|
||||
style={style}
|
||||
style={mediaStyles.video}
|
||||
resizeMode={ResizeMode.COVER}
|
||||
shouldPlay={false}
|
||||
isMuted
|
||||
@@ -194,7 +197,7 @@ export default function ResultPage() {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
style={style}
|
||||
style={mediaStyles.image}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
);
|
||||
@@ -245,7 +248,10 @@ export default function ResultPage() {
|
||||
</ScrollView>
|
||||
) : (
|
||||
<>
|
||||
{renderVisualMedia(primaryPreviewUrl, styles.heroMedia)}
|
||||
{renderVisualMedia(primaryPreviewUrl, {
|
||||
image: styles.heroMedia,
|
||||
video: styles.heroMedia,
|
||||
})}
|
||||
{result.templateThumbnail && (
|
||||
<View style={styles.referencePreview}>
|
||||
<Image
|
||||
@@ -268,7 +274,10 @@ export default function ResultPage() {
|
||||
<Maximize2 size={18} color="#F5F5F7" />
|
||||
</View>
|
||||
<View style={styles.detailMediaWrapper}>
|
||||
{renderVisualMedia(primaryPreviewUrl, styles.detailMedia)}
|
||||
{renderVisualMedia(primaryPreviewUrl, {
|
||||
image: styles.detailMedia,
|
||||
video: styles.detailMedia,
|
||||
})}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { type Href, useRouter } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -29,7 +29,7 @@ type Palette = {
|
||||
type SettingDestination = {
|
||||
key: string;
|
||||
label: string;
|
||||
route?: string;
|
||||
route?: Href;
|
||||
};
|
||||
|
||||
const palettes: Record<'dark' | 'light', Palette> = {
|
||||
@@ -214,4 +214,3 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user