feat: build mobile app shell
Signed-off-by: baizhi958216 <1475289190@qq.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
|
||||||
|
const projectRoot = __dirname;
|
||||||
|
const workspaceRoot = path.resolve(projectRoot, '..');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(projectRoot);
|
||||||
|
|
||||||
|
config.watchFolders = [workspaceRoot];
|
||||||
|
config.resolver.alias = {
|
||||||
|
...config.resolver.alias,
|
||||||
|
'@infiplot/core': path.resolve(workspaceRoot, 'packages/core/src/index.ts'),
|
||||||
|
'@infiplot/types': path.resolve(workspaceRoot, 'packages/types/src/index.ts'),
|
||||||
|
};
|
||||||
|
config.resolver.nodeModulesPaths = [
|
||||||
|
path.resolve(projectRoot, 'node_modules'),
|
||||||
|
path.resolve(workspaceRoot, 'node_modules'),
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/ui": "~56.0.18",
|
"@expo/ui": "~56.0.18",
|
||||||
|
"@infiplot/core": "workspace:*",
|
||||||
|
"@infiplot/types": "workspace:*",
|
||||||
"expo": "~56.0.12",
|
"expo": "~56.0.12",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-device": "~56.0.4",
|
"expo-device": "~56.0.4",
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { creationOptions, defaultStoryDraft } from '@infiplot/core';
|
||||||
|
import { SymbolView } from 'expo-symbols';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { BottomTabInset } from '@/constants/theme';
|
||||||
|
|
||||||
|
export default function CreateScreen() {
|
||||||
|
const [world, setWorld] = useState(defaultStoryDraft.worldSetting);
|
||||||
|
const [style, setStyle] = useState(defaultStoryDraft.styleGuide);
|
||||||
|
const [template, setTemplate] = useState(creationOptions.templates[1]);
|
||||||
|
const [ratio, setRatio] = useState(creationOptions.ratios[0]);
|
||||||
|
const [rhythm, setRhythm] = useState(creationOptions.rhythms[0]);
|
||||||
|
|
||||||
|
const ready = world.trim().length > 10 && style.trim().length > 5;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safe}>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentContainerStyle={styles.content}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.eyebrow}>AI 剧情创作</Text>
|
||||||
|
<Text style={styles.title}>写下两段文字,生成可刷的短剧。</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerIcon}>
|
||||||
|
<SymbolView name={{ ios: 'sparkles', android: 'auto_awesome', web: 'auto_awesome' }} size={24} tintColor="#fff" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.label}>题材模板</Text>
|
||||||
|
<ChipRow options={creationOptions.templates} value={template} onChange={setTemplate} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.fieldBlock}>
|
||||||
|
<View style={styles.fieldHeader}>
|
||||||
|
<Text style={styles.label}>世界设定</Text>
|
||||||
|
<Text style={styles.counter}>{world.length}</Text>
|
||||||
|
</View>
|
||||||
|
<TextInput
|
||||||
|
value={world}
|
||||||
|
onChangeText={setWorld}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
placeholder="主角、世界、冲突、第一幕钩子..."
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.36)"
|
||||||
|
style={styles.textArea}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.fieldBlock}>
|
||||||
|
<View style={styles.fieldHeader}>
|
||||||
|
<Text style={styles.label}>视觉风格</Text>
|
||||||
|
<Text style={styles.counter}>{style.length}</Text>
|
||||||
|
</View>
|
||||||
|
<TextInput
|
||||||
|
value={style}
|
||||||
|
onChangeText={setStyle}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
placeholder="镜头、色彩、材质、画风、氛围..."
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.36)"
|
||||||
|
style={[styles.textArea, styles.shortArea]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.twoColumns}>
|
||||||
|
<View style={styles.optionCard}>
|
||||||
|
<Text style={styles.label}>画面比例</Text>
|
||||||
|
<ChipRow compact options={creationOptions.ratios} value={ratio} onChange={setRatio} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.optionCard}>
|
||||||
|
<Text style={styles.label}>叙事节奏</Text>
|
||||||
|
<ChipRow compact options={creationOptions.rhythms} value={rhythm} onChange={setRhythm} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.previewCard}>
|
||||||
|
<Text style={styles.previewTitle}>生成流程</Text>
|
||||||
|
<View style={styles.steps}>
|
||||||
|
{['故事圣经', '分镜脚本', '角色视觉', '首集预览'].map((step, index) => (
|
||||||
|
<View key={step} style={styles.step}>
|
||||||
|
<Text style={styles.stepIndex}>{index + 1}</Text>
|
||||||
|
<Text style={styles.stepText}>{step}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Pressable
|
||||||
|
disabled={!ready}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.generateButton,
|
||||||
|
!ready && styles.disabledButton,
|
||||||
|
pressed && ready && styles.pressed,
|
||||||
|
]}>
|
||||||
|
<SymbolView name={{ ios: 'wand.and.stars', android: 'auto_fix_high', web: 'auto_fix_high' }} size={18} tintColor="#050505" />
|
||||||
|
<Text style={styles.generateText}>{ready ? '生成第一集' : '继续补充设定'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChipRow({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
options: readonly string[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.chips, compact && styles.compactChips]}>
|
||||||
|
{options.map((option) => {
|
||||||
|
const selected = option === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option}
|
||||||
|
onPress={() => onChange(option)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.chip,
|
||||||
|
compact && styles.compactChip,
|
||||||
|
selected && styles.selectedChip,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}>
|
||||||
|
<Text style={[styles.chipText, selected && styles.selectedChipText]}>{option}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#09090b',
|
||||||
|
},
|
||||||
|
scroll: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 18,
|
||||||
|
paddingBottom: BottomTabInset + 112,
|
||||||
|
gap: 18,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 18,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
eyebrow: {
|
||||||
|
color: '#ff4d6d',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '900',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 30,
|
||||||
|
lineHeight: 36,
|
||||||
|
fontWeight: '900',
|
||||||
|
maxWidth: 300,
|
||||||
|
},
|
||||||
|
headerIcon: {
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
borderRadius: 23,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
chips: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
compactChips: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
minHeight: 38,
|
||||||
|
borderRadius: 19,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.09)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
},
|
||||||
|
compactChip: {
|
||||||
|
minHeight: 32,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
},
|
||||||
|
selectedChip: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderColor: '#fff',
|
||||||
|
},
|
||||||
|
chipText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
selectedChipText: {
|
||||||
|
color: '#09090b',
|
||||||
|
},
|
||||||
|
fieldBlock: {
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
fieldHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
counter: {
|
||||||
|
color: 'rgba(255,255,255,0.42)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
textArea: {
|
||||||
|
minHeight: 158,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 14,
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
fontWeight: '600',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
shortArea: {
|
||||||
|
minHeight: 116,
|
||||||
|
},
|
||||||
|
twoColumns: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
optionCard: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||||
|
},
|
||||||
|
previewCard: {
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 14,
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||||
|
},
|
||||||
|
previewTitle: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
step: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
minWidth: '46%',
|
||||||
|
},
|
||||||
|
stepIndex: {
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 22,
|
||||||
|
color: '#09090b',
|
||||||
|
backgroundColor: '#b8ff5d',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
stepText: {
|
||||||
|
color: 'rgba(255,255,255,0.82)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: BottomTabInset + 14,
|
||||||
|
backgroundColor: 'rgba(9,9,11,0.96)',
|
||||||
|
},
|
||||||
|
generateButton: {
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 26,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
opacity: 0.42,
|
||||||
|
},
|
||||||
|
generateText: {
|
||||||
|
color: '#050505',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.76,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { Image } from 'expo-image';
|
|
||||||
import { SymbolView } from 'expo-symbols';
|
|
||||||
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
import { ExternalLink } from '@/components/external-link';
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { Collapsible } from '@/components/ui/collapsible';
|
|
||||||
import { WebBadge } from '@/components/web-badge';
|
|
||||||
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
|
||||||
|
|
||||||
export default function TabTwoScreen() {
|
|
||||||
const safeAreaInsets = useSafeAreaInsets();
|
|
||||||
const insets = {
|
|
||||||
...safeAreaInsets,
|
|
||||||
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
|
|
||||||
};
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
const contentPlatformStyle = Platform.select({
|
|
||||||
android: {
|
|
||||||
paddingTop: insets.top,
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: insets.bottom,
|
|
||||||
},
|
|
||||||
web: {
|
|
||||||
paddingTop: Spacing.six,
|
|
||||||
paddingBottom: Spacing.four,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
style={[styles.scrollView, { backgroundColor: theme.background }]}
|
|
||||||
contentInset={insets}
|
|
||||||
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
|
|
||||||
<ThemedView style={styles.container}>
|
|
||||||
<ThemedView style={styles.titleContainer}>
|
|
||||||
<ThemedText type="subtitle">Explore</ThemedText>
|
|
||||||
<ThemedText style={styles.centerText} themeColor="textSecondary">
|
|
||||||
This starter app includes example{'\n'}code to help you get started.
|
|
||||||
</ThemedText>
|
|
||||||
|
|
||||||
<ExternalLink href="https://docs.expo.dev" asChild>
|
|
||||||
<Pressable style={({ pressed }) => pressed && styles.pressed}>
|
|
||||||
<ThemedView type="backgroundElement" style={styles.linkButton}>
|
|
||||||
<ThemedText type="link">Expo documentation</ThemedText>
|
|
||||||
<SymbolView
|
|
||||||
tintColor={theme.text}
|
|
||||||
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
|
|
||||||
size={12}
|
|
||||||
/>
|
|
||||||
</ThemedView>
|
|
||||||
</Pressable>
|
|
||||||
</ExternalLink>
|
|
||||||
</ThemedView>
|
|
||||||
|
|
||||||
<ThemedView style={styles.sectionsWrapper}>
|
|
||||||
<Collapsible title="File-based routing">
|
|
||||||
<ThemedText type="small">
|
|
||||||
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
|
|
||||||
<ThemedText type="code">src/app/explore.tsx</ThemedText>
|
|
||||||
</ThemedText>
|
|
||||||
<ThemedText type="small">
|
|
||||||
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
|
|
||||||
the tab navigator.
|
|
||||||
</ThemedText>
|
|
||||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
|
||||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
<Collapsible title="Android, iOS, and web support">
|
|
||||||
<ThemedView type="backgroundElement" style={styles.collapsibleContent}>
|
|
||||||
<ThemedText type="small">
|
|
||||||
You can open this project on Android, iOS, and the web. To open the web version,
|
|
||||||
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this
|
|
||||||
project.
|
|
||||||
</ThemedText>
|
|
||||||
<Image
|
|
||||||
source={require('@/assets/images/tutorial-web.png')}
|
|
||||||
style={styles.imageTutorial}
|
|
||||||
/>
|
|
||||||
</ThemedView>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
<Collapsible title="Images">
|
|
||||||
<ThemedText type="small">
|
|
||||||
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
|
|
||||||
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
|
|
||||||
screen densities.
|
|
||||||
</ThemedText>
|
|
||||||
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
|
|
||||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
|
||||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
<Collapsible title="Light and dark mode components">
|
|
||||||
<ThemedText type="small">
|
|
||||||
This template has light and dark mode support. The{' '}
|
|
||||||
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the
|
|
||||||
user's current color scheme is, and so you can adjust UI colors accordingly.
|
|
||||||
</ThemedText>
|
|
||||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
|
||||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
<Collapsible title="Animations">
|
|
||||||
<ThemedText type="small">
|
|
||||||
This template includes an example of an animated component. The{' '}
|
|
||||||
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
|
|
||||||
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
|
|
||||||
animate opening this hint.
|
|
||||||
</ThemedText>
|
|
||||||
</Collapsible>
|
|
||||||
</ThemedView>
|
|
||||||
{Platform.OS === 'web' && <WebBadge />}
|
|
||||||
</ThemedView>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
scrollView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
contentContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
maxWidth: MaxContentWidth,
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
titleContainer: {
|
|
||||||
gap: Spacing.three,
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: Spacing.four,
|
|
||||||
paddingVertical: Spacing.six,
|
|
||||||
},
|
|
||||||
centerText: {
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
pressed: {
|
|
||||||
opacity: 0.7,
|
|
||||||
},
|
|
||||||
linkButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
paddingHorizontal: Spacing.four,
|
|
||||||
paddingVertical: Spacing.two,
|
|
||||||
borderRadius: Spacing.five,
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: Spacing.one,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
sectionsWrapper: {
|
|
||||||
gap: Spacing.five,
|
|
||||||
paddingHorizontal: Spacing.four,
|
|
||||||
paddingTop: Spacing.three,
|
|
||||||
},
|
|
||||||
collapsibleContent: {
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
imageTutorial: {
|
|
||||||
width: '100%',
|
|
||||||
aspectRatio: 296 / 171,
|
|
||||||
borderRadius: Spacing.three,
|
|
||||||
marginTop: Spacing.two,
|
|
||||||
},
|
|
||||||
imageReact: {
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
alignSelf: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
+269
-72
@@ -1,98 +1,295 @@
|
|||||||
import * as Device from 'expo-device';
|
import { mobileDramaFeed } from '@infiplot/core';
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
import type { MobileDrama } from '@infiplot/types';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SymbolView } from 'expo-symbols';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dimensions,
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
type NativeScrollEvent,
|
||||||
|
type NativeSyntheticEvent,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { AnimatedIcon } from '@/components/animated-icon';
|
import { BottomTabInset } from '@/constants/theme';
|
||||||
import { HintRow } from '@/components/hint-row';
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { WebBadge } from '@/components/web-badge';
|
|
||||||
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
|
||||||
|
|
||||||
function getDevMenuHint() {
|
type SymbolName = ComponentProps<typeof SymbolView>['name'];
|
||||||
if (Platform.OS === 'web') {
|
|
||||||
return <ThemedText type="small">use browser devtools</ThemedText>;
|
export default function FeedScreen() {
|
||||||
}
|
const { height } = Dimensions.get('window');
|
||||||
if (Device.isDevice) {
|
const insets = useSafeAreaInsets();
|
||||||
return (
|
const pageHeight = height;
|
||||||
<ThemedText type="small">
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
shake device or press <ThemedText type="code">m</ThemedText> in terminal
|
const listRef = useRef<FlatList<MobileDrama>>(null);
|
||||||
</ThemedText>
|
|
||||||
|
const snapOffsets = useMemo(
|
||||||
|
() => mobileDramaFeed.map((_, index) => index * pageHeight),
|
||||||
|
[pageHeight],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function handleScrollEnd(event: NativeSyntheticEvent<NativeScrollEvent>) {
|
||||||
|
const nextIndex = Math.round(event.nativeEvent.contentOffset.y / pageHeight);
|
||||||
|
setActiveIndex(Math.max(0, Math.min(mobileDramaFeed.length - 1, nextIndex)));
|
||||||
}
|
}
|
||||||
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
|
|
||||||
return (
|
return (
|
||||||
<ThemedText type="small">
|
<View style={styles.root}>
|
||||||
press <ThemedText type="code">{shortcut}</ThemedText>
|
<FlatList
|
||||||
</ThemedText>
|
ref={listRef}
|
||||||
|
data={mobileDramaFeed}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
pagingEnabled
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate="fast"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onMomentumScrollEnd={handleScrollEnd}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<DramaPage
|
||||||
|
drama={item}
|
||||||
|
active={index === activeIndex}
|
||||||
|
height={pageHeight}
|
||||||
|
bottomInset={insets.bottom}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomeScreen() {
|
function DramaPage({
|
||||||
|
drama,
|
||||||
|
active,
|
||||||
|
height,
|
||||||
|
bottomInset,
|
||||||
|
}: {
|
||||||
|
drama: MobileDrama;
|
||||||
|
active: boolean;
|
||||||
|
height: number;
|
||||||
|
bottomInset: number;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<ThemedView style={styles.container}>
|
<View style={[styles.page, { height, backgroundColor: drama.palette[0] }]}>
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<View style={[styles.posterGlow, { backgroundColor: drama.palette[1] }]} />
|
||||||
<ThemedView style={styles.heroSection}>
|
<View style={[styles.posterOrb, { borderColor: drama.palette[2] }]}>
|
||||||
<AnimatedIcon />
|
<Text style={[styles.posterTitle, { color: drama.palette[2] }]}>{drama.title}</Text>
|
||||||
<ThemedText type="title" style={styles.title}>
|
<Text style={styles.posterSubtitle}>{drama.episode}</Text>
|
||||||
Welcome to Expo
|
</View>
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
|
|
||||||
<ThemedText type="code" style={styles.code}>
|
<SafeAreaView style={styles.safe}>
|
||||||
get started
|
<View style={styles.topBar}>
|
||||||
</ThemedText>
|
<Text style={styles.brand}>InfiPlot</Text>
|
||||||
|
<View style={styles.livePill}>
|
||||||
|
<View style={[styles.liveDot, { backgroundColor: active ? '#ff2d55' : '#777' }]} />
|
||||||
|
<Text style={styles.liveText}>刷剧</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<ThemedView type="backgroundElement" style={styles.stepContainer}>
|
<View style={[styles.sideRail, { bottom: bottomInset + BottomTabInset + 86 }]}>
|
||||||
<HintRow
|
<ActionButton name={{ ios: 'heart.fill', android: 'favorite', web: 'favorite' }} label={drama.likes} />
|
||||||
title="Try editing"
|
<ActionButton name={{ ios: 'bubble.right.fill', android: 'chat_bubble', web: 'chat_bubble' }} label={drama.comments} />
|
||||||
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
|
<ActionButton name={{ ios: 'square.and.arrow.up', android: 'ios_share', web: 'ios_share' }} label="分享" />
|
||||||
/>
|
</View>
|
||||||
<HintRow title="Dev tools" hint={getDevMenuHint()} />
|
|
||||||
<HintRow
|
|
||||||
title="Fresh start"
|
|
||||||
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
|
|
||||||
/>
|
|
||||||
</ThemedView>
|
|
||||||
|
|
||||||
{Platform.OS === 'web' && <WebBadge />}
|
<View style={[styles.infoPanel, { paddingBottom: bottomInset + BottomTabInset + 20 }]}>
|
||||||
|
<Text style={styles.creator}>@{drama.creator}</Text>
|
||||||
|
<Text style={styles.title}>{drama.title}</Text>
|
||||||
|
<Text style={styles.hook}>{drama.hook}</Text>
|
||||||
|
<View style={styles.tags}>
|
||||||
|
{drama.tags.map((tag) => (
|
||||||
|
<Text key={tag} style={styles.tag}>
|
||||||
|
#{tag}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={styles.playRow}>
|
||||||
|
<Pressable style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}>
|
||||||
|
<SymbolView name={{ ios: 'play.fill', android: 'play_arrow', web: 'play_arrow' }} size={16} tintColor="#101010" />
|
||||||
|
<Text style={styles.primaryButtonText}>继续看</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.episode}>{drama.episode}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</ThemedView>
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({ name, label }: { name: SymbolName; label: string }) {
|
||||||
|
return (
|
||||||
|
<Pressable style={({ pressed }) => [styles.actionButton, pressed && styles.pressed]}>
|
||||||
|
<View style={styles.actionIcon}>
|
||||||
|
<SymbolView name={name} size={24} tintColor="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.actionLabel}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
root: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
posterGlow: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 520,
|
||||||
|
height: 520,
|
||||||
|
borderRadius: 260,
|
||||||
|
top: '14%',
|
||||||
|
left: -120,
|
||||||
|
opacity: 0.5,
|
||||||
|
transform: [{ rotate: '-18deg' }],
|
||||||
|
},
|
||||||
|
posterOrb: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '18%',
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '76%',
|
||||||
|
aspectRatio: 0.72,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 28,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
flexDirection: 'row',
|
|
||||||
},
|
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
paddingHorizontal: Spacing.four,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: Spacing.three,
|
padding: 24,
|
||||||
paddingBottom: BottomTabInset + Spacing.three,
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
maxWidth: MaxContentWidth,
|
|
||||||
},
|
},
|
||||||
heroSection: {
|
posterTitle: {
|
||||||
alignItems: 'center',
|
fontSize: 42,
|
||||||
justifyContent: 'center',
|
lineHeight: 48,
|
||||||
flex: 1,
|
fontWeight: '800',
|
||||||
paddingHorizontal: Spacing.four,
|
|
||||||
gap: Spacing.four,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
code: {
|
posterSubtitle: {
|
||||||
textTransform: 'uppercase',
|
color: 'rgba(255,255,255,0.72)',
|
||||||
|
marginTop: 14,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
stepContainer: {
|
safe: {
|
||||||
gap: Spacing.three,
|
flex: 1,
|
||||||
alignSelf: 'stretch',
|
},
|
||||||
paddingHorizontal: Spacing.three,
|
topBar: {
|
||||||
paddingVertical: Spacing.four,
|
flexDirection: 'row',
|
||||||
borderRadius: Spacing.four,
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingTop: 6,
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
livePill: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 7,
|
||||||
|
borderRadius: 99,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.32)',
|
||||||
|
},
|
||||||
|
liveDot: {
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: 7,
|
||||||
|
},
|
||||||
|
liveText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
sideRail: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 14,
|
||||||
|
gap: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
actionIcon: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.16)',
|
||||||
|
},
|
||||||
|
actionLabel: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
infoPanel: {
|
||||||
|
marginTop: 'auto',
|
||||||
|
paddingLeft: 18,
|
||||||
|
paddingRight: 82,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
creator: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 30,
|
||||||
|
lineHeight: 36,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
hook: {
|
||||||
|
color: 'rgba(255,255,255,0.88)',
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 22,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
playRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 21,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
color: '#101010',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
episode: {
|
||||||
|
color: 'rgba(255,255,255,0.78)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.75,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,676 @@
|
|||||||
|
import type { ModelConfig, ModelConfigMap, ModelRole } from '@infiplot/types';
|
||||||
|
import { SymbolView } from 'expo-symbols';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { BottomTabInset } from '@/constants/theme';
|
||||||
|
import {
|
||||||
|
clearModelConfig,
|
||||||
|
defaultModelConfig,
|
||||||
|
readModelConfig,
|
||||||
|
writeModelConfig,
|
||||||
|
} from '@/lib/model-config';
|
||||||
|
|
||||||
|
type SymbolName = ComponentProps<typeof SymbolView>['name'];
|
||||||
|
|
||||||
|
const modelGroups: {
|
||||||
|
id: ModelRole;
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
accent: string;
|
||||||
|
}[] = [
|
||||||
|
{ id: 'text', title: '文本模型', hint: '剧情、分镜、选择分支', accent: '#ff4d6d' },
|
||||||
|
{ id: 'image', title: '图像模型', hint: '角色、场景、封面生成', accent: '#b8ff5d' },
|
||||||
|
{ id: 'vision', title: '视觉理解', hint: '风格图解析、画面标注', accent: '#78d7ff' },
|
||||||
|
{ id: 'tts', title: '语音模型', hint: '对白配音、旁白', accent: '#ffd166' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const roleLabels: Record<ModelRole, string> = {
|
||||||
|
text: '文本',
|
||||||
|
image: '图像',
|
||||||
|
vision: '视觉',
|
||||||
|
tts: '语音',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const [syncEnabled, setSyncEnabled] = useState(true);
|
||||||
|
const [showKeys, setShowKeys] = useState(false);
|
||||||
|
const [signedIn, setSignedIn] = useState(false);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [selectedRole, setSelectedRole] = useState<ModelRole>('text');
|
||||||
|
const [configs, setConfigs] = useState<ModelConfigMap>(defaultModelConfig);
|
||||||
|
const [savedConfigs, setSavedConfigs] = useState<ModelConfigMap>(defaultModelConfig);
|
||||||
|
const [status, setStatus] = useState<'loading' | 'saved' | 'dirty'>('loading');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
readModelConfig().then((stored) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setConfigs(stored);
|
||||||
|
setSavedConfigs(stored);
|
||||||
|
setStatus('saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedGroup = modelGroups.find((group) => group.id === selectedRole) ?? modelGroups[0];
|
||||||
|
const selectedConfig = configs[selectedRole];
|
||||||
|
|
||||||
|
const configuredCount = useMemo(
|
||||||
|
() => Object.values(configs).filter((config) => config.apiKey.trim().length > 0).length,
|
||||||
|
[configs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dirty = useMemo(
|
||||||
|
() => JSON.stringify(configs) !== JSON.stringify(savedConfigs),
|
||||||
|
[configs, savedConfigs],
|
||||||
|
);
|
||||||
|
|
||||||
|
function updateConfig(field: keyof ModelConfig, value: string) {
|
||||||
|
setConfigs((current) => ({
|
||||||
|
...current,
|
||||||
|
[selectedRole]: {
|
||||||
|
...current[selectedRole],
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setStatus('dirty');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const saved = await writeModelConfig(configs);
|
||||||
|
setConfigs(saved);
|
||||||
|
setSavedConfigs(saved);
|
||||||
|
setStatus('saved');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
const reset = await clearModelConfig();
|
||||||
|
setConfigs(reset);
|
||||||
|
setSavedConfigs(reset);
|
||||||
|
setSelectedRole('text');
|
||||||
|
setStatus('saved');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthAction() {
|
||||||
|
if (signedIn) {
|
||||||
|
setSignedIn(false);
|
||||||
|
setEmail('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email.trim().length > 3) {
|
||||||
|
setSignedIn(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safe}>
|
||||||
|
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||||
|
<View style={styles.profileCard}>
|
||||||
|
<View style={styles.avatar}>
|
||||||
|
<Text style={styles.avatarText}>IP</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.profileCopy}>
|
||||||
|
<Text style={styles.name}>{signedIn ? 'InfiPlot 创作者' : '未登录创作者'}</Text>
|
||||||
|
<Text style={styles.subline}>
|
||||||
|
{signedIn ? email.trim() : '登录后同步我的剧情、草稿和模型配置。'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleAuthAction}
|
||||||
|
style={({ pressed }) => [styles.loginButton, pressed && styles.pressed]}>
|
||||||
|
<Text style={styles.loginText}>{signedIn ? '退出' : '登录'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!signedIn && (
|
||||||
|
<View style={styles.loginPanel}>
|
||||||
|
<TextInput
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
placeholder="creator@infiplot.app"
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.35)"
|
||||||
|
style={styles.loginInput}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleAuthAction}
|
||||||
|
disabled={email.trim().length <= 3}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.loginSubmit,
|
||||||
|
email.trim().length <= 3 && styles.disabledButton,
|
||||||
|
pressed && email.trim().length > 3 && styles.pressed,
|
||||||
|
]}>
|
||||||
|
<Text style={styles.loginSubmitText}>继续</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.stats}>
|
||||||
|
<Stat value="12" label="草稿" />
|
||||||
|
<Stat value="4" label="已发布" />
|
||||||
|
<Stat value={`${configuredCount}/4`} label="API" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.sectionTitle}>模型 API</Text>
|
||||||
|
<Text style={styles.sectionHint}>对应 web 端文字、图片、视觉和 TTS 配置。</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowKeys((value) => !value)}
|
||||||
|
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}>
|
||||||
|
<SymbolView
|
||||||
|
name={{
|
||||||
|
ios: showKeys ? 'eye.slash.fill' : 'eye.fill',
|
||||||
|
android: showKeys ? 'visibility_off' : 'visibility',
|
||||||
|
web: showKeys ? 'visibility_off' : 'visibility',
|
||||||
|
}}
|
||||||
|
size={18}
|
||||||
|
tintColor="#fff"
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.roleTabs}>
|
||||||
|
{modelGroups.map((group) => {
|
||||||
|
const selected = group.id === selectedRole;
|
||||||
|
const configured = configs[group.id].apiKey.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={group.id}
|
||||||
|
onPress={() => setSelectedRole(group.id)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.roleTab,
|
||||||
|
selected && styles.selectedRoleTab,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
]}>
|
||||||
|
<View style={[styles.roleDot, { backgroundColor: configured ? group.accent : '#4a4a52' }]} />
|
||||||
|
<Text style={[styles.roleText, selected && styles.selectedRoleText]}>
|
||||||
|
{roleLabels[group.id]}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.modelEditor}>
|
||||||
|
<View style={styles.modelTop}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.modelTitle}>{selectedGroup.title}</Text>
|
||||||
|
<Text style={styles.modelHint}>{selectedGroup.hint}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statusPill, dirty && styles.dirtyPill]}>
|
||||||
|
<Text style={[styles.statusText, dirty && styles.dirtyText]}>
|
||||||
|
{status === 'loading' ? '读取中' : dirty ? '未保存' : '已保存'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<InputLine
|
||||||
|
label="Provider"
|
||||||
|
value={selectedConfig.provider}
|
||||||
|
onChangeText={(value) => updateConfig('provider', value)}
|
||||||
|
placeholder="openai / claude / gemini / stepfun"
|
||||||
|
/>
|
||||||
|
<InputLine
|
||||||
|
label="Base URL"
|
||||||
|
value={selectedConfig.baseUrl}
|
||||||
|
onChangeText={(value) => updateConfig('baseUrl', value)}
|
||||||
|
placeholder="https://api.example.com/v1"
|
||||||
|
/>
|
||||||
|
<InputLine
|
||||||
|
label="API Key"
|
||||||
|
value={selectedConfig.apiKey}
|
||||||
|
onChangeText={(value) => updateConfig('apiKey', value)}
|
||||||
|
secureTextEntry={!showKeys}
|
||||||
|
placeholder="sk-..."
|
||||||
|
/>
|
||||||
|
<InputLine
|
||||||
|
label="Model"
|
||||||
|
value={selectedConfig.model}
|
||||||
|
onChangeText={(value) => updateConfig('model', value)}
|
||||||
|
placeholder="model name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleReset}
|
||||||
|
style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressed]}>
|
||||||
|
<Text style={styles.secondaryText}>重置</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={!dirty}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.saveButton,
|
||||||
|
!dirty && styles.disabledButton,
|
||||||
|
pressed && dirty && styles.pressed,
|
||||||
|
]}>
|
||||||
|
<SymbolView
|
||||||
|
name={{ ios: 'checkmark.circle.fill', android: 'check_circle', web: 'check_circle' }}
|
||||||
|
size={18}
|
||||||
|
tintColor="#09090b"
|
||||||
|
/>
|
||||||
|
<Text style={styles.saveText}>保存配置</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.settingRow}>
|
||||||
|
<View style={styles.settingTextBlock}>
|
||||||
|
<Text style={styles.settingTitle}>云端同步</Text>
|
||||||
|
<Text style={styles.settingHint}>登录后自动同步我的剧情和配置。</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={syncEnabled}
|
||||||
|
onValueChange={setSyncEnabled}
|
||||||
|
trackColor={{ false: '#303036', true: '#ff4d6d' }}
|
||||||
|
thumbColor="#fff"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.menuList}>
|
||||||
|
<MenuItem name={{ ios: 'tray.and.arrow.down.fill', android: 'download', web: 'download' }} label="导入 .infiplot 剧情" />
|
||||||
|
<MenuItem name={{ ios: 'lock.shield.fill', android: 'shield', web: 'shield' }} label="隐私与本地密钥" />
|
||||||
|
<MenuItem name={{ ios: 'questionmark.circle.fill', android: 'help', web: 'help' }} label="帮助与反馈" />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ value, label }: { value: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.stat}>
|
||||||
|
<Text style={styles.statValue}>{value}</Text>
|
||||||
|
<Text style={styles.statLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputLine({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
secureTextEntry,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (value: string) => void;
|
||||||
|
secureTextEntry?: boolean;
|
||||||
|
placeholder: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.inputLine}>
|
||||||
|
<Text style={styles.inputLabel}>{label}</Text>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
secureTextEntry={secureTextEntry}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={styles.input}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.35)"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({ name, label }: { name: SymbolName; label: string }) {
|
||||||
|
return (
|
||||||
|
<Pressable style={({ pressed }) => [styles.menuItem, pressed && styles.pressed]}>
|
||||||
|
<View style={styles.menuIcon}>
|
||||||
|
<SymbolView name={name} size={18} tintColor="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.menuLabel}>{label}</Text>
|
||||||
|
<SymbolView
|
||||||
|
name={{ ios: 'chevron.right', android: 'chevron_right', web: 'chevron_right' }}
|
||||||
|
size={14}
|
||||||
|
tintColor="rgba(255,255,255,0.4)"
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#09090b',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 18,
|
||||||
|
paddingBottom: BottomTabInset + 34,
|
||||||
|
gap: 18,
|
||||||
|
},
|
||||||
|
profileCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 54,
|
||||||
|
height: 54,
|
||||||
|
borderRadius: 27,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#ff4d6d',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
profileCopy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
subline: {
|
||||||
|
color: 'rgba(255,255,255,0.58)',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 17,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
loginButton: {
|
||||||
|
height: 36,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 18,
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
loginText: {
|
||||||
|
color: '#09090b',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
loginPanel: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||||
|
},
|
||||||
|
loginInput: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 42,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.22)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
},
|
||||||
|
loginSubmit: {
|
||||||
|
minWidth: 70,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 21,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
loginSubmitText: {
|
||||||
|
color: '#09090b',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 14,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||||
|
},
|
||||||
|
stat: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
color: 'rgba(255,255,255,0.54)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 23,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
sectionHint: {
|
||||||
|
color: 'rgba(255,255,255,0.56)',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 21,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
roleTabs: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
roleTab: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 42,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 6,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
},
|
||||||
|
selectedRoleTab: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.14)',
|
||||||
|
borderColor: 'rgba(255,255,255,0.2)',
|
||||||
|
},
|
||||||
|
roleDot: {
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: 7,
|
||||||
|
},
|
||||||
|
roleText: {
|
||||||
|
color: 'rgba(255,255,255,0.58)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
selectedRoleText: {
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
modelEditor: {
|
||||||
|
gap: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.075)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
},
|
||||||
|
modelTop: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modelTitle: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
modelHint: {
|
||||||
|
color: 'rgba(255,255,255,0.52)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginTop: 3,
|
||||||
|
},
|
||||||
|
statusPill: {
|
||||||
|
height: 26,
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 13,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
backgroundColor: 'rgba(184,255,93,0.16)',
|
||||||
|
},
|
||||||
|
dirtyPill: {
|
||||||
|
backgroundColor: 'rgba(255,209,102,0.16)',
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
color: '#b8ff5d',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
dirtyText: {
|
||||||
|
color: '#ffd166',
|
||||||
|
},
|
||||||
|
inputLine: {
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
minHeight: 42,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.22)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.08)',
|
||||||
|
},
|
||||||
|
actionRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
secondaryText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
flex: 1,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 7,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
opacity: 0.42,
|
||||||
|
},
|
||||||
|
saveText: {
|
||||||
|
color: '#09090b',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
settingRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 18,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||||
|
},
|
||||||
|
settingTextBlock: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
settingTitle: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
settingHint: {
|
||||||
|
color: 'rgba(255,255,255,0.52)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
menuList: {
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||||
|
},
|
||||||
|
menuItem: {
|
||||||
|
minHeight: 56,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: 'rgba(255,255,255,0.08)',
|
||||||
|
},
|
||||||
|
menuIcon: {
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 17,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
menuLabel: {
|
||||||
|
flex: 1,
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.76,
|
||||||
|
},
|
||||||
|
});
|
||||||
+22
-16
@@ -1,30 +1,36 @@
|
|||||||
import { NativeTabs } from 'expo-router/unstable-native-tabs';
|
import { NativeTabs } from 'expo-router/unstable-native-tabs';
|
||||||
import { useColorScheme } from 'react-native';
|
|
||||||
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
|
|
||||||
export default function AppTabs() {
|
export default function AppTabs() {
|
||||||
const scheme = useColorScheme();
|
|
||||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
backgroundColor={colors.background}
|
backgroundColor="#09090b"
|
||||||
indicatorColor={colors.backgroundElement}
|
iconColor={{ default: 'rgba(255,255,255,0.54)', selected: '#ffffff' }}
|
||||||
labelStyle={{ selected: { color: colors.text } }}>
|
indicatorColor="rgba(255,255,255,0.12)"
|
||||||
|
labelStyle={{
|
||||||
|
default: { color: 'rgba(255,255,255,0.54)', fontSize: 11, fontWeight: '800' },
|
||||||
|
selected: { color: '#ffffff', fontSize: 11, fontWeight: '900' },
|
||||||
|
}}>
|
||||||
<NativeTabs.Trigger name="index">
|
<NativeTabs.Trigger name="index">
|
||||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>刷剧</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon
|
<NativeTabs.Trigger.Icon
|
||||||
src={require('@/assets/images/tabIcons/home.png')}
|
sf={{ default: 'play.rectangle', selected: 'play.rectangle.fill' }}
|
||||||
renderingMode="template"
|
md={{ default: 'slideshow', selected: 'slideshow' }}
|
||||||
/>
|
/>
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
<NativeTabs.Trigger name="explore">
|
<NativeTabs.Trigger name="create">
|
||||||
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>创作</NativeTabs.Trigger.Label>
|
||||||
<NativeTabs.Trigger.Icon
|
<NativeTabs.Trigger.Icon
|
||||||
src={require('@/assets/images/tabIcons/explore.png')}
|
sf={{ default: 'wand.and.stars', selected: 'wand.and.stars.inverse' }}
|
||||||
renderingMode="template"
|
md={{ default: 'auto_fix_high', selected: 'auto_fix_high' }}
|
||||||
|
/>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="profile">
|
||||||
|
<NativeTabs.Trigger.Label>我的</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
sf={{ default: 'person.crop.circle', selected: 'person.crop.circle.fill' }}
|
||||||
|
md={{ default: 'person', selected: 'person' }}
|
||||||
/>
|
/>
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
|||||||
@@ -1,115 +1,112 @@
|
|||||||
import {
|
import {
|
||||||
Tabs,
|
|
||||||
TabList,
|
TabList,
|
||||||
TabTrigger,
|
|
||||||
TabSlot,
|
TabSlot,
|
||||||
|
TabTrigger,
|
||||||
TabTriggerSlotProps,
|
TabTriggerSlotProps,
|
||||||
TabListProps,
|
Tabs,
|
||||||
} from 'expo-router/ui';
|
} from 'expo-router/ui';
|
||||||
import { SymbolView } from 'expo-symbols';
|
import { SymbolView } from 'expo-symbols';
|
||||||
import { Pressable, useColorScheme, View, StyleSheet } from 'react-native';
|
import type { ComponentProps } from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import type { Href } from 'expo-router';
|
||||||
|
|
||||||
import { ExternalLink } from './external-link';
|
type SymbolName = ComponentProps<typeof SymbolView>['name'];
|
||||||
import { ThemedText } from './themed-text';
|
|
||||||
import { ThemedView } from './themed-view';
|
|
||||||
|
|
||||||
import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
|
const tabs = [
|
||||||
|
{
|
||||||
|
name: 'feed',
|
||||||
|
href: '/' as Href,
|
||||||
|
label: '刷剧',
|
||||||
|
icon: { ios: 'play.rectangle.fill', android: 'slideshow', web: 'slideshow' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'create',
|
||||||
|
href: '/create' as Href,
|
||||||
|
label: '创作',
|
||||||
|
icon: { ios: 'wand.and.stars', android: 'auto_fix_high', web: 'auto_fix_high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'profile',
|
||||||
|
href: '/profile' as Href,
|
||||||
|
label: '我的',
|
||||||
|
icon: { ios: 'person.crop.circle.fill', android: 'person', web: 'person' },
|
||||||
|
},
|
||||||
|
] satisfies {
|
||||||
|
name: string;
|
||||||
|
href: Href;
|
||||||
|
label: string;
|
||||||
|
icon: SymbolName;
|
||||||
|
}[];
|
||||||
|
|
||||||
export default function AppTabs() {
|
export default function AppTabs() {
|
||||||
return (
|
return (
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<TabSlot style={{ height: '100%' }} />
|
<TabSlot style={styles.slot} />
|
||||||
<TabList asChild>
|
<TabList asChild>
|
||||||
<CustomTabList>
|
<View style={styles.tabList}>
|
||||||
<TabTrigger name="home" href="/" asChild>
|
{tabs.map((tab) => (
|
||||||
<TabButton>Home</TabButton>
|
<TabTrigger key={tab.name} name={tab.name} href={tab.href} asChild>
|
||||||
|
<TabButton icon={tab.icon}>{tab.label}</TabButton>
|
||||||
</TabTrigger>
|
</TabTrigger>
|
||||||
<TabTrigger name="explore" href="/explore" asChild>
|
))}
|
||||||
<TabButton>Explore</TabButton>
|
</View>
|
||||||
</TabTrigger>
|
|
||||||
</CustomTabList>
|
|
||||||
</TabList>
|
</TabList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
|
function TabButton({
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
isFocused,
|
||||||
|
...props
|
||||||
|
}: TabTriggerSlotProps & { icon: SymbolName }) {
|
||||||
return (
|
return (
|
||||||
<Pressable {...props} style={({ pressed }) => pressed && styles.pressed}>
|
<Pressable {...props} style={({ pressed }) => [styles.tabButton, isFocused && styles.activeTab, pressed && styles.pressed]}>
|
||||||
<ThemedView
|
<SymbolView name={icon} size={20} tintColor={isFocused ? '#fff' : 'rgba(255,255,255,0.5)'} />
|
||||||
type={isFocused ? 'backgroundSelected' : 'backgroundElement'}
|
<Text style={[styles.tabText, isFocused && styles.activeText]}>{children}</Text>
|
||||||
style={styles.tabButtonView}>
|
|
||||||
<ThemedText type="small" themeColor={isFocused ? 'text' : 'textSecondary'}>
|
|
||||||
{children}
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomTabList(props: TabListProps) {
|
|
||||||
const scheme = useColorScheme();
|
|
||||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props} style={styles.tabListContainer}>
|
|
||||||
<ThemedView type="backgroundElement" style={styles.innerContainer}>
|
|
||||||
<ThemedText type="smallBold" style={styles.brandText}>
|
|
||||||
Expo Starter
|
|
||||||
</ThemedText>
|
|
||||||
|
|
||||||
{props.children}
|
|
||||||
|
|
||||||
<ExternalLink href="https://docs.expo.dev" asChild>
|
|
||||||
<Pressable style={styles.externalPressable}>
|
|
||||||
<ThemedText type="link">Docs</ThemedText>
|
|
||||||
<SymbolView
|
|
||||||
tintColor={colors.text}
|
|
||||||
name={{ ios: 'arrow.up.right.square', web: 'link' }}
|
|
||||||
size={12}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</ExternalLink>
|
|
||||||
</ThemedView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
tabListContainer: {
|
slot: {
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
tabList: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
width: '100%',
|
left: 0,
|
||||||
padding: Spacing.three,
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: 74,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#09090b',
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: 'rgba(255,255,255,0.12)',
|
||||||
|
},
|
||||||
|
tabButton: {
|
||||||
|
minWidth: 86,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
gap: 4,
|
||||||
flexDirection: 'row',
|
|
||||||
},
|
},
|
||||||
innerContainer: {
|
activeTab: {
|
||||||
paddingVertical: Spacing.two,
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||||
paddingHorizontal: Spacing.five,
|
|
||||||
borderRadius: Spacing.five,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexGrow: 1,
|
|
||||||
gap: Spacing.two,
|
|
||||||
maxWidth: MaxContentWidth,
|
|
||||||
},
|
},
|
||||||
brandText: {
|
tabText: {
|
||||||
marginRight: 'auto',
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
activeText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '900',
|
||||||
},
|
},
|
||||||
pressed: {
|
pressed: {
|
||||||
opacity: 0.7,
|
opacity: 0.72,
|
||||||
},
|
|
||||||
tabButtonView: {
|
|
||||||
paddingVertical: Spacing.one,
|
|
||||||
paddingHorizontal: Spacing.three,
|
|
||||||
borderRadius: Spacing.three,
|
|
||||||
},
|
|
||||||
externalPressable: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: Spacing.one,
|
|
||||||
marginLeft: Spacing.three,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { defaultModelConfig, normalizeModelConfig } from '@infiplot/core';
|
||||||
|
import type { ModelConfigMap } from '@infiplot/types';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'infiplot:mobile:model-config';
|
||||||
|
|
||||||
|
export { defaultModelConfig };
|
||||||
|
export type { ModelConfigMap };
|
||||||
|
|
||||||
|
let memoryStore: string | null = null;
|
||||||
|
|
||||||
|
function getWebStorage() {
|
||||||
|
if (Platform.OS !== 'web' || typeof window === 'undefined') return null;
|
||||||
|
return window.localStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readModelConfig(): Promise<ModelConfigMap> {
|
||||||
|
try {
|
||||||
|
const storage = getWebStorage();
|
||||||
|
const raw = storage ? storage.getItem(STORAGE_KEY) : memoryStore;
|
||||||
|
if (!raw) return defaultModelConfig;
|
||||||
|
return normalizeModelConfig(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return defaultModelConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeModelConfig(config: ModelConfigMap): Promise<ModelConfigMap> {
|
||||||
|
const normalized = normalizeModelConfig(config);
|
||||||
|
const serialized = JSON.stringify(normalized);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storage = getWebStorage();
|
||||||
|
if (storage) {
|
||||||
|
storage.setItem(STORAGE_KEY, serialized);
|
||||||
|
} else {
|
||||||
|
memoryStore = serialized;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
memoryStore = serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearModelConfig(): Promise<ModelConfigMap> {
|
||||||
|
try {
|
||||||
|
const storage = getWebStorage();
|
||||||
|
if (storage) storage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in private sessions.
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryStore = null;
|
||||||
|
return defaultModelConfig;
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
],
|
],
|
||||||
"@/assets/*": [
|
"@/assets/*": [
|
||||||
"./assets/*"
|
"./assets/*"
|
||||||
|
],
|
||||||
|
"@infiplot/core": [
|
||||||
|
"../packages/core/src/index.ts"
|
||||||
|
],
|
||||||
|
"@infiplot/types": [
|
||||||
|
"../packages/types/src/index.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user