添加主题切换、多语言支持
This commit is contained in:
@@ -1,33 +1,76 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from "expo-router";
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
import { HapticTab } from '@/components/haptic-tab';
|
import { HapticTab } from "@/components/haptic-tab";
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
import { Colors } from '@/constants/theme';
|
import TabBarBackground from "@/components/ui/tab-bar-background";
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { Colors } from "@/constants/theme";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const { theme } = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
tabBarActiveTintColor: Colors[theme].tint,
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarButton: HapticTab,
|
tabBarButton: HapticTab,
|
||||||
}}>
|
tabBarBackground: TabBarBackground,
|
||||||
|
tabBarStyle: Platform.select({
|
||||||
|
ios: {
|
||||||
|
position: "absolute",
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="index"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Home',
|
title: t("tabs.all"),
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
tabBarIcon: ({ color }) => (
|
||||||
|
<IconSymbol size={28} name="list" color={color} />
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="explore"
|
name="live"
|
||||||
options={{
|
options={{
|
||||||
title: 'Explore',
|
title: t("tabs.live"),
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
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>
|
</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 { HomeHeader } from "@/components/home-header";
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
import { ThemedView } from "@/components/themed-view";
|
||||||
|
import React from "react";
|
||||||
import { HelloWave } from '@/components/hello-wave';
|
import { ScrollView, StyleSheet } from "react-native";
|
||||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { Link } from 'expo-router';
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
return (
|
return (
|
||||||
<ParallaxScrollView
|
<ThemedView style={styles.container}>
|
||||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
<HomeHeader />
|
||||||
headerImage={
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
<Image
|
{/* Placeholder for future content */}
|
||||||
source={require('@/assets/images/partial-react-logo.png')}
|
</ScrollView>
|
||||||
style={styles.reactLogo}
|
</ThemedView>
|
||||||
/>
|
|
||||||
}>
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
titleContainer: {
|
container: {
|
||||||
flexDirection: 'row',
|
flex: 1,
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
},
|
},
|
||||||
stepContainer: {
|
content: {
|
||||||
gap: 8,
|
padding: 16,
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
reactLogo: {
|
|
||||||
height: 178,
|
|
||||||
width: 290,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
position: 'absolute',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
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 { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
|
import { ThemeProvider } from '@/context/ThemeContext';
|
||||||
|
import '@/i18n'; // Initialize i18n
|
||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
anchor: '(tabs)',
|
anchor: '(tabs)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<RootLayoutNav />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootLayoutNav() {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<NavigationThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||||
</ThemeProvider>
|
</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';
|
// Define the icon names we use in the app
|
||||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
export type IconSymbolName = ComponentProps<typeof Ionicons>["name"];
|
||||||
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.
|
* An icon component that uses Ionicons on all platforms.
|
||||||
* - 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({
|
export function IconSymbol({
|
||||||
name,
|
name,
|
||||||
@@ -35,7 +19,7 @@ export function IconSymbol({
|
|||||||
size?: number;
|
size?: number;
|
||||||
color: string | OpaqueColorValue;
|
color: string | OpaqueColorValue;
|
||||||
style?: StyleProp<TextStyle>;
|
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",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
|
||||||
"android": "expo start --android",
|
"android": "expo start --android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo start --ios",
|
||||||
"web": "expo start --web",
|
"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