Compare commits

...

1 Commits

Author SHA1 Message Date
baizhi958216 c00a8c6ff6 feat: build mobile app shell
Signed-off-by: baizhi958216 <1475289190@qq.com>
2026-06-30 13:16:15 +08:00
10 changed files with 1474 additions and 352 deletions
+20
View File
@@ -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;
+2
View File
@@ -4,6 +4,8 @@
"version": "1.0.0",
"dependencies": {
"@expo/ui": "~56.0.18",
"@infiplot/core": "workspace:*",
"@infiplot/types": "workspace:*",
"expo": "~56.0.12",
"expo-constants": "~56.0.18",
"expo-device": "~56.0.4",
+342
View File
@@ -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,
},
});
-180
View File
@@ -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&apos;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',
},
});
+270 -73
View File
@@ -1,98 +1,295 @@
import * as Device from 'expo-device';
import { Platform, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { mobileDramaFeed } from '@infiplot/core';
import type { MobileDrama } from '@infiplot/types';
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 { 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';
import { BottomTabInset } from '@/constants/theme';
function getDevMenuHint() {
if (Platform.OS === 'web') {
return <ThemedText type="small">use browser devtools</ThemedText>;
type SymbolName = ComponentProps<typeof SymbolView>['name'];
export default function FeedScreen() {
const { height } = Dimensions.get('window');
const insets = useSafeAreaInsets();
const pageHeight = height;
const [activeIndex, setActiveIndex] = useState(0);
const listRef = useRef<FlatList<MobileDrama>>(null);
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)));
}
if (Device.isDevice) {
return (
<ThemedText type="small">
shake device or press <ThemedText type="code">m</ThemedText> in terminal
</ThemedText>
);
}
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
return (
<ThemedText type="small">
press <ThemedText type="code">{shortcut}</ThemedText>
</ThemedText>
<View style={styles.root}>
<FlatList
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 (
<ThemedView style={styles.container}>
<SafeAreaView style={styles.safeArea}>
<ThemedView style={styles.heroSection}>
<AnimatedIcon />
<ThemedText type="title" style={styles.title}>
Welcome to&nbsp;Expo
</ThemedText>
</ThemedView>
<View style={[styles.page, { height, backgroundColor: drama.palette[0] }]}>
<View style={[styles.posterGlow, { backgroundColor: drama.palette[1] }]} />
<View style={[styles.posterOrb, { borderColor: drama.palette[2] }]}>
<Text style={[styles.posterTitle, { color: drama.palette[2] }]}>{drama.title}</Text>
<Text style={styles.posterSubtitle}>{drama.episode}</Text>
</View>
<ThemedText type="code" style={styles.code}>
get started
</ThemedText>
<SafeAreaView style={styles.safe}>
<View style={styles.topBar}>
<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}>
<HintRow
title="Try editing"
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
/>
<HintRow title="Dev tools" hint={getDevMenuHint()} />
<HintRow
title="Fresh start"
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
/>
</ThemedView>
<View style={[styles.sideRail, { bottom: bottomInset + BottomTabInset + 86 }]}>
<ActionButton name={{ ios: 'heart.fill', android: 'favorite', web: 'favorite' }} label={drama.likes} />
<ActionButton name={{ ios: 'bubble.right.fill', android: 'chat_bubble', web: 'chat_bubble' }} label={drama.comments} />
<ActionButton name={{ ios: 'square.and.arrow.up', android: 'ios_share', web: 'ios_share' }} label="分享" />
</View>
{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>
</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({
container: {
root: {
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',
flexDirection: 'row',
},
safeArea: {
flex: 1,
paddingHorizontal: Spacing.four,
alignItems: 'center',
gap: Spacing.three,
paddingBottom: BottomTabInset + Spacing.three,
maxWidth: MaxContentWidth,
padding: 24,
backgroundColor: 'rgba(255,255,255,0.08)',
},
heroSection: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
paddingHorizontal: Spacing.four,
gap: Spacing.four,
},
title: {
posterTitle: {
fontSize: 42,
lineHeight: 48,
fontWeight: '800',
textAlign: 'center',
},
code: {
textTransform: 'uppercase',
posterSubtitle: {
color: 'rgba(255,255,255,0.72)',
marginTop: 14,
fontSize: 13,
fontWeight: '700',
},
stepContainer: {
gap: Spacing.three,
alignSelf: 'stretch',
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.four,
borderRadius: Spacing.four,
safe: {
flex: 1,
},
topBar: {
flexDirection: 'row',
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,
},
});
+676
View File
@@ -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
View File
@@ -1,30 +1,36 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { useColorScheme } from 'react-native';
import { Colors } from '@/constants/theme';
export default function AppTabs() {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<NativeTabs
backgroundColor={colors.background}
indicatorColor={colors.backgroundElement}
labelStyle={{ selected: { color: colors.text } }}>
backgroundColor="#09090b"
iconColor={{ default: 'rgba(255,255,255,0.54)', selected: '#ffffff' }}
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.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Label></NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/home.png')}
renderingMode="template"
sf={{ default: 'play.rectangle', selected: 'play.rectangle.fill' }}
md={{ default: 'slideshow', selected: 'slideshow' }}
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger name="create">
<NativeTabs.Trigger.Label></NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/explore.png')}
renderingMode="template"
sf={{ default: 'wand.and.stars', selected: 'wand.and.stars.inverse' }}
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>
+80 -83
View File
@@ -1,115 +1,112 @@
import {
Tabs,
TabList,
TabTrigger,
TabSlot,
TabTrigger,
TabTriggerSlotProps,
TabListProps,
Tabs,
} from 'expo-router/ui';
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';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
type SymbolName = ComponentProps<typeof SymbolView>['name'];
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() {
return (
<Tabs>
<TabSlot style={{ height: '100%' }} />
<TabSlot style={styles.slot} />
<TabList asChild>
<CustomTabList>
<TabTrigger name="home" href="/" asChild>
<TabButton>Home</TabButton>
</TabTrigger>
<TabTrigger name="explore" href="/explore" asChild>
<TabButton>Explore</TabButton>
</TabTrigger>
</CustomTabList>
<View style={styles.tabList}>
{tabs.map((tab) => (
<TabTrigger key={tab.name} name={tab.name} href={tab.href} asChild>
<TabButton icon={tab.icon}>{tab.label}</TabButton>
</TabTrigger>
))}
</View>
</TabList>
</Tabs>
);
}
export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
function TabButton({
children,
icon,
isFocused,
...props
}: TabTriggerSlotProps & { icon: SymbolName }) {
return (
<Pressable {...props} style={({ pressed }) => pressed && styles.pressed}>
<ThemedView
type={isFocused ? 'backgroundSelected' : 'backgroundElement'}
style={styles.tabButtonView}>
<ThemedText type="small" themeColor={isFocused ? 'text' : 'textSecondary'}>
{children}
</ThemedText>
</ThemedView>
<Pressable {...props} style={({ pressed }) => [styles.tabButton, isFocused && styles.activeTab, pressed && styles.pressed]}>
<SymbolView name={icon} size={20} tintColor={isFocused ? '#fff' : 'rgba(255,255,255,0.5)'} />
<Text style={[styles.tabText, isFocused && styles.activeText]}>{children}</Text>
</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({
tabListContainer: {
slot: {
height: '100%',
},
tabList: {
position: 'absolute',
width: '100%',
padding: Spacing.three,
left: 0,
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',
alignItems: 'center',
flexDirection: 'row',
gap: 4,
},
innerContainer: {
paddingVertical: Spacing.two,
paddingHorizontal: Spacing.five,
borderRadius: Spacing.five,
flexDirection: 'row',
alignItems: 'center',
flexGrow: 1,
gap: Spacing.two,
maxWidth: MaxContentWidth,
activeTab: {
backgroundColor: 'rgba(255,255,255,0.1)',
},
brandText: {
marginRight: 'auto',
tabText: {
color: 'rgba(255,255,255,0.5)',
fontSize: 12,
fontWeight: '800',
},
activeText: {
color: '#fff',
fontWeight: '900',
},
pressed: {
opacity: 0.7,
},
tabButtonView: {
paddingVertical: Spacing.one,
paddingHorizontal: Spacing.three,
borderRadius: Spacing.three,
},
externalPressable: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: Spacing.one,
marginLeft: Spacing.three,
opacity: 0.72,
},
});
+56
View File
@@ -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;
}
+6
View File
@@ -8,6 +8,12 @@
],
"@/assets/*": [
"./assets/*"
],
"@infiplot/core": [
"../packages/core/src/index.ts"
],
"@infiplot/types": [
"../packages/types/src/index.ts"
]
}
},