提交修改
CI / Server (Go) (push) Has been cancelled
CI / Web (Next.js) (push) Has been cancelled
CI / Mobile (React Native) (push) Has been cancelled
CI / Admin (React) (push) Has been cancelled
CI / Docker Build (push) Has been cancelled

This commit is contained in:
zeronline
2026-06-29 16:49:54 +08:00
parent 545329ac40
commit b01a5f91c3
43 changed files with 740 additions and 338 deletions
+62
View File
@@ -0,0 +1,62 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { useColorScheme } from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { ThemeMode, IThemeColors } from '../constants/theme'
import { THEME } from '../constants/theme'
const STORAGE_KEY = 'inchstep-theme'
interface IThemeContext {
mode: ThemeMode
colors: IThemeColors
toggle: () => void
isDark: boolean
}
const ThemeContext = createContext<IThemeContext>({
mode: 'light',
colors: THEME.light,
toggle: () => {},
isDark: false,
})
export function useTheme() {
return useContext(ThemeContext)
}
export default function ThemeProvider({ children }: { children: React.ReactNode }) {
const systemScheme = useColorScheme()
const [mode, setModeState] = useState<ThemeMode>('light')
const [loaded, setLoaded] = useState(false)
useEffect(() => {
AsyncStorage.getItem(STORAGE_KEY).then((stored) => {
if (stored === 'light' || stored === 'dark') {
setModeState(stored)
} else if (systemScheme === 'dark') {
setModeState('dark')
}
setLoaded(true)
})
}, [systemScheme])
useEffect(() => {
if (loaded) {
AsyncStorage.setItem(STORAGE_KEY, mode)
}
}, [mode, loaded])
const toggle = useCallback(() => {
setModeState(prev => (prev === 'light' ? 'dark' : 'light'))
}, [])
if (!loaded) {
return <>{children}</>
}
return (
<ThemeContext.Provider value={{ mode, colors: THEME[mode], toggle, isDark: mode === 'dark' }}>
{children}
</ThemeContext.Provider>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { useColorScheme } from 'react-native'
export type ThemeMode = 'light' | 'dark'
export interface IThemeColors {
primary: string
primaryLight: string
primaryGradient: string
accent: string
bg: string
card: string
text: string
textSecondary: string
border: string
navBg: string
success: string
warning: string
error: string
}
export const THEME: Record<ThemeMode, IThemeColors> = {
light: {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryGradient: ['#FF6B35', '#FF4D6D'] as unknown as string,
accent: '#7209B7',
bg: '#F5F5F7',
card: '#FFFFFF',
text: '#1D1D1F',
textSecondary: '#86868B',
border: '#E5E5EA',
navBg: 'rgba(245,245,247,0.95)',
success: '#34C759',
warning: '#FF9500',
error: '#FF3B30',
},
dark: {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryGradient: ['#FF6B35', '#FF4D6D'] as unknown as string,
accent: '#BB86FC',
bg: '#1C1C1E',
card: '#2C2C2E',
text: '#F5F5F7',
textSecondary: '#98989D',
border: '#38383A',
navBg: 'rgba(28,28,30,0.95)',
success: '#34C759',
warning: '#FF9500',
error: '#FF453A',
},
}
+7 -4
View File
@@ -1,6 +1,7 @@
import React from 'react'
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useTheme } from '../components/ThemeProvider'
import type { MainTabParamList } from './types'
import HomeStack from './HomeStack'
import FeedStack from './FeedStack'
@@ -9,15 +10,17 @@ import ProfileStack from './ProfileStack'
const Tab = createBottomTabNavigator<MainTabParamList>()
export default function MainTabs() {
const { colors, isDark } = useTheme()
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#FF6B35',
tabBarInactiveTintColor: '#86868B',
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textSecondary,
tabBarStyle: {
backgroundColor: 'rgba(245,245,247,0.95)',
borderTopColor: '#E5E5EA',
backgroundColor: colors.navBg,
borderTopColor: colors.border,
borderTopWidth: 0.5,
paddingTop: 6,
paddingBottom: 20,
+43 -36
View File
@@ -4,11 +4,14 @@ import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { api } from '../services/api'
import { useAuth } from '../components/AuthProvider'
import { useTheme } from '../components/ThemeProvider'
import type { HomeStackParamList } from '../navigation/types'
import type { IThemeColors } from '../constants/theme'
export default function HomeScreen() {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList, 'HomeMain'>>()
const { user } = useAuth()
const { colors } = useTheme()
const [items, setItems] = useState<any[]>([])
const [streaks, setStreaks] = useState<Record<string, number>>({})
const [todayStatus, setTodayStatus] = useState<Record<string, boolean>>({})
@@ -34,37 +37,39 @@ export default function HomeScreen() {
const activeItems = items.filter(i => i.status === 'active')
const styles = createStyles(colors)
return (
<ScrollView style={s.container} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadItems().then(() => setRefreshing(false)) }} />}>
<View style={s.header}>
<View style={s.avatar}><Text style={s.avatarText}>{user?.nickname?.[0] || user?.phone?.slice(-4) || '?'}</Text></View>
<ScrollView style={styles.container} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); loadItems().then(() => setRefreshing(false)) }} />}>
<View style={styles.header}>
<View style={styles.avatar}><Text style={styles.avatarText}>{user?.nickname?.[0] || user?.phone?.slice(-4) || '?'}</Text></View>
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={s.headerName}>{user?.nickname || '用户'}</Text>
<Text style={s.headerSub}> {user?.points} · {activeItems.length} </Text>
<Text style={styles.headerName}>{user?.nickname || '用户'}</Text>
<Text style={styles.headerSub}> {user?.points} · {activeItems.length} </Text>
</View>
</View>
<View style={s.sectionHeader}>
<Text style={s.sectionTitle}></Text>
<TouchableOpacity onPress={() => navigation.navigate('CreateCheckIn')}><Text style={s.createBtn}>+ </Text></TouchableOpacity>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity onPress={() => navigation.navigate('CreateCheckIn')}><Text style={styles.createBtn}>+ </Text></TouchableOpacity>
</View>
{activeItems.length === 0 ? (
<View style={s.empty}>
<View style={styles.empty}>
<Text style={{ fontSize: 40 }}>📋</Text>
<Text style={s.emptyText}></Text>
<Text style={{ fontSize: 13, color: '#86868B', marginTop: 4 }}>"创建"</Text>
<Text style={styles.emptyText}></Text>
<Text style={{ fontSize: 13, color: colors.textSecondary, marginTop: 4 }}>"创建"</Text>
</View>
) : activeItems.map((item: any) => (
<TouchableOpacity key={item.id} onPress={() => navigation.navigate('CheckInDetail', { itemId: item.id })}>
<View style={s.card}>
<View style={s.cardIcon}><Text style={{ fontSize: 22 }}>{item.icon || '📋'}</Text></View>
<View style={styles.card}>
<View style={styles.cardIcon}><Text style={{ fontSize: 22 }}>{item.icon || '📋'}</Text></View>
<View style={{ flex: 1 }}>
<Text style={s.cardName}>{item.name}</Text>
<Text style={s.cardMeta}>{item.category}</Text>
{todayStatus[item.id] && <Text style={s.doneText}> </Text>}
<Text style={styles.cardName}>{item.name}</Text>
<Text style={styles.cardMeta}>{item.category}</Text>
{todayStatus[item.id] && <Text style={styles.doneText}> </Text>}
</View>
{streaks[item.id] !== undefined && <Text style={s.streak}>🔥 {streaks[item.id]}</Text>}
{streaks[item.id] !== undefined && <Text style={styles.streak}>🔥 {streaks[item.id]}</Text>}
</View>
</TouchableOpacity>
))}
@@ -72,22 +77,24 @@ export default function HomeScreen() {
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F5F5F7' },
header: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'white', borderRadius: 12, margin: 12, padding: 14, shadowOpacity: 0.04, shadowRadius: 3, elevation: 1 },
avatar: { width: 44, height: 44, borderRadius: 22, backgroundColor: '#FF6B35', justifyContent: 'center', alignItems: 'center' },
avatarText: { fontSize: 18, color: 'white', fontWeight: '600' },
headerName: { fontSize: 16, fontWeight: '600' },
headerSub: { fontSize: 13, color: '#86868B' },
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16 },
sectionTitle: { fontSize: 20, fontWeight: '700' },
createBtn: { color: '#FF6B35', fontSize: 14, fontWeight: '600' },
card: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'white', borderRadius: 12, marginHorizontal: 12, marginBottom: 8, padding: 14, shadowOpacity: 0.04, elevation: 1 },
cardIcon: { width: 44, height: 44, borderRadius: 12, backgroundColor: '#FFF0EB', justifyContent: 'center', alignItems: 'center', marginRight: 14 },
cardName: { fontSize: 16, fontWeight: '600' },
cardMeta: { fontSize: 13, color: '#86868B', marginTop: 2 },
doneText: { fontSize: 12, color: '#34C759', fontWeight: '600', marginTop: 2 },
streak: { fontSize: 14, color: '#FF6B35', fontWeight: '600' },
empty: { alignItems: 'center', padding: 60, marginTop: 20 },
emptyText: { fontSize: 15, color: '#86868B', marginTop: 8 },
})
function createStyles(c: IThemeColors) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.bg },
header: { flexDirection: 'row', alignItems: 'center', backgroundColor: c.card, borderRadius: 12, margin: 12, padding: 14, shadowOpacity: 0.04, shadowRadius: 3, elevation: 1 },
avatar: { width: 44, height: 44, borderRadius: 22, backgroundColor: c.primary, justifyContent: 'center', alignItems: 'center' },
avatarText: { fontSize: 18, color: 'white', fontWeight: '600' },
headerName: { fontSize: 16, fontWeight: '600', color: c.text },
headerSub: { fontSize: 13, color: c.textSecondary },
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16 },
sectionTitle: { fontSize: 20, fontWeight: '700', color: c.text },
createBtn: { color: c.primary, fontSize: 14, fontWeight: '600' },
card: { flexDirection: 'row', alignItems: 'center', backgroundColor: c.card, borderRadius: 12, marginHorizontal: 12, marginBottom: 8, padding: 14, shadowOpacity: 0.04, elevation: 1 },
cardIcon: { width: 44, height: 44, borderRadius: 12, backgroundColor: c.primaryLight + '20', justifyContent: 'center', alignItems: 'center', marginRight: 14 },
cardName: { fontSize: 16, fontWeight: '600', color: c.text },
cardMeta: { fontSize: 13, color: c.textSecondary, marginTop: 2 },
doneText: { fontSize: 12, color: c.success, fontWeight: '600', marginTop: 2 },
streak: { fontSize: 14, color: c.primary, fontWeight: '600' },
empty: { alignItems: 'center', padding: 60, marginTop: 20 },
emptyText: { fontSize: 15, color: c.textSecondary, marginTop: 8 },
})
}
+28 -20
View File
@@ -1,12 +1,14 @@
import React from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, Switch } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useAuth } from '../components/AuthProvider'
import { useTheme } from '../components/ThemeProvider'
import type { ProfileStackParamList } from '../navigation/types'
export default function ProfileScreen() {
const { user, logout } = useAuth()
const { colors, isDark, toggle } = useTheme()
const navigation = useNavigation<NativeStackNavigationProp<ProfileStackParamList, 'ProfileMain'>>()
const handleLogout = () => {
@@ -17,23 +19,29 @@ export default function ProfileScreen() {
}
return (
<ScrollView style={{ flex: 1, backgroundColor: '#F5F5F7' }}>
<View style={styles.profileCard}>
<ScrollView style={{ flex: 1, backgroundColor: colors.bg }}>
<View style={[styles.profileCard, { backgroundColor: colors.card }]}>
<View style={styles.avatar}><Text style={{ fontSize: 28, color: 'white', fontWeight: '700' }}>{user?.nickname?.[0] || user?.phone?.slice(-4) || '?'}</Text></View>
<Text style={styles.name}>{user?.nickname || '未设置昵称'}</Text>
<Text style={styles.bio}>{user?.bio || '还没有个性签名'}</Text>
<Text style={[styles.name, { color: colors.text }]}>{user?.nickname || '未设置昵称'}</Text>
<Text style={[styles.bio, { color: colors.textSecondary }]}>{user?.bio || '还没有个性签名'}</Text>
</View>
<View style={styles.statsRow}>
<View style={styles.stat}><Text style={styles.statVal}>{user?.points || 0}</Text><Text style={styles.statLbl}></Text></View>
<View style={styles.stat}><Text style={styles.statVal}>--</Text><Text style={styles.statLbl}></Text></View>
<View style={styles.stat}><Text style={styles.statVal}>--</Text><Text style={styles.statLbl}></Text></View>
<View style={[styles.statsRow, { backgroundColor: colors.card }]}>
<View style={styles.stat}><Text style={[styles.statVal, { color: colors.primary }]}>{user?.points || 0}</Text><Text style={[styles.statLbl, { color: colors.textSecondary }]}></Text></View>
<View style={styles.stat}><Text style={[styles.statVal, { color: colors.primary }]}>--</Text><Text style={[styles.statLbl, { color: colors.textSecondary }]}></Text></View>
<View style={styles.stat}><Text style={[styles.statVal, { color: colors.primary }]}>--</Text><Text style={[styles.statLbl, { color: colors.textSecondary }]}></Text></View>
</View>
<View style={styles.menu}>
<TouchableOpacity style={styles.menuItem} onPress={() => navigation.navigate('Achievements')}><Text></Text><Text style={styles.arrow}></Text></TouchableOpacity>
<View style={[styles.menu, { backgroundColor: colors.card }]}>
<TouchableOpacity style={[styles.menuItem, { borderBottomColor: colors.border }]} onPress={() => navigation.navigate('Achievements')}>
<Text style={{ color: colors.text }}></Text><Text style={[styles.arrow, { color: colors.textSecondary }]}></Text>
</TouchableOpacity>
<View style={[styles.menuItem, { borderBottomColor: colors.border }]}>
<Text style={{ color: colors.text }}></Text>
<Switch value={isDark} onValueChange={toggle} trackColor={{ false: colors.border, true: colors.primary }} thumbColor="white" />
</View>
<TouchableOpacity style={[styles.menuItem, { borderBottomWidth: 0 }]} onPress={handleLogout}>
<Text style={{ color: '#FF3B30' }}>退</Text><Text style={styles.arrow}></Text>
<Text style={{ color: colors.error }}>退</Text><Text style={[styles.arrow, { color: colors.textSecondary }]}></Text>
</TouchableOpacity>
</View>
</ScrollView>
@@ -41,15 +49,15 @@ export default function ProfileScreen() {
}
const styles = StyleSheet.create({
profileCard: { backgroundColor: 'white', borderRadius: 12, margin: 12, padding: 32, alignItems: 'center', shadowOpacity: 0.04, elevation: 1 },
profileCard: { borderRadius: 12, margin: 12, padding: 32, alignItems: 'center', shadowOpacity: 0.04, elevation: 1 },
avatar: { width: 72, height: 72, borderRadius: 36, backgroundColor: '#FF6B35', justifyContent: 'center', alignItems: 'center', marginBottom: 12 },
name: { fontSize: 22, fontWeight: '700' },
bio: { fontSize: 14, color: '#86868B', marginTop: 4 },
statsRow: { flexDirection: 'row', justifyContent: 'space-around', backgroundColor: 'white', borderRadius: 12, margin: 8, padding: 16, shadowOpacity: 0.04, elevation: 1 },
bio: { fontSize: 14, marginTop: 4 },
statsRow: { flexDirection: 'row', justifyContent: 'space-around', borderRadius: 12, margin: 8, padding: 16, shadowOpacity: 0.04, elevation: 1 },
stat: { alignItems: 'center' },
statVal: { fontSize: 24, fontWeight: '700', color: '#FF6B35' },
statLbl: { fontSize: 13, color: '#86868B' },
menu: { backgroundColor: 'white', borderRadius: 12, margin: 8, overflow: 'hidden', shadowOpacity: 0.04, elevation: 1 },
menuItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 14, borderBottomWidth: 0.5, borderBottomColor: '#E5E5EA' },
arrow: { fontSize: 15, color: '#86868B' },
statVal: { fontSize: 24, fontWeight: '700' },
statLbl: { fontSize: 13 },
menu: { borderRadius: 12, margin: 8, overflow: 'hidden', shadowOpacity: 0.04, elevation: 1 },
menuItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 14, borderBottomWidth: 0.5 },
arrow: { fontSize: 15 },
})