chore: vendor client into main repo
This commit is contained in:
18
client/app/(onboarding)/_layout.tsx
Normal file
18
client/app/(onboarding)/_layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function OnboardingLayout() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitleAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="onboarding" options={{ title: t('onboarding.title') }} />
|
||||
<Stack.Screen name="push-prompt" options={{ title: t('push.title') }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
104
client/app/(onboarding)/onboarding.tsx
Normal file
104
client/app/(onboarding)/onboarding.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { setOnboardingCompleted } from '@/src/storage/appStorage';
|
||||
|
||||
type OnboardingPage = {
|
||||
title: string;
|
||||
desc: string;
|
||||
};
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 3–5 页:这里默认 4 页,后续可按产品调整为 3 或 5
|
||||
const pages = useMemo<OnboardingPage[]>(
|
||||
() => [
|
||||
{ title: t('onboarding.q1Title'), desc: t('onboarding.q1Desc') },
|
||||
{ title: t('onboarding.q2Title'), desc: t('onboarding.q2Desc') },
|
||||
{ title: t('onboarding.q3Title'), desc: t('onboarding.q3Desc') },
|
||||
{ title: t('onboarding.q4Title'), desc: t('onboarding.q4Desc') }
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const total = pages.length;
|
||||
const [step, setStep] = useState(0);
|
||||
const page = pages[Math.min(step, total - 1)];
|
||||
|
||||
async function finishAndNext() {
|
||||
await setOnboardingCompleted(true);
|
||||
router.replace('/(onboarding)/push-prompt');
|
||||
}
|
||||
|
||||
async function onNext() {
|
||||
if (step >= total - 1) {
|
||||
await finishAndNext();
|
||||
return;
|
||||
}
|
||||
setStep((s) => s + 1);
|
||||
}
|
||||
|
||||
async function onSkipPage() {
|
||||
// 每页可跳过:直接进入下一页(最后一页则结束)
|
||||
await onNext();
|
||||
}
|
||||
|
||||
async function onSkipAll() {
|
||||
// 一键跳过整个 Onboarding
|
||||
await finishAndNext();
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.progress}>
|
||||
{t('onboarding.progress', { current: step + 1, total })}
|
||||
</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>{page.title}</Text>
|
||||
<Text style={styles.desc}>{page.desc}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={[styles.btn, styles.secondary]} onPress={onSkipPage}>
|
||||
<Text style={[styles.btnText, styles.secondaryText]}>{t('onboarding.skip')}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.btn, styles.primary]} onPress={onNext}>
|
||||
<Text style={styles.btnText}>{t('onboarding.next')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable style={styles.skipAll} onPress={onSkipAll}>
|
||||
<Text style={styles.skipAllText}>{t('onboarding.skipAll')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 20, justifyContent: 'center', gap: 16 },
|
||||
progress: { textAlign: 'center', color: '#6B7280' },
|
||||
card: {
|
||||
borderRadius: 18,
|
||||
padding: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: '#E5E7EB',
|
||||
gap: 10
|
||||
},
|
||||
title: { fontSize: 22, fontWeight: '700', color: '#111827' },
|
||||
desc: { fontSize: 16, lineHeight: 22, color: '#374151' },
|
||||
actions: { flexDirection: 'row', gap: 12 },
|
||||
btn: { flex: 1, paddingVertical: 14, borderRadius: 14, alignItems: 'center' },
|
||||
primary: { backgroundColor: '#111827' },
|
||||
secondary: { backgroundColor: '#F3F4F6' },
|
||||
btnText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' },
|
||||
secondaryText: { color: '#111827' },
|
||||
skipAll: { alignItems: 'center', paddingTop: 10 },
|
||||
skipAllText: { color: '#6B7280', textDecorationLine: 'underline' }
|
||||
});
|
||||
|
||||
74
client/app/(onboarding)/push-prompt.tsx
Normal file
74
client/app/(onboarding)/push-prompt.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
|
||||
import { setPushPromptState } from '@/src/storage/appStorage';
|
||||
|
||||
export default function PushPromptScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function goHome() {
|
||||
router.replace('/(app)/home');
|
||||
}
|
||||
|
||||
async function onLater() {
|
||||
await setPushPromptState('skipped');
|
||||
await goHome();
|
||||
}
|
||||
|
||||
async function onEnableNow() {
|
||||
// 触发系统权限申请(可失败,但不阻塞进入主功能)
|
||||
setLoading(true);
|
||||
try {
|
||||
await Notifications.requestPermissionsAsync();
|
||||
await setPushPromptState('enabled');
|
||||
await goHome();
|
||||
} catch (e) {
|
||||
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
|
||||
await goHome();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>{t('push.cardTitle')}</Text>
|
||||
<Text style={styles.desc}>{t('push.cardDesc')}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={[styles.btn, styles.secondary]} onPress={onLater} disabled={loading}>
|
||||
<Text style={[styles.btnText, styles.secondaryText]}>{t('push.later')}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.btn, styles.primary]} onPress={onEnableNow} disabled={loading}>
|
||||
<Text style={styles.btnText}>{loading ? t('push.loading') : t('push.enable')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 20, justifyContent: 'center', gap: 16 },
|
||||
card: {
|
||||
borderRadius: 18,
|
||||
padding: 20,
|
||||
backgroundColor: '#111827',
|
||||
gap: 10
|
||||
},
|
||||
title: { color: 'white', fontSize: 20, fontWeight: '700' },
|
||||
desc: { color: '#E5E7EB', fontSize: 15, lineHeight: 21 },
|
||||
actions: { flexDirection: 'row', gap: 12 },
|
||||
btn: { flex: 1, paddingVertical: 14, borderRadius: 14, alignItems: 'center' },
|
||||
primary: { backgroundColor: '#16A34A' },
|
||||
secondary: { backgroundColor: '#F3F4F6' },
|
||||
btnText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' },
|
||||
secondaryText: { color: '#111827' }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user