add ios app
This commit is contained in:
162
app-ios/components/chat/ChatBubble.tsx
Normal file
162
app-ios/components/chat/ChatBubble.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { AppColors } from '@/constants/theme';
|
||||
import { Message } from '@/data/mockData';
|
||||
|
||||
interface ChatBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function ChatBubble({ message }: ChatBubbleProps) {
|
||||
const isAI = message.senderType === 'ai';
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isAI ? styles.aiContainer : styles.userContainer]}>
|
||||
{/* Avatar */}
|
||||
<View style={[styles.avatar, isAI ? styles.aiAvatar : styles.userAvatar]}>
|
||||
<Text style={styles.avatarEmoji}>{isAI ? '📖' : '👤'}</Text>
|
||||
</View>
|
||||
|
||||
{/* Bubble */}
|
||||
<View style={[styles.bubble, isAI ? styles.aiBubble : styles.userBubble]}>
|
||||
<Text style={[styles.text, isAI ? styles.aiText : styles.userText]}>
|
||||
{message.content}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Typing indicator component
|
||||
export function TypingIndicator() {
|
||||
return (
|
||||
<View style={[styles.container, styles.aiContainer]}>
|
||||
<View style={[styles.avatar, styles.aiAvatar]}>
|
||||
<Text style={styles.avatarEmoji}>📖</Text>
|
||||
</View>
|
||||
<View style={[styles.bubble, styles.aiBubble]}>
|
||||
<View style={styles.typingDots}>
|
||||
<View style={[styles.dot, styles.dot1]} />
|
||||
<View style={[styles.dot, styles.dot2]} />
|
||||
<View style={[styles.dot, styles.dot3]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Time separator component
|
||||
export function TimeSeparator({ time }: { time: string }) {
|
||||
return (
|
||||
<View style={styles.timeContainer}>
|
||||
<View style={styles.timeBadge}>
|
||||
<Text style={styles.timeText}>{time}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
aiContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
userContainer: {
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
aiAvatar: {
|
||||
backgroundColor: AppColors.lavender,
|
||||
marginRight: 10,
|
||||
},
|
||||
userAvatar: {
|
||||
backgroundColor: AppColors.blushPink,
|
||||
marginLeft: 10,
|
||||
},
|
||||
avatarEmoji: {
|
||||
fontSize: 18,
|
||||
},
|
||||
bubble: {
|
||||
maxWidth: '70%',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 18,
|
||||
},
|
||||
aiBubble: {
|
||||
backgroundColor: AppColors.white,
|
||||
borderTopLeftRadius: 4,
|
||||
shadowColor: AppColors.deepPurple,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
userBubble: {
|
||||
backgroundColor: AppColors.mediumPurple,
|
||||
borderTopRightRadius: 4,
|
||||
shadowColor: AppColors.mediumPurple,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
text: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
aiText: {
|
||||
color: AppColors.deepPurple,
|
||||
},
|
||||
userText: {
|
||||
color: AppColors.white,
|
||||
},
|
||||
// Typing indicator styles
|
||||
typingDots: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: AppColors.slatePurple,
|
||||
marginHorizontal: 2,
|
||||
opacity: 0.5,
|
||||
},
|
||||
dot1: {
|
||||
opacity: 1,
|
||||
},
|
||||
dot2: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
dot3: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
// Time separator styles
|
||||
timeContainer: {
|
||||
alignItems: 'center',
|
||||
marginVertical: 16,
|
||||
},
|
||||
timeBadge: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: 'rgba(140, 142, 163, 0.1)',
|
||||
borderRadius: 12,
|
||||
},
|
||||
timeText: {
|
||||
fontSize: 12,
|
||||
color: AppColors.slatePurple,
|
||||
},
|
||||
});
|
||||
180
app-ios/components/chat/ChatInput.tsx
Normal file
180
app-ios/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
KeyboardAvoidingView,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { AppColors } from '@/constants/theme';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSendMessage: (text: string) => void;
|
||||
onVoicePress?: () => void;
|
||||
onEmojiPress?: () => void;
|
||||
onMorePress?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSendMessage,
|
||||
onVoicePress,
|
||||
onEmojiPress,
|
||||
onMorePress
|
||||
}: ChatInputProps) {
|
||||
const [text, setText] = useState('');
|
||||
const [isVoiceMode, setIsVoiceMode] = useState(false);
|
||||
|
||||
const handleSend = () => {
|
||||
if (text.trim()) {
|
||||
onSendMessage(text.trim());
|
||||
setText('');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleVoiceMode = () => {
|
||||
setIsVoiceMode(!isVoiceMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.inputRow}>
|
||||
{/* Voice/Keyboard Toggle */}
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
onPress={toggleVoiceMode}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name={isVoiceMode ? 'keypad-outline' : 'mic-outline'}
|
||||
size={26}
|
||||
color={AppColors.slatePurple}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Text Input or Voice Button */}
|
||||
{isVoiceMode ? (
|
||||
<TouchableOpacity
|
||||
style={styles.voiceButton}
|
||||
onPress={onVoicePress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="mic" size={20} color={AppColors.slatePurple} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
placeholder="说点什么..."
|
||||
placeholderTextColor={AppColors.slatePurple}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
multiline
|
||||
maxLength={1000}
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={handleSend}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Emoji Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
onPress={onEmojiPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name="happy-outline"
|
||||
size={26}
|
||||
color={AppColors.slatePurple}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* More Options / Send Button */}
|
||||
{text.trim() ? (
|
||||
<TouchableOpacity
|
||||
style={styles.sendButton}
|
||||
onPress={handleSend}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="send" size={20} color={AppColors.white} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
onPress={onMorePress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name="add-circle-outline"
|
||||
size={26}
|
||||
color={AppColors.slatePurple}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: AppColors.white,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(32, 0, 40, 0.08)',
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 10,
|
||||
paddingBottom: Platform.OS === 'ios' ? 34 : 10,
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: 8,
|
||||
},
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
textInput: {
|
||||
flex: 1,
|
||||
minHeight: 40,
|
||||
maxHeight: 100,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: AppColors.cream,
|
||||
borderRadius: 20,
|
||||
fontSize: 16,
|
||||
color: AppColors.deepPurple,
|
||||
lineHeight: 20,
|
||||
},
|
||||
voiceButton: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
backgroundColor: AppColors.cream,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
sendButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
backgroundColor: AppColors.mediumPurple,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: AppColors.mediumPurple,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
});
|
||||
131
app-ios/components/chat/ConversationItem.tsx
Normal file
131
app-ios/components/chat/ConversationItem.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withTiming,
|
||||
runOnJS,
|
||||
} from 'react-native-reanimated';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { AppColors } from '@/constants/theme';
|
||||
import { Conversation } from '@/data/mockData';
|
||||
|
||||
interface ConversationItemProps {
|
||||
conversation: Conversation;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function ConversationItem({ conversation, onPress }: ConversationItemProps) {
|
||||
const scale = useSharedValue(1);
|
||||
const opacity = useSharedValue(1);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
const tapGesture = Gesture.Tap()
|
||||
.onBegin(() => {
|
||||
scale.value = withSpring(0.97, { damping: 15, stiffness: 400 });
|
||||
opacity.value = withTiming(0.8, { duration: 100 });
|
||||
})
|
||||
.onFinalize((_, success) => {
|
||||
scale.value = withSpring(1, { damping: 15, stiffness: 400 });
|
||||
opacity.value = withTiming(1, { duration: 150 });
|
||||
if (success) {
|
||||
runOnJS(onPress)();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={tapGesture}>
|
||||
<Animated.View style={[styles.container, animatedStyle]}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarEmoji}>{conversation.avatarEmoji}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{conversation.title}
|
||||
</Text>
|
||||
<Text style={styles.preview} numberOfLines={1}>
|
||||
{conversation.lastMessage}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.meta}>
|
||||
<Text style={styles.timestamp}>{conversation.timestamp}</Text>
|
||||
{conversation.unreadCount && conversation.unreadCount > 0 && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{conversation.unreadCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
backgroundColor: AppColors.cream,
|
||||
},
|
||||
avatar: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 14,
|
||||
backgroundColor: AppColors.lavender,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 14,
|
||||
shadowColor: AppColors.deepPurple,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
avatarEmoji: {
|
||||
fontSize: 26,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 17,
|
||||
fontWeight: '500',
|
||||
color: AppColors.deepPurple,
|
||||
marginBottom: 4,
|
||||
},
|
||||
preview: {
|
||||
fontSize: 14,
|
||||
color: AppColors.slatePurple,
|
||||
lineHeight: 20,
|
||||
},
|
||||
meta: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 12,
|
||||
color: AppColors.slatePurple,
|
||||
marginBottom: 6,
|
||||
},
|
||||
badge: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: AppColors.mediumPurple,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: 12,
|
||||
color: AppColors.white,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
25
app-ios/components/external-link.tsx
Normal file
25
app-ios/components/external-link.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (process.env.EXPO_OS !== 'web') {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
event.preventDefault();
|
||||
// Open the link in an in-app browser.
|
||||
await openBrowserAsync(href, {
|
||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
app-ios/components/haptic-tab.tsx
Normal file
18
app-ios/components/haptic-tab.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||
import { PlatformPressable } from '@react-navigation/elements';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||
return (
|
||||
<PlatformPressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
// Add a soft haptic feedback when pressing down on the tabs.
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
props.onPressIn?.(ev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
app-ios/components/hello-wave.tsx
Normal file
19
app-ios/components/hello-wave.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
export function HelloWave() {
|
||||
return (
|
||||
<Animated.Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
animationName: {
|
||||
'50%': { transform: [{ rotate: '25deg' }] },
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: '300ms',
|
||||
}}>
|
||||
👋
|
||||
</Animated.Text>
|
||||
);
|
||||
}
|
||||
139
app-ios/components/memoir/ChapterContent.tsx
Normal file
139
app-ios/components/memoir/ChapterContent.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView } from 'react-native';
|
||||
import { AppColors } from '@/constants/theme';
|
||||
import { Chapter } from '@/data/mockData';
|
||||
|
||||
interface ChapterContentProps {
|
||||
chapter: Chapter;
|
||||
}
|
||||
|
||||
export function ChapterContent({ chapter }: ChapterContentProps) {
|
||||
// Parse content to handle quotes
|
||||
const renderContent = () => {
|
||||
if (!chapter.content) {
|
||||
return (
|
||||
<Text style={styles.emptyText}>
|
||||
这一章还没有内容,快去和回忆录助手聊聊吧!
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const parts = chapter.content.split('"');
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if this part is a quote (starts and ends with quote marks in original)
|
||||
if (part.startsWith('"') || (index > 0 && parts[index - 1].endsWith('"'))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for quote pattern
|
||||
const quoteMatch = chapter.content?.match(/"([^"]+)"/g);
|
||||
const isQuote = quoteMatch && quoteMatch.some(q => q.includes(part) && part.length > 10);
|
||||
|
||||
if (part.trim().startsWith('"') && part.trim().endsWith('"')) {
|
||||
return (
|
||||
<View key={index} style={styles.quoteBlock}>
|
||||
<Text style={styles.quoteText}>{part}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Split by newlines and render paragraphs
|
||||
return part.split('\n\n').map((paragraph, pIndex) => {
|
||||
if (!paragraph.trim()) return null;
|
||||
|
||||
// Check if it's a quote
|
||||
if (paragraph.trim().startsWith('"') && paragraph.trim().endsWith('"')) {
|
||||
return (
|
||||
<View key={`${index}-${pIndex}`} style={styles.quoteBlock}>
|
||||
<Text style={styles.quoteText}>{paragraph.trim()}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text key={`${index}-${pIndex}`} style={styles.paragraph}>
|
||||
{paragraph.trim()}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Chapter Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.chapterNumber}>
|
||||
第{['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][chapter.number - 1] || chapter.number}章
|
||||
</Text>
|
||||
<Text style={styles.chapterTitle}>{chapter.title}</Text>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
chapterNumber: {
|
||||
fontSize: 13,
|
||||
color: AppColors.mediumPurple,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
chapterTitle: {
|
||||
fontSize: 26,
|
||||
fontWeight: '600',
|
||||
color: AppColors.deepPurple,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingBottom: 100,
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 16,
|
||||
lineHeight: 30,
|
||||
color: AppColors.deepPurple,
|
||||
marginBottom: 20,
|
||||
textAlign: 'justify',
|
||||
},
|
||||
quoteBlock: {
|
||||
marginVertical: 24,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
backgroundColor: AppColors.lavender,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: AppColors.mediumPurple,
|
||||
borderTopRightRadius: 12,
|
||||
borderBottomRightRadius: 12,
|
||||
},
|
||||
quoteText: {
|
||||
fontSize: 15,
|
||||
fontStyle: 'italic',
|
||||
color: AppColors.deepPurple,
|
||||
lineHeight: 24,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 15,
|
||||
color: AppColors.slatePurple,
|
||||
textAlign: 'center',
|
||||
marginTop: 40,
|
||||
},
|
||||
});
|
||||
98
app-ios/components/memoir/ChapterItem.tsx
Normal file
98
app-ios/components/memoir/ChapterItem.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { AppColors } from '@/constants/theme';
|
||||
import { Chapter } from '@/data/mockData';
|
||||
|
||||
interface ChapterItemProps {
|
||||
chapter: Chapter;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function ChapterItem({ chapter, onPress }: ChapterItemProps) {
|
||||
const getStatusText = () => {
|
||||
switch (chapter.status) {
|
||||
case 'complete':
|
||||
return `已整理 · 约${chapter.pageCount}页`;
|
||||
case 'partial':
|
||||
return `部分整理 · 约${chapter.pageCount}页`;
|
||||
case 'pending':
|
||||
return '待补充';
|
||||
}
|
||||
};
|
||||
|
||||
const isComplete = chapter.status === 'complete';
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.container}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.number}>
|
||||
<Text style={styles.numberText}>
|
||||
{chapter.number.toString().padStart(2, '0')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>{chapter.title}</Text>
|
||||
<Text style={[styles.status, isComplete && styles.statusComplete]}>
|
||||
{getStatusText()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={AppColors.slatePurple}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
backgroundColor: AppColors.white,
|
||||
borderRadius: 14,
|
||||
marginBottom: 12,
|
||||
shadowColor: AppColors.deepPurple,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 8,
|
||||
elevation: 1,
|
||||
},
|
||||
number: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
backgroundColor: AppColors.lavender,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 14,
|
||||
},
|
||||
numberText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: AppColors.deepPurple,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
color: AppColors.deepPurple,
|
||||
marginBottom: 2,
|
||||
},
|
||||
status: {
|
||||
fontSize: 12,
|
||||
color: AppColors.slatePurple,
|
||||
},
|
||||
statusComplete: {
|
||||
color: AppColors.mediumPurple,
|
||||
},
|
||||
});
|
||||
196
app-ios/components/memoir/FullMemoirContent.tsx
Normal file
196
app-ios/components/memoir/FullMemoirContent.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { AppColors } from '@/constants/theme';
|
||||
import { Memoir } from '@/data/mockData';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
interface FullMemoirContentProps {
|
||||
memoir: Memoir;
|
||||
}
|
||||
|
||||
const chineseNumbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||||
|
||||
function renderChapterContent(content: string | undefined) {
|
||||
if (!content) {
|
||||
return (
|
||||
<Text style={styles.emptyText}>
|
||||
这一章还没有内容,快去和回忆录助手聊聊吧!
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return content.split('\n\n').map((paragraph, index) => {
|
||||
if (!paragraph.trim()) return null;
|
||||
|
||||
// Check if it's a quote
|
||||
if (paragraph.trim().startsWith('"') && paragraph.trim().endsWith('"')) {
|
||||
return (
|
||||
<View key={index} style={styles.quoteBlock}>
|
||||
<Text style={styles.quoteText}>{paragraph.trim()}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text key={index} style={styles.paragraph}>
|
||||
{paragraph.trim()}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function FullMemoirContent({ memoir }: FullMemoirContentProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Content */}
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
{/* Book Title */}
|
||||
<View style={styles.bookHeader}>
|
||||
<Text style={styles.bookTitle}>{memoir.title}</Text>
|
||||
<Text style={styles.bookSubtitle}>{memoir.subtitle}</Text>
|
||||
</View>
|
||||
|
||||
{/* All Chapters */}
|
||||
{memoir.chapters.map((chapter, index) => (
|
||||
<View key={chapter.id} style={styles.chapterSection}>
|
||||
{/* Chapter Header */}
|
||||
<View style={styles.chapterHeader}>
|
||||
<Text style={styles.chapterNumber}>
|
||||
第{chineseNumbers[chapter.number - 1] || chapter.number}章
|
||||
</Text>
|
||||
<Text style={styles.chapterTitle}>{chapter.title}</Text>
|
||||
</View>
|
||||
|
||||
{/* Chapter Content */}
|
||||
<View style={styles.chapterContent}>
|
||||
{renderChapterContent(chapter.content)}
|
||||
</View>
|
||||
|
||||
{/* Chapter Divider (except for last chapter) */}
|
||||
{index < memoir.chapters.length - 1 && (
|
||||
<View style={styles.chapterDivider}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Ionicons name="leaf-outline" size={16} color={AppColors.lavender} />
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* End Mark */}
|
||||
<View style={styles.endMark}>
|
||||
<Text style={styles.endMarkText}>— 全文完 —</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingBottom: 100,
|
||||
},
|
||||
bookHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
paddingBottom: 32,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(32, 0, 40, 0.08)',
|
||||
},
|
||||
bookTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '600',
|
||||
color: AppColors.deepPurple,
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
bookSubtitle: {
|
||||
fontSize: 14,
|
||||
color: AppColors.slatePurple,
|
||||
textAlign: 'center',
|
||||
},
|
||||
chapterSection: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
chapterHeader: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
chapterNumber: {
|
||||
fontSize: 13,
|
||||
color: AppColors.mediumPurple,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
chapterTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
color: AppColors.deepPurple,
|
||||
},
|
||||
chapterContent: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 16,
|
||||
lineHeight: 30,
|
||||
color: AppColors.deepPurple,
|
||||
marginBottom: 20,
|
||||
textAlign: 'justify',
|
||||
},
|
||||
quoteBlock: {
|
||||
marginVertical: 24,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
backgroundColor: AppColors.lavender,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: AppColors.mediumPurple,
|
||||
borderTopRightRadius: 12,
|
||||
borderBottomRightRadius: 12,
|
||||
},
|
||||
quoteText: {
|
||||
fontSize: 15,
|
||||
fontStyle: 'italic',
|
||||
color: AppColors.deepPurple,
|
||||
lineHeight: 24,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 15,
|
||||
color: AppColors.slatePurple,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
chapterDivider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginVertical: 32,
|
||||
gap: 12,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(32, 0, 40, 0.08)',
|
||||
maxWidth: 80,
|
||||
},
|
||||
endMark: {
|
||||
alignItems: 'center',
|
||||
marginTop: 40,
|
||||
paddingTop: 32,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(32, 0, 40, 0.08)',
|
||||
},
|
||||
endMarkText: {
|
||||
fontSize: 14,
|
||||
color: AppColors.slatePurple,
|
||||
letterSpacing: 2,
|
||||
},
|
||||
});
|
||||
79
app-ios/components/parallax-scroll-view.tsx
Normal file
79
app-ios/components/parallax-scroll-view.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollOffset,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
headerBackgroundColor: { dark: string; light: string };
|
||||
}>;
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollOffset(scrollRef);
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
43
app-ios/components/profile/SectionCard.tsx
Normal file
43
app-ios/components/profile/SectionCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { AppColors } from '@/constants/theme';
|
||||
|
||||
interface SectionCardProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SectionCard({ title, children }: SectionCardProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<View style={styles.card}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 12,
|
||||
color: AppColors.slatePurple,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
paddingHorizontal: 4,
|
||||
marginBottom: 10,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: AppColors.white,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: AppColors.deepPurple,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 8,
|
||||
elevation: 1,
|
||||
},
|
||||
});
|
||||
108
app-ios/components/profile/SettingItem.tsx
Normal file
108
app-ios/components/profile/SettingItem.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Switch } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { AppColors } from '@/constants/theme';
|
||||
|
||||
interface SettingItemProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type: 'arrow' | 'toggle';
|
||||
value?: boolean;
|
||||
onPress?: () => void;
|
||||
onToggle?: (value: boolean) => void;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export function SettingItem({
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
type,
|
||||
value = false,
|
||||
onPress,
|
||||
onToggle,
|
||||
isLast = false,
|
||||
}: SettingItemProps) {
|
||||
const handlePress = () => {
|
||||
if (type === 'arrow' && onPress) {
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, !isLast && styles.border]}
|
||||
onPress={handlePress}
|
||||
activeOpacity={type === 'arrow' ? 0.7 : 1}
|
||||
disabled={type === 'toggle'}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons
|
||||
name={icon as any}
|
||||
size={20}
|
||||
color={AppColors.deepPurple}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
{description && (
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{type === 'arrow' ? (
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={18}
|
||||
color={AppColors.slatePurple}
|
||||
/>
|
||||
) : (
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onToggle}
|
||||
trackColor={{
|
||||
false: AppColors.slatePurple,
|
||||
true: AppColors.mediumPurple
|
||||
}}
|
||||
thumbColor={AppColors.white}
|
||||
ios_backgroundColor={AppColors.slatePurple}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
border: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(32, 0, 40, 0.06)',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
backgroundColor: AppColors.lavender,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
label: {
|
||||
fontSize: 15,
|
||||
color: AppColors.deepPurple,
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
color: AppColors.slatePurple,
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
60
app-ios/components/themed-text.tsx
Normal file
60
app-ios/components/themed-text.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = 'default',
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
},
|
||||
});
|
||||
14
app-ios/components/themed-view.tsx
Normal file
14
app-ios/components/themed-view.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
45
app-ios/components/ui/collapsible.tsx
Normal file
45
app-ios/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
});
|
||||
32
app-ios/components/ui/icon-symbol.ios.tsx
Normal file
32
app-ios/components/ui/icon-symbol.ios.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
weight = 'regular',
|
||||
}: {
|
||||
name: SymbolViewProps['name'];
|
||||
size?: number;
|
||||
color: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
app-ios/components/ui/icon-symbol.tsx
Normal file
41
app-ios/components/ui/icon-symbol.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// Fallback for using MaterialIcons on Android and web.
|
||||
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
||||
import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
* Add your SF Symbols to Material Icons mappings here.
|
||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||
*/
|
||||
const MAPPING = {
|
||||
'house.fill': 'home',
|
||||
'paperplane.fill': 'send',
|
||||
'chevron.left.forwardslash.chevron.right': 'code',
|
||||
'chevron.right': 'chevron-right',
|
||||
} as IconMapping;
|
||||
|
||||
/**
|
||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
}
|
||||
Reference in New Issue
Block a user