add ios app

This commit is contained in:
penghanyuan
2026-01-31 21:20:50 +01:00
parent a170632270
commit 748f252c2f
63 changed files with 38507 additions and 0 deletions

View 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,
},
});

View 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,
},
});

View 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',
},
});

View 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,
});
}
}}
/>
);
}

View 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);
}}
/>
);
}

View 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>
);
}

View 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,
},
});

View 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,
},
});

View 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,
},
});

View 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',
},
});

View 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,
},
});

View 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,
},
});

View 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',
},
});

View 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} />;
}

View 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,
},
});

View 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,
]}
/>
);
}

View 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} />;
}