添加主题切换、多语言支持
This commit is contained in:
@@ -1,33 +1,76 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Tabs } from "expo-router";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { HapticTab } from "@/components/haptic-tab";
|
||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||
import TabBarBackground from "@/components/ui/tab-bar-background";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const { theme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
tabBarActiveTintColor: Colors[theme].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
}}>
|
||||
tabBarBackground: TabBarBackground,
|
||||
tabBarStyle: Platform.select({
|
||||
ios: {
|
||||
position: "absolute",
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
title: t("tabs.all"),
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name="list" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
name="live"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
title: t("tabs.live"),
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name="play-circle" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="upcoming"
|
||||
options={{
|
||||
title: t("tabs.upcoming"),
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name="calendar" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="finished"
|
||||
options={{
|
||||
title: t("tabs.finished"),
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name="checkmark-circle" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="favorite"
|
||||
options={{
|
||||
title: t("tabs.fav"),
|
||||
tabBarIcon: ({ color }) => (
|
||||
<IconSymbol size={28} name="star" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Fonts } from '@/constants/theme';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type="title"
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}>
|
||||
Explore
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('@/assets/images/react-logo.png')}
|
||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||
/>
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful{' '}
|
||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
19
app/(tabs)/favorite.tsx
Normal file
19
app/(tabs)/favorite.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
export default function FavoriteScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">Favorites</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
19
app/(tabs)/finished.tsx
Normal file
19
app/(tabs)/finished.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
export default function FinishedScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">Finished Events</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
@@ -1,98 +1,24 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { HelloWave } from '@/components/hello-wave';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Link } from 'expo-router';
|
||||
import { HomeHeader } from "@/components/home-header";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import React from "react";
|
||||
import { ScrollView, StyleSheet } from "react-native";
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||
headerImage={
|
||||
<Image
|
||||
source={require('@/assets/images/partial-react-logo.png')}
|
||||
style={styles.reactLogo}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Welcome!</ThemedText>
|
||||
<HelloWave />
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
||||
<ThemedText>
|
||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
||||
Press{' '}
|
||||
<ThemedText type="defaultSemiBold">
|
||||
{Platform.select({
|
||||
ios: 'cmd + d',
|
||||
android: 'cmd + m',
|
||||
web: 'F12',
|
||||
})}
|
||||
</ThemedText>{' '}
|
||||
to open developer tools.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<Link href="/modal">
|
||||
<Link.Trigger>
|
||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
|
||||
<Link.MenuAction
|
||||
title="Share"
|
||||
icon="square.and.arrow.up"
|
||||
onPress={() => alert('Share pressed')}
|
||||
/>
|
||||
<Link.Menu title="More" icon="ellipsis">
|
||||
<Link.MenuAction
|
||||
title="Delete"
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => alert('Delete pressed')}
|
||||
/>
|
||||
</Link.Menu>
|
||||
</Link.Menu>
|
||||
</Link>
|
||||
|
||||
<ThemedText>
|
||||
{`Tap the Explore tab to learn more about what's included in this starter app.`}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText>
|
||||
{`When you're ready, run `}
|
||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
<ThemedView style={styles.container}>
|
||||
<HomeHeader />
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
{/* Placeholder for future content */}
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
});
|
||||
|
||||
19
app/(tabs)/live.tsx
Normal file
19
app/(tabs)/live.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
export default function LiveScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">Live Streams</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
19
app/(tabs)/upcoming.tsx
Normal file
19
app/(tabs)/upcoming.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
export default function UpcomingScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">Upcoming Events</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
@@ -1,24 +1,34 @@
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider as NavigationThemeProvider } from '@react-navigation/native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { ThemeProvider } from '@/context/ThemeContext';
|
||||
import '@/i18n'; // Initialize i18n
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<RootLayoutNav />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<NavigationThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
</NavigationThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
193
app/profile.tsx
Normal file
193
app/profile.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Image } from "expo-image";
|
||||
import { Stack } from "expo-router";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { changeLanguage } from "@/i18n";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { theme, toggleTheme, setTheme, isSystemTheme, useSystemTheme } =
|
||||
useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const currentLanguage = i18n.language;
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = currentLanguage.startsWith("en") ? "zh" : "en";
|
||||
changeLanguage(nextLang);
|
||||
};
|
||||
|
||||
const iconColor = isDark ? "#FFFFFF" : "#000000";
|
||||
const textColor = isDark ? "#FFFFFF" : "#000000";
|
||||
const subTextColor = isDark ? "#AAAAAA" : "#666666";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: t("profile.title"),
|
||||
headerShown: true,
|
||||
// Ensure header matches theme to avoid white flash
|
||||
headerStyle: {
|
||||
backgroundColor: isDark ? "#000" : "#f2f2f7",
|
||||
},
|
||||
headerTintColor: textColor,
|
||||
headerShadowVisible: false,
|
||||
// Present the screen as a normal card and slide from right
|
||||
presentation: "card",
|
||||
animation: "slide_from_right",
|
||||
// Set the scene/content background to match theme during transition
|
||||
contentStyle: { backgroundColor: isDark ? "#000" : "#f2f2f7" },
|
||||
}}
|
||||
/>
|
||||
<ScrollView
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: isDark ? "#000" : "#f2f2f7" },
|
||||
]}
|
||||
>
|
||||
{/* User Info Section */}
|
||||
<View
|
||||
style={[
|
||||
styles.section,
|
||||
{ backgroundColor: isDark ? "#1c1c1e" : "#fff" },
|
||||
]}
|
||||
>
|
||||
<View style={styles.profileHeader}>
|
||||
<Image
|
||||
source={{ uri: "https://via.placeholder.com/100" }}
|
||||
style={styles.avatar}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<View style={styles.profileInfo}>
|
||||
<ThemedText type="title">{t("profile.name")}</ThemedText>
|
||||
<ThemedText style={{ color: subTextColor }}>
|
||||
user@example.com
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Settings Section */}
|
||||
<ThemedText style={styles.sectionTitle}>
|
||||
{t("settings.title")}
|
||||
</ThemedText>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.section,
|
||||
{ backgroundColor: isDark ? "#1c1c1e" : "#fff" },
|
||||
]}
|
||||
>
|
||||
{/* Theme Setting */}
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingLabel}>
|
||||
<IconSymbol
|
||||
name="sunny"
|
||||
size={20}
|
||||
color={iconColor}
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
<ThemedText>{t("settings.theme")}</ThemedText>
|
||||
</View>
|
||||
<View style={styles.settingControl}>
|
||||
<TouchableOpacity onPress={toggleTheme} style={styles.button}>
|
||||
<ThemedText>
|
||||
{theme === "light" ? t("settings.light") : t("settings.dark")}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Language Setting */}
|
||||
<View
|
||||
style={[
|
||||
styles.settingItem,
|
||||
{
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: isDark ? "#38383a" : "#c6c6c8",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.settingLabel}>
|
||||
<IconSymbol
|
||||
name="globe"
|
||||
size={20}
|
||||
color={iconColor}
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
<ThemedText>{t("settings.language")}</ThemedText>
|
||||
</View>
|
||||
<View style={styles.settingControl}>
|
||||
<TouchableOpacity onPress={toggleLanguage} style={styles.button}>
|
||||
<ThemedText>
|
||||
{currentLanguage.startsWith("zh")
|
||||
? t("settings.chinese")
|
||||
: t("settings.english")}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
section: {
|
||||
marginTop: 20,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 10, // iOS style groups
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginLeft: 32,
|
||||
marginTop: 20,
|
||||
marginBottom: 5,
|
||||
fontSize: 13,
|
||||
textTransform: "uppercase",
|
||||
opacity: 0.6,
|
||||
},
|
||||
profileHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
},
|
||||
avatar: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: "#ccc",
|
||||
},
|
||||
profileInfo: {
|
||||
marginLeft: 15,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 12,
|
||||
},
|
||||
settingLabel: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
settingControl: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
button: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
@@ -1,25 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
73
components/home-header.tsx
Normal file
73
components/home-header.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
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 { useTheme } from "@/context/ThemeContext";
|
||||
import { useRouter } from "expo-router";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, TouchableOpacity } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
export function HomeHeader() {
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const iconColor = theme === "light" ? Colors.light.icon : Colors.dark.icon;
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<SafeAreaView edges={["top"]} style={styles.safeArea}>
|
||||
<ThemedView style={styles.headerContent}>
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
{t("home.title")}
|
||||
</ThemedText>
|
||||
<ThemedView style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
onPress={() => {
|
||||
/* TODO: Search Action */
|
||||
}}
|
||||
>
|
||||
<IconSymbol name="search" size={24} color={iconColor} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
onPress={() => router.push("/profile")}
|
||||
>
|
||||
<IconSymbol name="person-circle" size={24} color={iconColor} />
|
||||
</TouchableOpacity>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
</SafeAreaView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingTop: 0,
|
||||
},
|
||||
safeArea: {
|
||||
paddingBottom: 10,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingTop: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
alignItems: "center",
|
||||
},
|
||||
iconButton: {
|
||||
padding: 4,
|
||||
},
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
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,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,13 @@
|
||||
// Fallback for using MaterialIcons on Android and web.
|
||||
// Cross-platform icon component using Ionicons for all platforms.
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import { ComponentProps } from "react";
|
||||
import { OpaqueColorValue, StyleProp, TextStyle } from "react-native";
|
||||
|
||||
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;
|
||||
// Define the icon names we use in the app
|
||||
export type IconSymbolName = ComponentProps<typeof Ionicons>["name"];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* An icon component that uses Ionicons on all platforms.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
@@ -35,7 +19,7 @@ export function IconSymbol({
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
weight?: string; // Kept for compatibility but unused
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
return <Ionicons color={color} size={size} name={name} style={style} />;
|
||||
}
|
||||
|
||||
22
components/ui/tab-bar-background.ios.tsx
Normal file
22
components/ui/tab-bar-background.ios.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { StyleSheet } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function BlurTabBarBackground() {
|
||||
return (
|
||||
<BlurView
|
||||
// System chrome material automatically adapts to the system's theme
|
||||
// and matches the native tab bar appearance on iOS.
|
||||
tint="systemChromeMaterial"
|
||||
intensity={100}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBottomTabOverflow() {
|
||||
const tabHeight = useBottomTabBarHeight();
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
return tabHeight - bottom;
|
||||
}
|
||||
6
components/ui/tab-bar-background.tsx
Normal file
6
components/ui/tab-bar-background.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// This is a shim for web and Android where the tab bar is generally opaque.
|
||||
export default undefined;
|
||||
|
||||
export function useBottomTabOverflow() {
|
||||
return 0;
|
||||
}
|
||||
115
context/ThemeContext.tsx
Normal file
115
context/ThemeContext.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import { useColorScheme as useDeviceColorScheme } from "react-native";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
systemTheme: Theme;
|
||||
isSystemTheme: boolean;
|
||||
useSystemTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
const THEME_STORAGE_KEY = "user_theme_preference";
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const deviceColorScheme = useDeviceColorScheme();
|
||||
const systemTheme = deviceColorScheme === "dark" ? "dark" : "light";
|
||||
|
||||
// State to hold the current theme
|
||||
const [theme, setThemeState] = useState<Theme>(systemTheme);
|
||||
// State to track if we are following system theme
|
||||
const [isSystemTheme, setIsSystemTheme] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadThemePersistence();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If we are following system theme, update when device theme changes
|
||||
if (isSystemTheme) {
|
||||
setThemeState(systemTheme);
|
||||
}
|
||||
}, [systemTheme, isSystemTheme]);
|
||||
|
||||
const loadThemePersistence = async () => {
|
||||
try {
|
||||
const storedTheme = await AsyncStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (storedTheme) {
|
||||
if (storedTheme === "system") {
|
||||
setIsSystemTheme(true);
|
||||
setThemeState(systemTheme);
|
||||
} else {
|
||||
setIsSystemTheme(false);
|
||||
setThemeState(storedTheme as Theme);
|
||||
}
|
||||
} else {
|
||||
// Default to system
|
||||
setIsSystemTheme(true);
|
||||
setThemeState(systemTheme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load theme preference", error);
|
||||
// Fallback to system
|
||||
setIsSystemTheme(true);
|
||||
setThemeState(systemTheme);
|
||||
}
|
||||
};
|
||||
|
||||
const saveThemePreference = async (newTheme: Theme | "system") => {
|
||||
try {
|
||||
await AsyncStorage.setItem(THEME_STORAGE_KEY, newTheme);
|
||||
} catch (error) {
|
||||
console.warn("Failed to save theme preference", error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === "light" ? "dark" : "light";
|
||||
setIsSystemTheme(false);
|
||||
setThemeState(newTheme);
|
||||
saveThemePreference(newTheme);
|
||||
};
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setIsSystemTheme(false);
|
||||
setThemeState(newTheme);
|
||||
saveThemePreference(newTheme);
|
||||
};
|
||||
|
||||
const useSystemTheme = () => {
|
||||
setIsSystemTheme(true);
|
||||
setThemeState(systemTheme);
|
||||
saveThemePreference("system");
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
theme,
|
||||
toggleTheme,
|
||||
setTheme,
|
||||
systemTheme,
|
||||
isSystemTheme,
|
||||
useSystemTheme,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1 +1,11 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { ColorSchemeName } from "react-native";
|
||||
|
||||
export function useColorScheme(): ColorSchemeName {
|
||||
try {
|
||||
const { theme } = useTheme();
|
||||
return theme;
|
||||
} catch (e) {
|
||||
return "light";
|
||||
}
|
||||
}
|
||||
|
||||
55
i18n/index.ts
Normal file
55
i18n/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as Localization from "expo-localization";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import en from "./locales/en.json";
|
||||
import zh from "./locales/zh.json";
|
||||
|
||||
const RESOURCES = {
|
||||
en: { translation: en },
|
||||
zh: { translation: zh },
|
||||
};
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = "user_language_preference";
|
||||
|
||||
const initI18n = async () => {
|
||||
let savedLanguage = null;
|
||||
try {
|
||||
savedLanguage = await AsyncStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn("Failed to load language preference", error);
|
||||
}
|
||||
|
||||
// Get device language (e.g. "en-US" -> "en")
|
||||
// Prefer Localization.getLocales()[0].languageCode; fall back to 'en'
|
||||
const locales = Localization.getLocales?.() ?? [];
|
||||
const deviceLanguageCode = locales[0]?.languageCode ?? "en";
|
||||
|
||||
const languageToUse = savedLanguage || deviceLanguageCode || "en";
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: RESOURCES,
|
||||
lng: languageToUse,
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
react: {
|
||||
useSuspense: false, // For React Native compatibility
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
initI18n();
|
||||
|
||||
export default i18n;
|
||||
|
||||
export const changeLanguage = async (lang: string) => {
|
||||
try {
|
||||
await i18n.changeLanguage(lang);
|
||||
await AsyncStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
|
||||
} catch (error) {
|
||||
console.warn("Failed to change/save language", error);
|
||||
}
|
||||
};
|
||||
28
i18n/locales/en.json
Normal file
28
i18n/locales/en.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"welcome": "Welcome!",
|
||||
"tabs": {
|
||||
"all": "All",
|
||||
"live": "Live",
|
||||
"upcoming": "Upcoming",
|
||||
"finished": "Finished",
|
||||
"fav": "Favorites"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"theme": "Theme",
|
||||
"language": "Language",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"english": "English",
|
||||
"chinese": "Chinese"
|
||||
},
|
||||
"home": {
|
||||
"title": "Physical"
|
||||
},
|
||||
"profile": {
|
||||
"title": "My Profile",
|
||||
"name": "User Name",
|
||||
"settings": "Settings"
|
||||
}
|
||||
}
|
||||
28
i18n/locales/zh.json
Normal file
28
i18n/locales/zh.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"welcome": "欢迎!",
|
||||
"tabs": {
|
||||
"all": "全部",
|
||||
"live": "直播",
|
||||
"upcoming": "即将到来",
|
||||
"finished": "已完成",
|
||||
"fav": "收藏"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"theme": "主题",
|
||||
"language": "语言",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"system": "跟随系统",
|
||||
"english": "英文",
|
||||
"chinese": "中文"
|
||||
},
|
||||
"home": {
|
||||
"title": "Physical"
|
||||
},
|
||||
"profile": {
|
||||
"title": "我的",
|
||||
"name": "用户名",
|
||||
"settings": "设置"
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script is used to reset the project to a blank state.
|
||||
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
|
||||
const root = process.cwd();
|
||||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
|
||||
const exampleDir = "app-example";
|
||||
const newAppDir = "app";
|
||||
const exampleDirPath = path.join(root, exampleDir);
|
||||
|
||||
const indexContent = `import { Text, View } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const layoutContent = `import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
`;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const moveDirectories = async (userInput) => {
|
||||
try {
|
||||
if (userInput === "y") {
|
||||
// Create the app-example directory
|
||||
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
||||
console.log(`📁 /${exampleDir} directory created.`);
|
||||
}
|
||||
|
||||
// Move old directories to new app-example directory or delete them
|
||||
for (const dir of oldDirs) {
|
||||
const oldDirPath = path.join(root, dir);
|
||||
if (fs.existsSync(oldDirPath)) {
|
||||
if (userInput === "y") {
|
||||
const newDirPath = path.join(root, exampleDir, dir);
|
||||
await fs.promises.rename(oldDirPath, newDirPath);
|
||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
||||
} else {
|
||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
||||
console.log(`❌ /${dir} deleted.`);
|
||||
}
|
||||
} else {
|
||||
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new /app directory
|
||||
const newAppDirPath = path.join(root, newAppDir);
|
||||
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||
console.log("\n📁 New /app directory created.");
|
||||
|
||||
// Create index.tsx
|
||||
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||
await fs.promises.writeFile(indexPath, indexContent);
|
||||
console.log("📄 app/index.tsx created.");
|
||||
|
||||
// Create _layout.tsx
|
||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||
console.log("📄 app/_layout.tsx created.");
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:");
|
||||
console.log(
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
||||
userInput === "y"
|
||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
rl.question(
|
||||
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
||||
(answer) => {
|
||||
const userInput = answer.trim().toLowerCase() || "y";
|
||||
if (userInput === "y" || userInput === "n") {
|
||||
moveDirectories(userInput).finally(() => rl.close());
|
||||
} else {
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user