Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22443c82b6 | ||
|
|
d37262876b | ||
|
|
0ad21da246 | ||
|
|
43e33eb991 | ||
|
|
7fcc64f6e3 | ||
|
|
9d6829eceb | ||
|
|
4838bcef4b |
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"requireFullScreen": true,
|
||||
"bundleIdentifier": "com.damer.mindfulness"
|
||||
},
|
||||
"android": {
|
||||
|
||||
@@ -2,13 +2,14 @@ import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } fr
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Dimensions,
|
||||
Text,
|
||||
Pressable,
|
||||
PanResponder,
|
||||
AppState,
|
||||
Animated as RNAnimated,
|
||||
ImageBackground,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
@@ -29,6 +30,8 @@ import {
|
||||
getUserProfile,
|
||||
setReaction,
|
||||
setThemeMode,
|
||||
clearPendingHomePushMessage,
|
||||
getPendingHomePushMessage,
|
||||
getRecoFeedCache,
|
||||
setRecoFeedCache,
|
||||
getUserProfileScoring,
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
} from '@/src/storage/appStorage';
|
||||
|
||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||
import { subscribeHomePushMessage } from '@/src/services/pushNotificationRoute';
|
||||
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
||||
|
||||
import ProfileModal from '@/components/home/ProfileModal';
|
||||
@@ -57,8 +61,6 @@ import { wrapText } from '@/src/features/textWrap';
|
||||
import { defaultMeasureWidthImpl } from '@/src/features/textWrap/measure';
|
||||
import { ensureDailyWidgetRecoUpToDate } from '@/src/modules/dailyWidgetReco';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
// 预定义风景图列表
|
||||
const NATURE_IMAGES = [
|
||||
require('@/assets/theme/nature/1.png'),
|
||||
@@ -97,7 +99,9 @@ type FeedItem = { content_id: string; text: string };
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const isEnglish = i18n.language?.startsWith('en');
|
||||
const isTablet = Platform.OS === 'ios' && Math.min(windowWidth, windowHeight) >= 768;
|
||||
const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [index, setIndex] = useState(0);
|
||||
@@ -109,6 +113,7 @@ export default function HomeScreen() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [likeFilled, setLikeFilled] = useState(false);
|
||||
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
||||
const [pendingPushItem, setPendingPushItem] = useState<FeedItem | null>(null);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [cardWidth, setCardWidth] = useState<number | null>(null);
|
||||
const [wrappedText, setWrappedText] = useState<string>('');
|
||||
@@ -119,6 +124,24 @@ export default function HomeScreen() {
|
||||
const likedIdsRef = useRef<Set<string>>(new Set());
|
||||
const likeInFlightRef = useRef(false);
|
||||
|
||||
const applyPendingPushItem = useCallback(async (message: { notification_id: string; content_id?: number; text: string }) => {
|
||||
const nextItem: FeedItem = {
|
||||
content_id: message.content_id != null ? String(message.content_id) : `push:${message.notification_id}`,
|
||||
text: message.text,
|
||||
};
|
||||
setPendingPushItem(nextItem);
|
||||
indexRef.current = 0;
|
||||
setIndex(0);
|
||||
setLikeFilled(likedIdsRef.current.has(String(nextItem.content_id)));
|
||||
await clearPendingHomePushMessage();
|
||||
}, []);
|
||||
|
||||
const consumePendingPushItem = useCallback(async () => {
|
||||
const pendingMessage = await getPendingHomePushMessage();
|
||||
if (!pendingMessage?.text) return;
|
||||
await applyPendingPushItem(pendingMessage);
|
||||
}, [applyPendingPushItem]);
|
||||
|
||||
useEffect(() => {
|
||||
busyRef.current = busy;
|
||||
}, [busy]);
|
||||
@@ -187,14 +210,25 @@ export default function HomeScreen() {
|
||||
|
||||
// 统一文案对象结构
|
||||
const currentFeed = useMemo(() => {
|
||||
if (feedItems.length > 0) {
|
||||
return feedItems;
|
||||
const baseFeed =
|
||||
feedItems.length > 0
|
||||
? feedItems
|
||||
: MOCK_CONTENT.map(item => ({
|
||||
content_id: item.id,
|
||||
text: t(item.textKey)
|
||||
}));
|
||||
|
||||
if (!pendingPushItem) {
|
||||
return baseFeed;
|
||||
}
|
||||
return MOCK_CONTENT.map(item => ({
|
||||
content_id: item.id,
|
||||
text: t(item.textKey)
|
||||
}));
|
||||
}, [feedItems, t]);
|
||||
|
||||
return [
|
||||
pendingPushItem,
|
||||
...baseFeed.filter((entry) => (
|
||||
String(entry.content_id) !== String(pendingPushItem.content_id) && entry.text !== pendingPushItem.text
|
||||
)),
|
||||
];
|
||||
}, [feedItems, pendingPushItem, t]);
|
||||
useEffect(() => {
|
||||
currentFeedRef.current = currentFeed;
|
||||
}, [currentFeed]);
|
||||
@@ -248,8 +282,8 @@ export default function HomeScreen() {
|
||||
});
|
||||
|
||||
const fontSpec = {
|
||||
fontSize: 22,
|
||||
fontWeight: lang === 'EN' ? '600' : '700',
|
||||
fontSize: 24,
|
||||
fontWeight: lang === 'EN' ? '700' : '800',
|
||||
fontFamily: String(fontFamily ?? 'System'),
|
||||
};
|
||||
|
||||
@@ -381,13 +415,20 @@ export default function HomeScreen() {
|
||||
useCallback(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const mode = await getThemeMode();
|
||||
const profile = await getUserProfile();
|
||||
const cache = await getRecoFeedCache();
|
||||
const [mode, profile, cache, pendingMessage] = await Promise.all([
|
||||
getThemeMode(),
|
||||
getUserProfile(),
|
||||
getRecoFeedCache(),
|
||||
getPendingHomePushMessage(),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
setThemeModeState(mode);
|
||||
setProfileName(profile.name);
|
||||
if (pendingMessage?.text) {
|
||||
await applyPendingPushItem(pendingMessage);
|
||||
if (cancelled) return;
|
||||
}
|
||||
|
||||
// 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算)
|
||||
if (mode === 'suixin') {
|
||||
@@ -415,9 +456,24 @@ export default function HomeScreen() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchNewFeed, recoLang, ensureSuixinReady])
|
||||
}, [applyPendingPushItem, fetchNewFeed, recoLang, ensureSuixinReady])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeHomePushMessage((message) => {
|
||||
void applyPendingPushItem(message);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [applyPendingPushItem]);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state !== 'active') return;
|
||||
void consumePendingPushItem();
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [consumePendingPushItem]);
|
||||
|
||||
const backgroundColor = useMemo(() => {
|
||||
if (themeMode === 'suixin') {
|
||||
return suixinBgColor;
|
||||
@@ -659,6 +715,10 @@ export default function HomeScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
const actionsBottom = isTablet
|
||||
? Math.max(insets.bottom + 36, Math.min(windowHeight * 0.12, 140))
|
||||
: windowHeight * 0.16;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
|
||||
{themeMode === 'scenery' && (
|
||||
@@ -670,7 +730,15 @@ export default function HomeScreen() {
|
||||
)}
|
||||
|
||||
{/* 自绘顶部按钮:不使用系统 Header,彻底避免 iOS 导航栏的毛玻璃/液玻璃材质 */}
|
||||
<View style={[styles.topRight, { top: insets.top + 8 }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.topRight,
|
||||
{
|
||||
top: insets.top + (isTablet ? 16 : 8),
|
||||
right: isTablet ? 26 : 20,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<CircleIconButton
|
||||
onPress={() => setThemeOpen(true)}
|
||||
accessibilityLabel={t('home.theme')}
|
||||
@@ -685,19 +753,21 @@ export default function HomeScreen() {
|
||||
</CircleIconButton>
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}
|
||||
onLayout={(e) => {
|
||||
const w = e.nativeEvent.layout.width;
|
||||
if (Number.isFinite(w) && w > 0) setCardWidth(w);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
||||
{wrappedText || item.text}
|
||||
</Text>
|
||||
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
||||
<View
|
||||
style={[styles.textMeasureBox, isTablet && styles.textMeasureBoxTablet]}
|
||||
onLayout={(e) => {
|
||||
const w = e.nativeEvent.layout.width;
|
||||
if (Number.isFinite(w) && w > 0) setCardWidth(w);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
||||
{wrappedText || item.text}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<View style={[styles.actions, { bottom: actionsBottom }]}>
|
||||
<Animated.View style={[styles.reactionButton, likeAnimatedStyle]}>
|
||||
<Pressable
|
||||
onPress={onPressLike}
|
||||
@@ -762,7 +832,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
topRight: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
zIndex: 30,
|
||||
@@ -787,16 +856,23 @@ const styles = StyleSheet.create({
|
||||
zIndex: 5, // 降低层级,防止遮挡底部按钮
|
||||
},
|
||||
text: {
|
||||
fontSize: 22,
|
||||
lineHeight: 32,
|
||||
fontSize: 24,
|
||||
lineHeight: 34,
|
||||
color: '#5E2A28',
|
||||
fontWeight: '700',
|
||||
fontWeight: '800',
|
||||
textAlign: 'center',
|
||||
},
|
||||
textMeasureBox: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
},
|
||||
textMeasureBoxTablet: {
|
||||
maxWidth: 760,
|
||||
},
|
||||
textEnglish: {
|
||||
fontFamily: 'STIXTwoText',
|
||||
// 英文字体观感更细一点,避免过粗
|
||||
fontWeight: '600',
|
||||
// 英文字体保持较粗但避免过度发黑
|
||||
fontWeight: '700',
|
||||
},
|
||||
sceneryCard: {
|
||||
// 风景模式下稍微收窄文案宽度,增加呼吸感
|
||||
@@ -810,7 +886,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
actions: {
|
||||
position: 'absolute',
|
||||
bottom: SCREEN_HEIGHT * 0.16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import Constants from 'expo-constants';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { Platform, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { clampContentWidth, isIPadLike } from '@/src/utils/device';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isTablet = isIPadLike(width, height);
|
||||
const contentWidth = isTablet ? clampContentWidth(width, 720, 24) : undefined;
|
||||
|
||||
const version =
|
||||
Constants.expoConfig?.version ??
|
||||
@@ -12,6 +16,7 @@ export default function SettingsScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.contentWrap, contentWidth ? { width: contentWidth } : null]}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>{t('settings.version')}</Text>
|
||||
<Text style={styles.value}>{version}</Text>
|
||||
@@ -21,12 +26,18 @@ export default function SettingsScreen() {
|
||||
<Text style={styles.cardTitle}>{t('settings.widgetTitle')}</Text>
|
||||
<Text style={styles.cardText}>{t('settings.widgetDesc')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 16, gap: 16 },
|
||||
container: { flex: 1, width: '100%', alignSelf: 'stretch', padding: 16 },
|
||||
contentWrap: {
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
section: {
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
@@ -38,7 +49,12 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
label: { color: '#374151', fontSize: 16 },
|
||||
value: { color: '#111827', fontSize: 16, fontWeight: '600' },
|
||||
value: {
|
||||
color: '#111827',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : undefined,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Platform, Alert, Image, useWindowDimensions } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
@@ -14,8 +14,6 @@ import { isTraditionalChineseLocaleTag } from '@/src/i18n/locale';
|
||||
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
|
||||
import WelcomeBtn from '../../assets/images/index/welcome_btn.svg';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
// 繁中開屏 consent 文案:寫死在元件內,避免 Metro/iOS bundle 快取導致永遠顯示舊文案。
|
||||
// 若需修改,請改這裡並同步 client/src/i18n/locales/zh-TW.json 的 consent 區塊。
|
||||
const ZH_TW_CONSENT = {
|
||||
@@ -27,7 +25,9 @@ const ZH_TW_CONSENT = {
|
||||
export default function SplashScreen() {
|
||||
const router = useRouter();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const [showConsent, setShowConsent] = useState(false);
|
||||
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
|
||||
|
||||
// 繁中時強制使用上方常數(含 zh-TW / zh-Hant / zh-Hant-TW),其餘用 i18n
|
||||
const isZhTW = isTraditionalChineseLocaleTag(i18n.language || '');
|
||||
@@ -127,52 +127,67 @@ export default function SplashScreen() {
|
||||
void refreshLegalLinks();
|
||||
}, []);
|
||||
|
||||
const bgDecorationTop = 363;
|
||||
const bgDecorationHeight = height * 0.6;
|
||||
const bgDecorationHeight = isTablet ? Math.round(height * 0.55) : height * 0.6;
|
||||
const bgDecorationTop = height - bgDecorationHeight;
|
||||
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
|
||||
const contentWidth = isTablet ? Math.min(640, Math.floor(width * 0.76)) : width;
|
||||
const topImageWidth = isTablet ? Math.min(430, Math.floor(width * 0.48)) : 308;
|
||||
const topImageHeight = isTablet ? Math.min(460, Math.floor(height * 0.45)) : 354;
|
||||
const topImageMarginTop = isTablet ? 148 : 60;
|
||||
const bgWidth = width + 10;
|
||||
const bgLeft = -3;
|
||||
const buttonSize = isTablet ? { width: 108, height: 70 } : { width: 87, height: 57 };
|
||||
const bottomOffset = isTablet ? 28 : 60;
|
||||
const buttonBottomGap = isTablet ? 28 : 40;
|
||||
const noticeStyle = isTablet
|
||||
? { fontSize: 14, lineHeight: 20, paddingHorizontal: 32, maxWidth: contentWidth }
|
||||
: null;
|
||||
const noticeLinkStyle = isTablet ? { fontSize: 14 } : null;
|
||||
const titleStyle = isTablet ? { fontSize: 50, lineHeight: 60 } : null;
|
||||
const subtitleStyle = isTablet ? { marginTop: 10, fontSize: 18, lineHeight: 24 } : null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 中间的背景装饰 SVG (现在放在上面,作为上层) */}
|
||||
<View style={[styles.bgDecorationContainer, { top: bgDecorationTop }]}>
|
||||
<FlowersBg width={width + 10} height={bgDecorationHeight} />
|
||||
<View style={[styles.bgDecorationContainer, { bottom: 0, left: bgLeft }]}>
|
||||
<FlowersBg width={bgWidth} height={bgDecorationHeight} preserveAspectRatio="none" />
|
||||
</View>
|
||||
|
||||
{/* 顶部的花图片 (现在放在下面,作为下层) */}
|
||||
<View style={styles.topImageContainer}>
|
||||
<View style={[styles.topImageContainer, { marginTop: topImageMarginTop }]}>
|
||||
<Image
|
||||
source={require('../../assets/images/index/index_flowers.png')}
|
||||
style={styles.topImage}
|
||||
style={[styles.topImage, { width: topImageWidth, height: topImageHeight }]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 文案内容:主標題兩行 + 可選二級標題(字號更小、顏色更淺);繁中為元件內常數,其餘用 i18n */}
|
||||
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
|
||||
<Text style={styles.titleText}>
|
||||
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop, width: contentWidth }]}>
|
||||
<Text style={[styles.titleText, titleStyle]}>
|
||||
{title}
|
||||
{'\n'}
|
||||
{subtitle}
|
||||
</Text>
|
||||
{subtitleSecondary ? (
|
||||
<Text style={styles.consentSubtitleSecondary}>{subtitleSecondary}</Text>
|
||||
<Text style={[styles.consentSubtitleSecondary, subtitleStyle]}>{subtitleSecondary}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
|
||||
<SafeAreaView style={[styles.bottomContainer, { bottom: bottomOffset, width: contentWidth }]} edges={['bottom']}>
|
||||
{showConsent && (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={handleAgree}
|
||||
activeOpacity={0.8}
|
||||
style={styles.buttonWrapper}
|
||||
style={[styles.buttonWrapper, { marginBottom: buttonBottomGap }]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('consent.agree')}
|
||||
>
|
||||
<WelcomeBtn width={87} height={57} />
|
||||
<WelcomeBtn width={buttonSize.width} height={buttonSize.height} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={styles.noticeText}>
|
||||
<Text style={[styles.noticeText, noticeStyle]}>
|
||||
<Trans
|
||||
i18nKey="consent.noticeRich"
|
||||
values={{
|
||||
@@ -184,14 +199,14 @@ export default function SplashScreen() {
|
||||
components={{
|
||||
privacy: (
|
||||
<Text
|
||||
style={[styles.noticeLinkText, !links.privacy && styles.noticeLinkTextDisabled]}
|
||||
style={[styles.noticeLinkText, noticeLinkStyle, !links.privacy && styles.noticeLinkTextDisabled]}
|
||||
onPress={() => void handleOpenLegal('privacy')}
|
||||
suppressHighlighting
|
||||
/>
|
||||
),
|
||||
terms: (
|
||||
<Text
|
||||
style={[styles.noticeLinkText, !links.terms && styles.noticeLinkTextDisabled]}
|
||||
style={[styles.noticeLinkText, noticeLinkStyle, !links.terms && styles.noticeLinkTextDisabled]}
|
||||
onPress={() => void handleOpenLegal('terms')}
|
||||
suppressHighlighting
|
||||
/>
|
||||
@@ -209,11 +224,13 @@ export default function SplashScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignSelf: 'stretch',
|
||||
backgroundColor: '#F5D3B5', // 匹配 Figma 背景色
|
||||
alignItems: 'center',
|
||||
},
|
||||
topImageContainer: {
|
||||
marginTop: 60,
|
||||
zIndex: 1, // 降低层级
|
||||
},
|
||||
topImage: {
|
||||
@@ -222,7 +239,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
bgDecorationContainer: {
|
||||
position: 'absolute',
|
||||
left: -3,
|
||||
zIndex: 2, // 提高层级,使其覆盖在图片之上
|
||||
},
|
||||
contentContainer: {
|
||||
@@ -247,8 +263,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
bottomContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
zIndex: 4,
|
||||
},
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { StyleSheet, useWindowDimensions } from 'react-native';
|
||||
|
||||
import { Text, View } from '@/components/Themed';
|
||||
import { clampContentWidth, isIPadLike } from '@/src/utils/device';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isTablet = isIPadLike(width, height);
|
||||
const contentWidth = isTablet ? clampContentWidth(width, 680, 24) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||
<View style={[styles.contentWrap, contentWidth ? { width: contentWidth } : null]}>
|
||||
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||
|
||||
<Link href="/" style={styles.link}>
|
||||
<Text style={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
<Link href="/" style={styles.link}>
|
||||
<Text style={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
@@ -24,6 +31,12 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
width: '100%',
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
contentWrap: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack } from 'expo-router';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -11,7 +11,8 @@ import { Animated, AppState, Image, StyleSheet, View } from 'react-native';
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { initI18n } from '@/src/i18n';
|
||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
|
||||
import { getOrCreateClientUserId } from '@/src/storage/appStorage';
|
||||
import { getConsentAccepted, getOnboardingCompleted, getOrCreateClientUserId } from '@/src/storage/appStorage';
|
||||
import { persistHomePushMessageFromResponse } from '@/src/services/pushNotificationRoute';
|
||||
import { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi';
|
||||
|
||||
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
|
||||
@@ -149,6 +150,7 @@ export default function RootLayout() {
|
||||
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐”
|
||||
@@ -165,6 +167,44 @@ function RootLayoutNav() {
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
|
||||
const handleNotificationResponse = useCallback(
|
||||
async (response: Notifications.NotificationResponse) => {
|
||||
const message = await persistHomePushMessageFromResponse(response);
|
||||
if (!message) return;
|
||||
|
||||
const [consentAccepted, onboardingCompleted] = await Promise.all([
|
||||
getConsentAccepted(),
|
||||
getOnboardingCompleted(),
|
||||
]);
|
||||
if (consentAccepted && onboardingCompleted) {
|
||||
router.replace('/(app)/home');
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
Notifications.getLastNotificationResponseAsync()
|
||||
.then((response) => {
|
||||
if (cancelled || !response) return;
|
||||
return handleNotificationResponse(response);
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore:通知冷启动读取失败不阻塞主流程
|
||||
});
|
||||
|
||||
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
|
||||
void handleNotificationResponse(response);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
sub.remove();
|
||||
};
|
||||
}, [handleNotificationResponse]);
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
import { ActivityIndicator, StyleSheet, View, useWindowDimensions } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage';
|
||||
import { clampContentWidth, isIPadLike } from '@/src/utils/device';
|
||||
|
||||
/**
|
||||
* 启动分发:根据 consent 和 onboarding 状态跳转
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isTablet = isIPadLike(width, height);
|
||||
const loaderWidth = isTablet ? clampContentWidth(width, 680, 24) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -41,11 +45,24 @@ export default function Index() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator />
|
||||
<View style={[styles.loaderWrap, loaderWidth ? { width: loaderWidth } : null]}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
container: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignSelf: 'stretch',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loaderWrap: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import { Platform, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
|
||||
import EditScreenInfo from '@/components/EditScreenInfo';
|
||||
import { Text, View } from '@/components/Themed';
|
||||
import { clampContentWidth, isIPadLike } from '@/src/utils/device';
|
||||
|
||||
export default function ModalScreen() {
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isTablet = isIPadLike(width, height);
|
||||
const contentWidth = isTablet ? clampContentWidth(width, 700, 24) : undefined;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Modal</Text>
|
||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||
<EditScreenInfo path="app/modal.tsx" />
|
||||
<View style={[styles.contentWrap, contentWidth ? { width: contentWidth } : null]}>
|
||||
<Text style={styles.title}>Modal</Text>
|
||||
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
|
||||
<EditScreenInfo path="app/modal.tsx" />
|
||||
</View>
|
||||
|
||||
{/* Use a light status bar on iOS to account for the black space above the modal */}
|
||||
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
|
||||
@@ -22,6 +29,12 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
contentWrap: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, Dimensions } from 'react-native';
|
||||
import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, useWindowDimensions } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Switch } from 'react-native';
|
||||
@@ -42,8 +42,7 @@ import * as Notifications from 'expo-notifications';
|
||||
import { changeLanguage } from '@/src/i18n';
|
||||
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||||
import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
import { isIPadLike } from '@/src/utils/device';
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
@@ -79,6 +78,12 @@ const NATURE_IMAGES = [
|
||||
|
||||
export default function ProfileModal({ visible, name: propName, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isTablet = isIPadLike(width, height);
|
||||
const contentWidth = isTablet ? width - 40 : width - 40;
|
||||
const thumbWidth = isTablet ? Math.min(420, contentWidth - 120) : Math.min(width * 0.6, 300);
|
||||
const widgetImageWidth = isTablet ? Math.min(520, contentWidth - 24) : width * 0.9;
|
||||
const howToSlideWidth = isTablet ? Math.min(640, contentWidth) : width - 32;
|
||||
|
||||
const [page, setPage] = useState<Page>('root');
|
||||
const [navDirection, setNavDirection] = useState<NavDirection>('forward');
|
||||
@@ -192,6 +197,7 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
{page === 'root' ? (
|
||||
<RootPage
|
||||
name={currentName}
|
||||
contentWidth={contentWidth}
|
||||
onOpenFavorites={() => go('favorites', 'forward')}
|
||||
onOpenWidget={() => go('widget', 'forward')}
|
||||
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
|
||||
@@ -200,15 +206,15 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
onOpenTerms={() => openLink(legalLinks.terms)}
|
||||
/>
|
||||
) : page === 'favorites' ? (
|
||||
<FavoritesPage visible={visible} page={page} />
|
||||
<FavoritesPage visible={visible} page={page} thumbWidth={thumbWidth} contentWidth={contentWidth} />
|
||||
) : page === 'dailyReminder' ? (
|
||||
<DailyReminderPage visible={visible} onDone={() => go('root', 'back')} />
|
||||
<DailyReminderPage visible={visible} onDone={() => go('root', 'back')} contentWidth={contentWidth} />
|
||||
) : page === 'language' ? (
|
||||
<LanguagePage />
|
||||
<LanguagePage contentWidth={contentWidth} />
|
||||
) : page === 'widgetHowTo' ? (
|
||||
<WidgetHowToPage />
|
||||
<WidgetHowToPage howToSlideWidth={howToSlideWidth} widgetImageWidth={widgetImageWidth} />
|
||||
) : (
|
||||
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
|
||||
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} widgetImageWidth={widgetImageWidth} />
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
@@ -222,6 +228,7 @@ function toastTodo(t: (key: string) => string) {
|
||||
|
||||
function RootPage({
|
||||
name,
|
||||
contentWidth,
|
||||
onOpenFavorites,
|
||||
onOpenWidget,
|
||||
onOpenDailyReminder,
|
||||
@@ -230,6 +237,7 @@ function RootPage({
|
||||
onOpenTerms,
|
||||
}: {
|
||||
name?: string;
|
||||
contentWidth: number;
|
||||
onOpenFavorites: () => void;
|
||||
onOpenWidget: () => void;
|
||||
onOpenDailyReminder: () => void;
|
||||
@@ -239,7 +247,7 @@ function RootPage({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<View style={[styles.sectionWrap, { width: contentWidth }]}>
|
||||
<View style={styles.header}>
|
||||
<AvatarIcon width={234} height={183} />
|
||||
<Text style={styles.name}>{name || 'Hali'}</Text>
|
||||
@@ -276,11 +284,23 @@ function RootPage({
|
||||
onPress={onOpenLanguage}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
|
||||
<Text style={styles.versionText}>V1.0.0</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||||
function FavoritesPage({
|
||||
visible,
|
||||
page,
|
||||
thumbWidth,
|
||||
contentWidth,
|
||||
}: {
|
||||
visible: boolean;
|
||||
page: Page;
|
||||
thumbWidth: number;
|
||||
contentWidth: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [favorites, setFavorites] = useState<(FavoriteItem & { text: string })[]>([]);
|
||||
|
||||
@@ -317,7 +337,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.favContainer}>
|
||||
<View style={[styles.favContainer, { width: contentWidth, alignSelf: 'center' }]}>
|
||||
{favorites.length === 0 ? (
|
||||
<Text style={styles.favEmpty}>{t('favorites.empty')}</Text>
|
||||
) : (
|
||||
@@ -339,6 +359,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||||
<View style={styles.favRight}>
|
||||
<View style={[
|
||||
styles.favThumb,
|
||||
{ width: thumbWidth },
|
||||
item.themeMode === 'scenery' ? {} : { backgroundColor: item.background }
|
||||
]}>
|
||||
{item.themeMode === 'scenery' ? (
|
||||
@@ -346,7 +367,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||||
<Image
|
||||
source={NATURE_IMAGES[parseInt(item.background)]}
|
||||
style={{
|
||||
width: width * 0.6,
|
||||
width: thumbWidth,
|
||||
height: 800, // 假设原图较高,设置一个较大的高度
|
||||
position: 'absolute',
|
||||
bottom: 0, // 关键:将图片底部对齐容器底部
|
||||
@@ -378,7 +399,15 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () => void }) {
|
||||
function DailyReminderPage({
|
||||
visible,
|
||||
onDone,
|
||||
contentWidth,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onDone: () => void;
|
||||
contentWidth: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [timesPerDay, setTimesPerDay] = useState(3);
|
||||
@@ -525,7 +554,7 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.remindRow}>
|
||||
<View style={[styles.remindRow, { width: contentWidth }]}>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={styles.rowIcon}>
|
||||
<RemindIcon width={18} height={18} />
|
||||
@@ -556,7 +585,7 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
||||
function WidgetPage({ onOpenHowTo, widgetImageWidth }: { onOpenHowTo: () => void; widgetImageWidth: number }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const currentLang = i18n.language;
|
||||
// 需求:个人主页弹窗「小工具」页暂时隐藏锁屏小工具说明/入口
|
||||
@@ -580,13 +609,13 @@ function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
||||
<View style={styles.widgetScroll}>
|
||||
{showLockScreenWidget ? (
|
||||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" />
|
||||
<Image source={widget1} style={[styles.widgetImg1, { width: widgetImageWidth, height: widgetImageWidth * (156 / 311) }]} resizeMode="contain" />
|
||||
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||
<Image source={widget2} style={styles.widgetImg2} resizeMode="contain" />
|
||||
<Image source={widget2} style={[styles.widgetImg2, { width: widgetImageWidth, height: widgetImageWidth * (175 / 311) }]} resizeMode="contain" />
|
||||
<Text style={styles.widgetLabel}>{t('widget.homeScreen')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -594,7 +623,7 @@ function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetHowToPage() {
|
||||
function WidgetHowToPage({ howToSlideWidth, widgetImageWidth }: { howToSlideWidth: number; widgetImageWidth: number }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const currentLang = i18n.language;
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
@@ -631,7 +660,7 @@ function WidgetHowToPage() {
|
||||
|
||||
const onScroll = (event: any) => {
|
||||
const x = event.nativeEvent.contentOffset.x;
|
||||
const index = Math.round(x / (width - 32));
|
||||
const index = Math.round(x / howToSlideWidth);
|
||||
if (index !== activeIndex) {
|
||||
setActiveIndex(index);
|
||||
}
|
||||
@@ -655,14 +684,14 @@ function WidgetHowToPage() {
|
||||
onScrollBeginDrag={onScrollBeginDrag}
|
||||
scrollEventThrottle={16}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.howToSlide}>
|
||||
<Image source={item.src} style={styles.howToImg} resizeMode="contain" />
|
||||
<View style={[styles.howToSlide, { width: howToSlideWidth }]}>
|
||||
<Image source={item.src} style={[styles.howToImg, { width: widgetImageWidth, height: widgetImageWidth * (234 / 326) }]} resizeMode="contain" />
|
||||
<Text style={styles.howToDesc}>{item.desc}</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
<View style={styles.pagination}>
|
||||
<View style={[styles.pagination, { top: widgetImageWidth * (234 / 326) + 35 }]}>
|
||||
{images.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
@@ -677,7 +706,7 @@ function WidgetHowToPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function LanguagePage() {
|
||||
function LanguagePage({ contentWidth }: { contentWidth: number }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const currentLang = i18n.language;
|
||||
|
||||
@@ -687,8 +716,8 @@ function LanguagePage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.langPage}>
|
||||
<View style={styles.langList}>
|
||||
<View style={[styles.langPage, { width: contentWidth, alignSelf: 'center' }]}>
|
||||
<View style={[styles.langList, { width: contentWidth }]}>
|
||||
{languages.map((lang, index) => (
|
||||
<Pressable
|
||||
key={lang.id}
|
||||
@@ -750,6 +779,9 @@ const styles = StyleSheet.create({
|
||||
pageWrap: {
|
||||
// 给页面切换动画一个稳定的容器,避免布局抖动
|
||||
},
|
||||
sectionWrap: {
|
||||
alignSelf: 'center',
|
||||
},
|
||||
backRow: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingVertical: 4,
|
||||
@@ -806,6 +838,13 @@ const styles = StyleSheet.create({
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
},
|
||||
versionText: {
|
||||
marginTop: 12,
|
||||
textAlign: 'center',
|
||||
color: 'rgba(94,42,40,0.45)',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
item: {
|
||||
height: 52,
|
||||
paddingHorizontal: 18,
|
||||
@@ -848,7 +887,7 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 100,
|
||||
},
|
||||
favList: {
|
||||
paddingHorizontal: 20,
|
||||
paddingHorizontal: 8,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
favCard: {
|
||||
@@ -872,7 +911,6 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#FFF4EA',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
width: width * 0.6,
|
||||
height: 161,
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
@@ -945,7 +983,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 18,
|
||||
width: width - 40, // 屏幕宽度减去左右各 20pt
|
||||
alignSelf: 'center',
|
||||
},
|
||||
rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||
@@ -1004,12 +1041,12 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
},
|
||||
widgetImg1: {
|
||||
width: width * 0.9,
|
||||
height: (width * 0.9) * (156 / 311),
|
||||
width: 320,
|
||||
height: 160,
|
||||
},
|
||||
widgetImg2: {
|
||||
width: width * 0.9,
|
||||
height: (width * 0.9) * (175 / 311),
|
||||
width: 320,
|
||||
height: 176,
|
||||
},
|
||||
widgetLabel: {
|
||||
marginTop: 12,
|
||||
@@ -1023,12 +1060,12 @@ const styles = StyleSheet.create({
|
||||
paddingTop: 20,
|
||||
},
|
||||
howToSlide: {
|
||||
width: width - 32, // 减去 SheetModal 的 paddingHorizontal: 16 * 2
|
||||
width: 320,
|
||||
alignItems: 'center',
|
||||
},
|
||||
howToImg: {
|
||||
width: width * 0.9,
|
||||
height: (width * 0.9) * (234 / 326),
|
||||
width: 320,
|
||||
height: 230,
|
||||
marginBottom: 40,
|
||||
},
|
||||
howToDesc: {
|
||||
@@ -1042,7 +1079,7 @@ const styles = StyleSheet.create({
|
||||
pagination: {
|
||||
flexDirection: 'row',
|
||||
position: 'absolute',
|
||||
top: (width * 0.9) * (234 / 326) + 35, // 根据新的图片高度动态计算
|
||||
top: 265,
|
||||
gap: 8,
|
||||
},
|
||||
dot: {
|
||||
@@ -1064,7 +1101,6 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
width: width - 40, // 屏幕宽度减去左右各 20pt
|
||||
alignSelf: 'center',
|
||||
},
|
||||
langItem: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Image, Pressable, StyleSheet, Text, View, Platform, useWindowDimensions } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SheetModal from '@/components/ui/SheetModal';
|
||||
@@ -15,8 +15,11 @@ type Props = {
|
||||
|
||||
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
|
||||
|
||||
return (
|
||||
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={360}>
|
||||
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={isTablet ? 560 : 360}>
|
||||
<View style={styles.row}>
|
||||
<ThemeCard
|
||||
title={t('theme.scenery')}
|
||||
@@ -100,9 +103,9 @@ const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap',
|
||||
gap: 12,
|
||||
paddingHorizontal: 4,
|
||||
paddingBottom: 50,
|
||||
gap: 8,
|
||||
paddingHorizontal: 0,
|
||||
paddingBottom: 22,
|
||||
paddingTop: 20,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable } from 'react-native';
|
||||
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable, useWindowDimensions } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
@@ -16,10 +16,13 @@ interface NameInputStepProps {
|
||||
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const blinkAnim = useRef(new Animated.Value(1)).current;
|
||||
const hasInput = value.trim().length > 0;
|
||||
const inputCardWidth = isTablet ? Math.min(520, Math.floor(width * 0.72)) : 335;
|
||||
|
||||
useEffect(() => {
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
@@ -62,7 +65,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
||||
|
||||
return (
|
||||
<Pressable style={styles.container} onPress={Keyboard.dismiss} accessible={false}>
|
||||
<View style={styles.inputCard}>
|
||||
<View style={[styles.inputCard, { width: inputCardWidth }]}>
|
||||
<View style={styles.inputWrapper}>
|
||||
{/* 显示层:文案 + 跟随的光标 */}
|
||||
<View style={styles.displayLayer}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform, Animated, Easing } from 'react-native';
|
||||
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform, Animated, Easing, useWindowDimensions } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
|
||||
|
||||
@@ -29,6 +29,9 @@ export function OnboardingLayout({
|
||||
userName = '',
|
||||
}: OnboardingLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
|
||||
const contentMaxWidth = isTablet ? 620 : undefined;
|
||||
const showGreeting = currentStep === 1 && userName.trim().length > 0;
|
||||
const displayName = userName.trim();
|
||||
const prevStepRef = useRef(currentStep);
|
||||
@@ -72,7 +75,7 @@ export function OnboardingLayout({
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
{/* Header: Back & Skip */}
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.header, contentMaxWidth ? { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } : null]}>
|
||||
<View style={styles.headerLeft}>
|
||||
{showBackButton && onBack && (
|
||||
<TouchableOpacity onPress={onBack} style={styles.iconButton}>
|
||||
@@ -94,7 +97,7 @@ export function OnboardingLayout({
|
||||
</View>
|
||||
|
||||
{/* Title & Progress Row(名字步骤后第一步且名字非空时显示招呼语 + 问题) */}
|
||||
<View style={styles.titleRow}>
|
||||
<View style={[styles.titleRow, contentMaxWidth ? { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } : null]}>
|
||||
<View style={styles.titleBlock}>
|
||||
{showGreeting && (
|
||||
<Text style={styles.greetingText}>{t('onboardingSurvey.greeting', { name: displayName })}</Text>
|
||||
@@ -108,6 +111,7 @@ export function OnboardingLayout({
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.content,
|
||||
contentMaxWidth ? { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } : null,
|
||||
{
|
||||
opacity,
|
||||
transform: [{ translateX }],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Platform, ActivityIndicator } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Platform, ActivityIndicator, useWindowDimensions } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
@@ -20,6 +20,9 @@ interface ReminderStepProps {
|
||||
export function ReminderStep({ value, onChange, onFinish, loading = false }: ReminderStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
|
||||
const contentMaxWidth = isTablet ? 560 : undefined;
|
||||
|
||||
const handleReduce = () => {
|
||||
// 本页最小为 1;不接收提醒请使用右上角 Skip
|
||||
@@ -32,7 +35,7 @@ export function ReminderStep({ value, onChange, onFinish, loading = false }: Rem
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.counterContainer}>
|
||||
<View style={[styles.counterContainer, contentMaxWidth ? { maxWidth: contentMaxWidth } : null]}>
|
||||
<TouchableOpacity onPress={handleReduce} disabled={loading} activeOpacity={0.7}>
|
||||
<ReduceIcon width={47} height={47} />
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, ScrollView, Text } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity, ScrollView, Text, Platform, useWindowDimensions } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
|
||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||
@@ -19,9 +19,12 @@ interface SelectionStepProps {
|
||||
}
|
||||
|
||||
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
|
||||
const maxOptionWidth = isTablet ? 560 : undefined;
|
||||
const hasSelection = selectedIds.length > 0;
|
||||
const insets = useSafeAreaInsets();
|
||||
const footerBottom = insets.bottom + 16;
|
||||
const footerBottom = insets.bottom + (isTablet ? 38 : 28);
|
||||
const footerButtonHeight = 57;
|
||||
// 底部留白加大,避免最后一项与按钮边框视觉重叠
|
||||
const footerPaddingBottom = footerBottom + footerButtonHeight + 40;
|
||||
@@ -31,14 +34,24 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]}
|
||||
contentContainerStyle={[
|
||||
styles.optionsList,
|
||||
{
|
||||
paddingBottom: footerPaddingBottom,
|
||||
alignItems: 'center',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedIds.includes(option.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.id}
|
||||
style={[styles.optionCard, isSelected && styles.optionCardSelected]}
|
||||
style={[
|
||||
styles.optionCard,
|
||||
maxOptionWidth ? { maxWidth: maxOptionWidth } : null,
|
||||
isSelected && styles.optionCardSelected,
|
||||
]}
|
||||
onPress={() => onToggle(option.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Dimensions, Image, ImageSourcePropType } from 'react-native';
|
||||
import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Image, ImageSourcePropType, Platform, useWindowDimensions } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Animated, {
|
||||
@@ -10,7 +10,6 @@ import Animated, {
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
const FIXED_TOP_GAP = 100; // 统一距离顶部的高度
|
||||
|
||||
type Props = {
|
||||
@@ -30,11 +29,13 @@ type Props = {
|
||||
export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const isTablet = Platform.OS === 'ios' && Math.min(windowWidth, windowHeight) >= 768;
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const progress = useSharedValue(0); // 0: 关闭, 1: 打开
|
||||
const dragY = useSharedValue(0); // 拖拽位移
|
||||
|
||||
const sheetHeight = customHeight || (SCREEN_HEIGHT - FIXED_TOP_GAP);
|
||||
const sheetHeight = customHeight || (isTablet ? Math.min(windowHeight - 72, 760) : (windowHeight - FIXED_TOP_GAP));
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -90,7 +91,10 @@ export default function SheetModal({ visible, title, onClose, children, leftIcon
|
||||
};
|
||||
});
|
||||
|
||||
const containerPaddingBottom = useMemo(() => Math.max(insets.bottom, 80), [insets.bottom]); // 增加底部间距至 80,约占 350 高度的 22%,确保内容不被截断并留出足够呼吸感
|
||||
const containerPaddingBottom = useMemo(() => {
|
||||
if (customHeight) return Math.max(insets.bottom, 16);
|
||||
return Math.max(insets.bottom, isTablet ? 20 : 80);
|
||||
}, [customHeight, insets.bottom, isTablet]);
|
||||
|
||||
// 注意:Modal 的 visible 必须为 true 才会渲染,因此用 mounted 保持退场动画
|
||||
return (
|
||||
@@ -107,6 +111,7 @@ export default function SheetModal({ visible, title, onClose, children, leftIcon
|
||||
{...panResponder.panHandlers}
|
||||
style={[
|
||||
styles.sheet,
|
||||
isTablet ? styles.sheetTablet : null,
|
||||
sheetStyle,
|
||||
{
|
||||
height: sheetHeight,
|
||||
@@ -159,6 +164,14 @@ const styles = StyleSheet.create({
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
sheetTablet: {
|
||||
width: '100%',
|
||||
alignSelf: 'stretch',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
handleContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objectVersion = 70;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -11,7 +11,7 @@
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */; };
|
||||
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
|
||||
@@ -54,7 +54,7 @@
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
|
||||
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
|
||||
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
@@ -74,7 +74,7 @@
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
|
||||
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
EmotionWidget.swift,
|
||||
@@ -85,18 +85,7 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = "情绪小组件";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -213,7 +202,7 @@
|
||||
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
|
||||
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
@@ -482,7 +471,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
|
||||
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -536,7 +525,7 @@
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
@@ -576,7 +565,7 @@
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
@@ -756,7 +745,7 @@
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -808,7 +797,7 @@
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 143 KiB |
@@ -38,8 +38,6 @@
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
@@ -47,6 +45,8 @@
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
||||
@@ -60,6 +60,8 @@
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleDefault</string>
|
||||
@@ -72,8 +74,6 @@
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Automatic</string>
|
||||
|
||||
@@ -280,12 +280,12 @@ struct EmotionWidgetView: View {
|
||||
Text(entry.text)
|
||||
.font(fontForFamily())
|
||||
.foregroundColor(widgetTextColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(lineSpacingForFamily())
|
||||
.lineLimit(lineLimitForFamily())
|
||||
.minimumScaleFactor(0.78)
|
||||
.padding(paddingForFamily())
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
.widgetSolidBackground(widgetBackgroundColor)
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ function getApiBaseUrl(env: AppRuntimeEnv): string {
|
||||
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'https://api.damer.fun'));
|
||||
}
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl(APPpai qa
|
||||
export const API_BASE_URL = getApiBaseUrl(APP_ENV);
|
||||
|
||||
/**
|
||||
* 调试:打印环境变量注入结果(仅开发环境)
|
||||
|
||||
77
client/src/services/__tests__/pushNotificationRoute.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../storage/appStorage', () => ({
|
||||
getLastHandledNotificationId: vi.fn(async () => null),
|
||||
setLastHandledNotificationId: vi.fn(async () => undefined),
|
||||
setPendingHomePushMessage: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { buildPendingHomePushMessageFromResponse } from '../pushNotificationRoute';
|
||||
|
||||
describe('pushNotificationRoute.buildPendingHomePushMessageFromResponse', () => {
|
||||
it('能从每日推荐 push payload 提取 home 文案', () => {
|
||||
const result = buildPendingHomePushMessageFromResponse({
|
||||
notification: {
|
||||
request: {
|
||||
identifier: 'notif-1',
|
||||
content: {
|
||||
title: '每日推荐',
|
||||
body: '先用通知正文兜底',
|
||||
data: {
|
||||
scene: 'push',
|
||||
target_screen: 'home',
|
||||
home_text: '点击推送后回到首页展示这句文案',
|
||||
content_id: '42',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
notification_id: 'notif-1',
|
||||
title: '每日推荐',
|
||||
text: '点击推送后回到首页展示这句文案',
|
||||
content_id: 42,
|
||||
scene: 'push',
|
||||
});
|
||||
});
|
||||
|
||||
it('home_text 缺失时回退到通知正文', () => {
|
||||
const result = buildPendingHomePushMessageFromResponse({
|
||||
notification: {
|
||||
request: {
|
||||
identifier: 'notif-2',
|
||||
content: {
|
||||
body: '直接展示通知正文',
|
||||
data: {
|
||||
target_screen: 'home',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.text).toBe('直接展示通知正文');
|
||||
expect(result?.notification_id).toBe('notif-2');
|
||||
});
|
||||
|
||||
it('非 home 目标且非 push 场景时忽略', () => {
|
||||
const result = buildPendingHomePushMessageFromResponse({
|
||||
notification: {
|
||||
request: {
|
||||
identifier: 'notif-3',
|
||||
content: {
|
||||
body: '这条不应该进入首页',
|
||||
data: {
|
||||
target_screen: 'profile',
|
||||
scene: 'other',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
100
client/src/services/pushNotificationRoute.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
getLastHandledNotificationId,
|
||||
setLastHandledNotificationId,
|
||||
setPendingHomePushMessage,
|
||||
type PendingHomePushMessage,
|
||||
} from '../storage/appStorage';
|
||||
|
||||
type NotificationContentLike = {
|
||||
title?: string | null;
|
||||
body?: string | null;
|
||||
data?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type NotificationResponseLike = {
|
||||
notification?: {
|
||||
request?: {
|
||||
identifier?: string;
|
||||
content?: NotificationContentLike;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type HomePushListener = (message: PendingHomePushMessage) => void;
|
||||
|
||||
const homePushListeners = new Set<HomePushListener>();
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function readContentId(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return Math.trunc(parsed);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function notifyHomePushListeners(message: PendingHomePushMessage): void {
|
||||
for (const listener of homePushListeners) {
|
||||
listener(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeHomePushMessage(listener: HomePushListener): () => void {
|
||||
homePushListeners.add(listener);
|
||||
return () => {
|
||||
homePushListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPendingHomePushMessageFromResponse(
|
||||
response: NotificationResponseLike
|
||||
): PendingHomePushMessage | null {
|
||||
const request = response.notification?.request;
|
||||
const content = request?.content;
|
||||
const data =
|
||||
content?.data && typeof content.data === 'object' && !Array.isArray(content.data)
|
||||
? content.data
|
||||
: {};
|
||||
|
||||
const targetScreen = readString(data.target_screen);
|
||||
const scene = readString(data.scene);
|
||||
const shouldOpenHome = targetScreen === 'home' || scene === 'push';
|
||||
if (!shouldOpenHome) return null;
|
||||
|
||||
const text = readString(data.home_text) ?? readString(content?.body);
|
||||
if (!text) return null;
|
||||
|
||||
return {
|
||||
notification_id: readString(request?.identifier) ?? `push-${Date.now()}`,
|
||||
received_at: new Date().toISOString(),
|
||||
text,
|
||||
title: readString(content?.title),
|
||||
content_id: readContentId(data.content_id),
|
||||
scene,
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistHomePushMessageFromResponse(
|
||||
response: NotificationResponseLike
|
||||
): Promise<PendingHomePushMessage | null> {
|
||||
const message = buildPendingHomePushMessageFromResponse(response);
|
||||
if (!message) return null;
|
||||
|
||||
const lastHandledNotificationId = await getLastHandledNotificationId();
|
||||
if (lastHandledNotificationId === message.notification_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await setPendingHomePushMessage(message);
|
||||
await setLastHandledNotificationId(message.notification_id);
|
||||
notifyHomePushListeners(message);
|
||||
return message;
|
||||
}
|
||||
@@ -21,6 +21,8 @@ const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
||||
const KEY_PUSH_LAST_REGISTERED_TOKEN = 'push.lastRegisteredToken'; // 旧:仅 token(保留兼容读取)
|
||||
const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt'; // 旧:时间(保留兼容)
|
||||
const KEY_PUSH_LAST_REGISTERED_PAYLOAD = 'push.lastRegisteredPayload'; // 新:token+client_user_id+env+app_id
|
||||
const KEY_PUSH_PENDING_HOME_MESSAGE = 'push.pendingHomeMessage';
|
||||
const KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID = 'push.lastHandledNotificationId';
|
||||
|
||||
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
|
||||
export type Reaction = 'like' | 'dislike';
|
||||
@@ -112,6 +114,52 @@ export async function setLastRegisteredPushPayload(payload: Omit<LastRegisteredP
|
||||
await setLastRegisteredPushToken(payload.pushToken);
|
||||
}
|
||||
|
||||
export type PendingHomePushMessage = {
|
||||
notification_id: string;
|
||||
received_at: string; // ISO8601
|
||||
text: string;
|
||||
title?: string;
|
||||
content_id?: number;
|
||||
scene?: string;
|
||||
};
|
||||
|
||||
export async function getPendingHomePushMessage(): Promise<PendingHomePushMessage | null> {
|
||||
const raw = await AsyncStorage.getItem(KEY_PUSH_PENDING_HOME_MESSAGE);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Partial<PendingHomePushMessage>;
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
if (!obj.notification_id || !obj.received_at || !obj.text) return null;
|
||||
return {
|
||||
notification_id: String(obj.notification_id),
|
||||
received_at: String(obj.received_at),
|
||||
text: String(obj.text),
|
||||
title: obj.title ? String(obj.title) : undefined,
|
||||
content_id: Number.isFinite(obj.content_id) ? Number(obj.content_id) : undefined,
|
||||
scene: obj.scene ? String(obj.scene) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPendingHomePushMessage(message: PendingHomePushMessage): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY_PUSH_PENDING_HOME_MESSAGE, JSON.stringify(message));
|
||||
}
|
||||
|
||||
export async function clearPendingHomePushMessage(): Promise<void> {
|
||||
await AsyncStorage.removeItem(KEY_PUSH_PENDING_HOME_MESSAGE);
|
||||
}
|
||||
|
||||
export async function getLastHandledNotificationId(): Promise<string | null> {
|
||||
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID);
|
||||
return raw ? String(raw) : null;
|
||||
}
|
||||
|
||||
export async function setLastHandledNotificationId(notificationId: string): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID, String(notificationId));
|
||||
}
|
||||
|
||||
export type RecoFeedCacheItem = {
|
||||
content_id: number;
|
||||
text: string;
|
||||
|
||||
9
client/src/utils/device.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export function isIPadLike(width: number, height: number): boolean {
|
||||
return Platform.OS === 'ios' && Math.min(width, height) >= 768;
|
||||
}
|
||||
|
||||
export function clampContentWidth(width: number, maxWidth: number, horizontalPadding: number): number {
|
||||
return Math.min(maxWidth, Math.max(0, width - horizontalPadding * 2));
|
||||
}
|
||||
@@ -7,16 +7,70 @@ from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.legal_docs import PRIVACY_POLICY_MD, TERMS_OF_USE_MD, choose_content_by_lang, render_as_simple_html
|
||||
from app.legal_docs import (
|
||||
PRIVACY_POLICY_MD,
|
||||
TERMS_OF_USE_MD,
|
||||
choose_content_by_lang,
|
||||
render_as_simple_html,
|
||||
split_bilingual_markdown,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/legal", tags=["legal"])
|
||||
|
||||
INTERNAL_SENTINEL = "__internal__"
|
||||
|
||||
# 技术支持页文案(EN / TC)
|
||||
SUPPORT_MD = """Dear Mama | Technical Support
|
||||
Last updated: February 2026
|
||||
|
||||
If you need technical support for Dear Mama, please contact us:
|
||||
- Support email: leitinglan@project-c.org
|
||||
|
||||
Support scope (including but not limited to):
|
||||
- App installation or update issues
|
||||
- App crashes, freezes, or abnormal behavior
|
||||
- Notification or widget related issues
|
||||
- Difficulty accessing privacy policy or terms pages
|
||||
|
||||
To help us process your request faster, please include:
|
||||
- Device model and OS version
|
||||
- App version
|
||||
- A brief issue description and occurrence time
|
||||
- Screenshots or screen recording (if available)
|
||||
|
||||
Service commitment:
|
||||
- We generally respond within 3-5 business days
|
||||
- Emergency availability may vary by holidays and local time
|
||||
|
||||
---
|
||||
Dear Mama|技術支援
|
||||
最後更新日期:2026 年 2 月
|
||||
|
||||
如需 Dear Mama 的技術支援,請透過以下方式聯絡我們:
|
||||
- 支援信箱:leitinglan@project-c.org
|
||||
|
||||
支援範圍(包含但不限於):
|
||||
- App 安裝或更新問題
|
||||
- App 閃退、卡頓或異常行為
|
||||
- 通知或小組件相關問題
|
||||
- 隱私政策或使用條款頁面無法開啟
|
||||
|
||||
為了加快處理,建議提供:
|
||||
- 裝置型號與系統版本
|
||||
- App 版本號
|
||||
- 問題描述與發生時間
|
||||
- 截圖或螢幕錄影(如有)
|
||||
|
||||
服務承諾:
|
||||
- 一般會在 3-5 個工作日內回覆
|
||||
- 節假日或時區差異時,回覆時間可能延長
|
||||
"""
|
||||
|
||||
|
||||
# 当前多语言仅支持 EN / TC(繁体)
|
||||
ResolvedLang = Literal["en", "tc"]
|
||||
BILINGUAL_CONTENT_LANGUAGE = "en, zh-Hant"
|
||||
|
||||
|
||||
class LegalLinksResponse(BaseModel):
|
||||
@@ -36,13 +90,23 @@ def _resolve_lang(accept_language: Optional[str]) -> ResolvedLang:
|
||||
|
||||
if not accept_language:
|
||||
return "en"
|
||||
s = accept_language.lower()
|
||||
|
||||
# 目前只支持 EN / TC:只要是中文或显式 tc,都归到 tc
|
||||
if "tc" in s:
|
||||
return "tc"
|
||||
if "zh" in s or "hant" in s or "tw" in s or "hk" in s or "mo" in s:
|
||||
return "tc"
|
||||
# 按语言优先级顺序逐项解析,避免“只要包含 zh 就全判 tc”。
|
||||
# 例如:en-US,en;q=0.9,zh-CN;q=0.8 应命中 en,而不是 tc。
|
||||
items = [seg.strip().lower() for seg in accept_language.split(",") if seg.strip()]
|
||||
for item in items:
|
||||
lang_tag = item.split(";", 1)[0].strip()
|
||||
if not lang_tag:
|
||||
continue
|
||||
|
||||
if lang_tag == "tc":
|
||||
return "tc"
|
||||
if lang_tag.startswith(("zh-hant", "zh-tw", "zh-hk", "zh-mo")):
|
||||
return "tc"
|
||||
if lang_tag.startswith("zh"):
|
||||
return "tc"
|
||||
if lang_tag.startswith("en"):
|
||||
return "en"
|
||||
|
||||
return "en"
|
||||
|
||||
@@ -85,6 +149,18 @@ def _normalize_internal_url(request: Request, url: str, internal_path: str) -> s
|
||||
return _join_base_url(str(request.base_url), internal_path)
|
||||
|
||||
|
||||
def _build_bilingual_content(text: str) -> str:
|
||||
"""
|
||||
固定输出双语协议内容:EN 在前,TC 在后。
|
||||
若缺少 TC,则仅返回 EN。
|
||||
"""
|
||||
|
||||
en, tc = split_bilingual_markdown(text)
|
||||
if not tc:
|
||||
return en
|
||||
return f"{en}\n\n---\n{tc}"
|
||||
|
||||
|
||||
@router.get("/links", response_model=LegalLinksResponse)
|
||||
async def get_legal_links(request: Request) -> LegalLinksResponse:
|
||||
"""
|
||||
@@ -109,11 +185,18 @@ async def get_privacy_policy(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
lang = _resolve_lang(accept_language)
|
||||
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
|
||||
title = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama|隱私權政策"
|
||||
page = render_as_simple_html(title=title, content=content)
|
||||
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
||||
if accept_language:
|
||||
lang = _resolve_lang(accept_language)
|
||||
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
|
||||
title = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama|隱私權政策"
|
||||
html_lang = "en" if resolved == "en" else "zh-Hant"
|
||||
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
|
||||
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
|
||||
|
||||
content = _build_bilingual_content(PRIVACY_POLICY_MD)
|
||||
title = "Dear Mama | Privacy Policy / 隱私權政策"
|
||||
page = render_as_simple_html(title=title, content=content, html_lang="en")
|
||||
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})
|
||||
|
||||
|
||||
@router.get("/terms", response_class=HTMLResponse)
|
||||
@@ -123,9 +206,37 @@ async def get_terms_of_use(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
lang = _resolve_lang(accept_language)
|
||||
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
|
||||
title = "Dear Mama – Terms of Use" if resolved == "en" else "Dear Mama 使用條款"
|
||||
page = render_as_simple_html(title=title, content=content)
|
||||
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
||||
if accept_language:
|
||||
lang = _resolve_lang(accept_language)
|
||||
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
|
||||
title = "Dear Mama – Terms of Use" if resolved == "en" else "Dear Mama 使用條款"
|
||||
html_lang = "en" if resolved == "en" else "zh-Hant"
|
||||
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
|
||||
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
|
||||
|
||||
content = _build_bilingual_content(TERMS_OF_USE_MD)
|
||||
title = "Dear Mama – Terms of Use / 使用條款"
|
||||
page = render_as_simple_html(title=title, content=content, html_lang="en")
|
||||
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})
|
||||
|
||||
|
||||
@router.get("/support", response_class=HTMLResponse)
|
||||
async def get_support_page(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
技术支持页面(用于 App 审核的可访问 URL)。
|
||||
"""
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
if accept_language:
|
||||
lang = _resolve_lang(accept_language)
|
||||
content, resolved = choose_content_by_lang(SUPPORT_MD, lang)
|
||||
title = "Dear Mama | Technical Support" if resolved == "en" else "Dear Mama|技術支援"
|
||||
html_lang = "en" if resolved == "en" else "zh-Hant"
|
||||
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
|
||||
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
|
||||
|
||||
content = _build_bilingual_content(SUPPORT_MD)
|
||||
title = "Dear Mama | Technical Support / 技術支援"
|
||||
page = render_as_simple_html(title=title, content=content, html_lang="en")
|
||||
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.db.models.push_preference import PushPreference
|
||||
from app.db.models.push_token import PushToken
|
||||
from app.db.models.push_send_log import PushSendLog
|
||||
from app.db.session import get_db
|
||||
from app.features.push_payload import build_home_push_data
|
||||
from app.features.user_profile_scoring.types import UserProfileV1_2
|
||||
from app.worker import celery_app
|
||||
|
||||
@@ -289,7 +290,16 @@ async def test_push(
|
||||
title = req.title or "Dear Mama"
|
||||
body = req.body or "这是一条测试推送(dev)。"
|
||||
|
||||
expo_res = await _send_expo_push(to=token.push_token, title=title, body=body, data={"client_user_id": req.client_user_id})
|
||||
expo_res = await _send_expo_push(
|
||||
to=token.push_token,
|
||||
title=title,
|
||||
body=body,
|
||||
data=build_home_push_data(
|
||||
client_user_id=req.client_user_id,
|
||||
body=body,
|
||||
scene="push",
|
||||
),
|
||||
)
|
||||
_ = accept_language
|
||||
return {"status": "ok", "expo": expo_res}
|
||||
|
||||
|
||||
26
server/app/features/push_payload.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def build_home_push_data(
|
||||
*,
|
||||
client_user_id: str,
|
||||
body: str,
|
||||
scene: str = "push",
|
||||
content_id: Optional[int] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
构建客户端点击通知后回到 Home 所需的最小 payload。
|
||||
"""
|
||||
|
||||
data: dict[str, Any] = {
|
||||
"client_user_id": str(client_user_id),
|
||||
"scene": str(scene),
|
||||
"target_screen": "home",
|
||||
"deep_link": "client://home",
|
||||
"home_text": str(body),
|
||||
}
|
||||
if content_id is not None:
|
||||
data["content_id"] = int(content_id)
|
||||
return data
|
||||
@@ -265,7 +265,7 @@ def choose_content_by_lang(text: str, lang: ResolvedLang) -> tuple[str, Resolved
|
||||
return en, "en"
|
||||
|
||||
|
||||
def render_as_simple_html(title: str, content: str) -> str:
|
||||
def render_as_simple_html(title: str, content: str, html_lang: str = "en") -> str:
|
||||
"""
|
||||
将文本以简单 HTML 的方式展示(使用 pre 保留换行并自动换行)。
|
||||
不做 Markdown 渲染,避免引入额外依赖,确保“最小可用、必有内容”。
|
||||
@@ -273,8 +273,9 @@ def render_as_simple_html(title: str, content: str) -> str:
|
||||
|
||||
safe_title = html.escape(title)
|
||||
safe_content = html.escape(content)
|
||||
safe_lang = html.escape(html_lang or "en")
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="{safe_lang}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.db.models.push_preference import PushPreference
|
||||
from app.db.models.push_send_log import PushSendLog
|
||||
from app.db.models.push_token import PushToken
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.features.push_payload import build_home_push_data
|
||||
from app.features.personalized_reco.content_repository.types import normalize_locale
|
||||
from app.features.user_profile_scoring.scoring import build_user_profile_from_questionnaire
|
||||
from app.features.user_profile_scoring.types import QuestionnaireAnswersV1_2, UserProfileV1_2
|
||||
@@ -68,7 +69,11 @@ async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict
|
||||
|
||||
def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[datetime]:
|
||||
"""
|
||||
将窗口均匀切分为 n 个区间,并在每段内随机取一个时间点(抖动)。
|
||||
将窗口均匀切分为 n 个区间,并在每段内取“中点 + 受限抖动”的时间点。
|
||||
|
||||
目的:
|
||||
- 尽量均匀分布(避免相邻两条推送随机到非常接近的时间)
|
||||
- 仍保留一定随机性,避免过于机械
|
||||
"""
|
||||
|
||||
if n <= 0:
|
||||
@@ -85,8 +90,15 @@ def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[dat
|
||||
if seg <= 0:
|
||||
out.append(seg_start)
|
||||
continue
|
||||
jitter = random.random() * seg
|
||||
out.append(seg_start + timedelta(seconds=jitter))
|
||||
|
||||
# 受限抖动:在每段的 [25%, 75%] 区间内取点
|
||||
# 这样相邻两段的最小间隔为 50% 段长,能显著减少“随机挤在一起”。
|
||||
mid = seg_start + timedelta(seconds=seg * 0.5)
|
||||
jitter = (random.random() - 0.5) * (seg * 0.5) # [-0.25*seg, +0.25*seg]
|
||||
out.append(mid + timedelta(seconds=jitter))
|
||||
|
||||
# 保序(理论上天然有序,这里再保险)
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
|
||||
@@ -289,18 +301,22 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
|
||||
# 关键:这里不能调用 tasks.reco.generate(内部会 asyncio.run),否则会嵌套事件循环崩溃。
|
||||
from app.tasks.reco import run_reco_payload_async
|
||||
|
||||
# 去重:同一用户同一天尽量不重复推送相同 content
|
||||
# 去重:用户推送过的内容尽量不再推送
|
||||
# 说明:
|
||||
# - 依赖 push_send_log.content_id(需先完成对应 DB 迁移)
|
||||
# - 为避免历史过长导致 already_recommended_ids 过大,这里取“最近若干条已推送内容”近似全量去重
|
||||
used_ids: list[int] = []
|
||||
try:
|
||||
qused = (
|
||||
select(PushSendLog.content_id)
|
||||
.where(
|
||||
PushSendLog.client_user_id == client_user_id,
|
||||
PushSendLog.local_date == local_date,
|
||||
PushSendLog.content_id.is_not(None),
|
||||
PushSendLog.id != log.id,
|
||||
)
|
||||
.order_by(PushSendLog.slot_index.asc())
|
||||
# 优先排除最近发送过的内容
|
||||
.order_by(PushSendLog.local_date.desc(), PushSendLog.slot_index.desc())
|
||||
.limit(5000)
|
||||
)
|
||||
rused = await session.execute(qused)
|
||||
used_ids = [int(x) for x in rused.scalars().all() if x is not None]
|
||||
@@ -350,7 +366,12 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
|
||||
to=str(token.push_token),
|
||||
title=title,
|
||||
body=body,
|
||||
data={"client_user_id": client_user_id, "scene": "push"},
|
||||
data=build_home_push_data(
|
||||
client_user_id=client_user_id,
|
||||
body=body,
|
||||
scene="push",
|
||||
content_id=picked_content_id,
|
||||
),
|
||||
)
|
||||
|
||||
# 6) 解析 Expo 回执,必要时停用 token
|
||||
|
||||
32
server/tests/test_push_payload.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.features.push_payload import build_home_push_data
|
||||
|
||||
|
||||
def test_build_home_push_data_contains_home_route_fields() -> None:
|
||||
payload = build_home_push_data(
|
||||
client_user_id="client-123",
|
||||
body="今天也请温柔地对自己说话。",
|
||||
scene="push",
|
||||
content_id=9,
|
||||
)
|
||||
|
||||
assert payload == {
|
||||
"client_user_id": "client-123",
|
||||
"scene": "push",
|
||||
"target_screen": "home",
|
||||
"deep_link": "client://home",
|
||||
"home_text": "今天也请温柔地对自己说话。",
|
||||
"content_id": 9,
|
||||
}
|
||||
|
||||
|
||||
def test_build_home_push_data_omits_content_id_when_missing() -> None:
|
||||
payload = build_home_push_data(
|
||||
client_user_id="client-123",
|
||||
body="先看到这句,再回到首页。",
|
||||
)
|
||||
|
||||
assert "content_id" not in payload
|
||||
assert payload["target_screen"] == "home"
|
||||
assert payload["home_text"] == "先看到这句,再回到首页。"
|
||||
137
spec_kit/iPad Adaptation/plan.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# iPad Adaptation(技术计划)
|
||||
|
||||
## 1. 计划目标
|
||||
|
||||
基于 `spec.md`,落地“全应用 iPad 适配(含 iOS Widget)”实施方案,确保:
|
||||
|
||||
- 客户端所有页面在 iPad 下可用、可读、可交互;
|
||||
- 以 **竖屏** 为主目标完成逐页适配与验收;
|
||||
- iOS Widget 在 iPad 相关尺寸下展示稳定;
|
||||
- 全过程保持 **iPhone 零回归**。
|
||||
|
||||
## 2. 默认技术决策
|
||||
|
||||
- **适配方式**:React Native 断点 + 条件样式(不改 iPhone 基线参数)。
|
||||
- **iPad 判定**:`Platform.OS === 'ios' && Math.min(width, height) >= 768`。
|
||||
- **尺寸获取**:统一使用 `useWindowDimensions()`,避免 `Dimensions.get()` 静态值问题。
|
||||
- **布局原则**:页面采用“最大内容宽度 + 居中 + 弹性留白”。
|
||||
- **改造策略**:按页面逐个交付,单页完成后冻结,待确认再进入下一页。
|
||||
|
||||
## 3. 工程配置基线(先决条件)
|
||||
|
||||
### 3.1 原生配置核查
|
||||
|
||||
- `app.json` 中 `expo.ios.supportsTablet = true`(已要求)。
|
||||
- iOS 工程目标设备族支持 iPad(`TARGETED_DEVICE_FAMILY` 需包含 `1,2`)。
|
||||
- 方向策略与产品要求一致(当前优先竖屏;若仅竖屏,则 iPad 也收敛为竖屏策略)。
|
||||
- 避免 iPad 以 iPhone 兼容模式运行(该模式会导致系统黑边)。
|
||||
|
||||
### 3.2 运行模式与验收环境
|
||||
|
||||
- iPad 模拟器:至少 1 台主流尺寸(如 11-inch)。
|
||||
- iPad 真机:至少 1 台(若可用),用于确认系统级显示行为。
|
||||
- iPhone 回归机型:至少小屏 + 大屏各 1 个。
|
||||
|
||||
## 4. 页面适配实施策略(逐页)
|
||||
|
||||
### 4.1 页面分批顺序
|
||||
|
||||
1. 启动链路:`splash / consent / index`
|
||||
2. Onboarding 全流程页
|
||||
3. 主应用页:`home`、详情/弹层、设置、收藏等
|
||||
4. 边缘页:`modal`、`not-found`、其他辅助页
|
||||
|
||||
### 4.2 单页改造标准模板
|
||||
|
||||
每页按以下模板执行:
|
||||
|
||||
1. 梳理页面结构:首屏视觉区、正文区、底部操作区、浮层区。
|
||||
2. 抽离 iPad 分支参数:最大宽度、字号、行高、间距、按钮尺寸。
|
||||
3. 保持 iPhone 参数不变:原样式分支保留。
|
||||
4. 自测:
|
||||
- iPad 竖屏显示完整;
|
||||
- 文案不截断、按钮可点击;
|
||||
- iPhone 关键路径不回归。
|
||||
5. 输出截图与验收点,待确认后冻结该页。
|
||||
|
||||
### 4.3 可复用样式基线(建议)
|
||||
|
||||
- 页面容器:`flex: 1` + `width/height: '100%'` + `alignSelf: 'stretch'`。
|
||||
- 内容最大宽度:按页面类型设置(例如 560/620/680 分级),统一居中。
|
||||
- 底部操作区:基于安全区与窗口高度计算,不使用硬编码魔法值。
|
||||
- 文案区:限制最大阅读宽度,iPad 适度增大字号与行高。
|
||||
- 图片/插画:按比例缩放,优先保持构图稳定,不压缩变形。
|
||||
|
||||
## 5. iOS Widget 适配计划
|
||||
|
||||
### 5.1 覆盖范围
|
||||
|
||||
- 小/中/大组件在 iPad 下的展示一致性;
|
||||
- 文案长度、换行与截断策略;
|
||||
- 图形与文本的层级、边距、留白;
|
||||
- 浅色模式基线(深色模式按资源情况补充)。
|
||||
|
||||
### 5.2 实施要点
|
||||
|
||||
- 统一组件内边距与字号层级映射;
|
||||
- 对长文本提供优雅截断(避免溢出与跳变);
|
||||
- 验证不同语言长度对布局影响;
|
||||
- 输出尺寸矩阵截图作为最终验收材料。
|
||||
|
||||
## 6. iPhone 零回归保障
|
||||
|
||||
- 所有 iPad 适配均以条件分支或断点参数实现;
|
||||
- 禁止直接覆盖 iPhone 基线字号/间距;
|
||||
- 每完成一页,执行 iPhone 冒烟回归:
|
||||
- 启动流程;
|
||||
- Onboarding 关键交互;
|
||||
- Home 关键按钮与弹层;
|
||||
- Settings/Favorites 基础可用性。
|
||||
|
||||
## 7. 验收清单与交付物
|
||||
|
||||
### 7.1 页面级验收清单
|
||||
|
||||
- 布局完整:无重叠、无错位、无异常黑边;
|
||||
- 文案可读:不截断(可接受预期截断场景需说明);
|
||||
- 交互可用:触控面积合理、按钮不被遮挡;
|
||||
- 状态一致:加载/空态/异常态显示正常;
|
||||
- iPhone 回归:关键路径无变化。
|
||||
|
||||
### 7.2 交付物
|
||||
|
||||
- 每页适配说明(改动点 + 参数策略);
|
||||
- iPad 改前/改后截图;
|
||||
- iPhone 回归截图;
|
||||
- Widget 各尺寸截图;
|
||||
- 最终页面覆盖矩阵与验收记录。
|
||||
|
||||
## 8. 分阶段里程碑
|
||||
|
||||
1. **M1 基线搭建**
|
||||
- 完成原生配置核查与适配基线工具/约定;
|
||||
- 输出页面清单与验收模板。
|
||||
|
||||
2. **M2 页面逐页适配**
|
||||
- 按既定顺序逐页改造;
|
||||
- 每页交付后等待确认,再继续下一页。
|
||||
|
||||
3. **M3 Widget 收口**
|
||||
- 完成 iPad 尺寸矩阵验证;
|
||||
- 修复文本/间距/层级问题。
|
||||
|
||||
4. **M4 全量回归与发布前验收**
|
||||
- iPad 全页验收 + iPhone 零回归确认;
|
||||
- 形成最终验收文档。
|
||||
|
||||
## 9. 风险与应对
|
||||
|
||||
- **风险**:页面存在大量固定像素值,局部改动引发联动。
|
||||
**应对**:参数分层、按区块替换、单页冻结机制。
|
||||
|
||||
- **风险**:iPad 多窗口/舞台管理导致尺寸波动。
|
||||
**应对**:以竖屏全屏为主验收,同时保留窗口变化最小兼容策略。
|
||||
|
||||
- **风险**:多语言文案长度影响 widget 与页面稳定性。
|
||||
**应对**:统一截断/换行规则,增加长文案样本回归。
|
||||
|
||||
92
spec_kit/iPad Adaptation/spec.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# iPad Adaptation Spec
|
||||
|
||||
## Background
|
||||
|
||||
当前应用主要按 iPhone 体验实现,iPad 上存在以下问题:
|
||||
- 页面布局在竖屏/横屏或不同窗口尺寸下出现留黑边、内容拥挤、元素比例失衡。
|
||||
- 各页面对 iPad 的适配策略不统一,样式行为不可预测。
|
||||
- iOS Widget 在 iPad 场景下缺少完整的尺寸与排版一致性规范。
|
||||
|
||||
本需求定义“全应用 iPad 适配”高层规范,覆盖客户端全部页面与 iOS 小组件,确保不回归现有 iPhone 体验。
|
||||
|
||||
## Goals
|
||||
|
||||
1. 为客户端所有页面建立统一的 iPad 适配规范(优先竖屏,兼容 iPad 常见窗口模式)。
|
||||
2. 完成全部页面在 iPad 下的布局、间距、字号、交互可用性优化。
|
||||
3. 完成 iOS Widget 在 iPad 相关尺寸上的视觉与信息层级适配。
|
||||
4. 明确“iPad 适配不影响 iPhone”的约束、验收与回归策略。
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 不重做品牌视觉与核心交互流程。
|
||||
- 不引入与 iPad 适配无关的新业务功能。
|
||||
- 不调整后端接口契约(除非为 Widget 展示字段做最小兼容补充)。
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- `client/app/` 下所有用户可见页面(启动、协议、onboarding、home、modal、not-found 等)。
|
||||
- 共享组件与页面级组件在 iPad 场景下的布局策略(容器宽度、断点、字号、触控面积、safe area 处理)。
|
||||
- iPad 竖屏主流程视觉一致性与可用性。
|
||||
- iOS Widget(小/中/大尺寸)在 iPad 的展示、文本截断、间距与点击目标。
|
||||
- iPad 相关工程配置核查(如设备家族支持、方向策略与运行模式)。
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Android 平板专项适配。
|
||||
- Web 端平板适配。
|
||||
- 新增 widget 类型或新增推荐策略。
|
||||
|
||||
## Core Requirements
|
||||
|
||||
### R1. 统一适配基线
|
||||
|
||||
- 定义 iPad 判定与布局断点策略,避免各页面各自实现。
|
||||
- 页面默认使用“内容最大宽度 + 居中 + 弹性留白”模式,不出现视觉黑边误判。
|
||||
- 明确安全区、状态栏、底部操作区在 iPad 下的通用规则。
|
||||
|
||||
### R2. 页面逐页适配
|
||||
|
||||
- 按页面清单逐页交付,单页可独立验收。
|
||||
- 每页适配需覆盖:首屏构图、正文可读性、底部操作区、长文案换行与触控可用性。
|
||||
- 已完成页面进入“冻结状态”,未经确认不回改。
|
||||
|
||||
### R3. iPhone 零回归
|
||||
|
||||
- 所有 iPad 样式调整必须使用条件分支或断点方案,不修改 iPhone 基线参数。
|
||||
- 每次页面适配后执行 iPhone 快速回归(关键路径与关键组件)。
|
||||
|
||||
### R4. iOS Widget 适配
|
||||
|
||||
- 覆盖 iPad 下 widget 尺寸与展示密度差异,保证文本与图形不溢出、不遮挡。
|
||||
- 小组件与主 App 的主题、字体层级、文案截断策略保持一致。
|
||||
- 提供 widget 预览/截图验收基线(至少包含浅色模式)。
|
||||
|
||||
### R5. 验收与质量
|
||||
|
||||
- 建立页面级验收清单:布局完整性、可读性、点击可达性、状态一致性、异常文案表现。
|
||||
- 关键页面提供 iPad 对比截图(改前/改后)与 iPhone 回归截图。
|
||||
- 适配完成后输出覆盖清单,确保无遗漏页面。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. 应用在 iPad 真机/模拟器上以 iPad 模式运行,不出现 iPhone 兼容模式导致的系统黑边。
|
||||
2. 全部页面在 iPad 竖屏下通过视觉与交互验收,页面无明显错位、截断、重叠。
|
||||
3. iPhone 主流尺寸下关键路径无样式与交互回归。
|
||||
4. iOS Widget 在 iPad 对应尺寸下通过展示验收。
|
||||
5. 提供最终“页面覆盖矩阵 + 验收记录”。
|
||||
|
||||
## Risks
|
||||
|
||||
- 现有页面存在大量固定像素值,逐页改造可能引入局部联动风险。
|
||||
- iPad 多窗口/舞台管理会带来额外窗口尺寸变化,需要明确支持级别。
|
||||
- Widget 文案长度受多语言影响,需预留截断与回退策略。
|
||||
|
||||
## Milestones (High-Level)
|
||||
|
||||
1. 基线与清单:完成断点策略、页面与组件清单、验收模板。
|
||||
2. 页面适配:按“启动链路 -> onboarding -> 主页面 -> 弹层/边缘页面”逐页交付。
|
||||
3. Widget 适配:完成尺寸验证与视觉一致性收口。
|
||||
4. 全量回归:iPad 全页检查 + iPhone 零回归确认 + 发布前验收。
|
||||
|
||||
151
spec_kit/iPad Adaptation/task.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# iPad Adaptation(任务清单)
|
||||
|
||||
> 说明:本清单由 `plan.md` 拆解,强调“详细、可执行、可验收”。
|
||||
> 执行规则:完成后将 `- [ ]` 改为 `- [x]`;阻塞项需补充阻塞原因与解除条件。
|
||||
> 约束:所有 iPad 改动不得影响 iPhone 现有适配。
|
||||
|
||||
## 0. 基线与准备
|
||||
|
||||
- [ ] **T0-1 建立页面与组件盘点清单**
|
||||
- 输出:`client/app/` 页面清单 + 关键共享组件清单(含负责人/优先级)
|
||||
- 验收:清单覆盖启动链路、onboarding、home、settings、favorites、modal、not-found
|
||||
|
||||
- [ ] **T0-2 建立验收模板(页面级)**
|
||||
- 输出:统一验收模板(布局、文案、交互、状态、iPhone 回归、截图)
|
||||
- 验收:模板可用于每页独立签收
|
||||
|
||||
- [x] **T0-3 建立 iPad 适配基线工具函数/约定**
|
||||
- 内容:统一 `isTablet` 判定、宽度分级(560/620/680)、容器基线写法
|
||||
- 验收:至少在 1 个页面实际接入并可复用
|
||||
|
||||
## 1. 原生配置修正(黑边先决条件)
|
||||
|
||||
- [x] **T1-1 修正 iOS 目标设备族为 iPhone+iPad**
|
||||
- 文件:`client/ios/client.xcodeproj/project.pbxproj`
|
||||
- 要求:主 App target(Debug/Release)`TARGETED_DEVICE_FAMILY` 包含 `1,2`
|
||||
- 验收:iPad 运行不再是 iPhone 兼容模式
|
||||
|
||||
- [x] **T1-2 校验方向策略与产品要求一致(优先竖屏)**
|
||||
- 文件:`client/app.json`、`client/ios/client/Info.plist`
|
||||
- 要求:iPad 方向策略与“竖屏优先”一致,不引入系统级黑边
|
||||
- 验收:iPad 竖屏全屏显示稳定
|
||||
|
||||
- [ ] **T1-3 设备验证(基础冒烟)**
|
||||
- 场景:iPad 模拟器(至少 11-inch)+ iPhone 两档尺寸
|
||||
- 验收:启动页无系统黑边,应用可正常进入主流程
|
||||
|
||||
## 2. 启动链路页面适配(第一批)
|
||||
|
||||
- [x] **T2-1 适配 `app/(splash)/splash.tsx`(同意页)**
|
||||
- 范围:首屏构图、文案可读、底部按钮区、安全区
|
||||
- 要求:iPad 使用独立参数分支,iPhone 参数保持不变
|
||||
- 验收:iPad 竖屏无错位、无遮挡;iPhone 对比无回归
|
||||
|
||||
- [x] **T2-2 适配 `app/index.tsx`(启动分发页)**
|
||||
- 范围:加载态容器尺寸、跳转前视觉稳定性
|
||||
- 验收:iPad 下不闪烁、不出现异常留边
|
||||
|
||||
- [ ] **T2-3 启动链路验收与冻结**
|
||||
- 输出:iPad 改前/改后截图 + iPhone 回归截图
|
||||
- 验收:产品确认后标记“冻结”,不再改动本批页面
|
||||
|
||||
## 3. Onboarding 全流程适配(第二批)
|
||||
|
||||
- [x] **T3-1 适配 `components/onboarding/OnboardingLayout.tsx`**
|
||||
- 范围:标题区、进度区、内容最大宽度、顶部操作区
|
||||
- 验收:iPad 竖屏布局层级清晰,交互区域可达
|
||||
|
||||
- [x] **T3-2 适配 `components/onboarding/NameInputStep.tsx`**
|
||||
- 范围:输入卡片宽度、键盘抬升、底部按钮区
|
||||
- 验收:iPad 输入过程不卡位、不遮挡按钮
|
||||
|
||||
- [x] **T3-3 适配 `components/onboarding/SelectionStep.tsx`**
|
||||
- 范围:选项卡宽度/间距、滚动区底部留白、底部按钮区
|
||||
- 验收:末项可见且不被按钮覆盖
|
||||
|
||||
- [x] **T3-4 适配 `components/onboarding/ReminderStep.tsx`**
|
||||
- 范围:数字区比例、加减按钮间距、完成按钮区域
|
||||
- 验收:iPad 读数清晰,触控误触率低
|
||||
|
||||
- [x] **T3-5 适配 `app/(onboarding)/onboarding.tsx`(流程壳)**
|
||||
- 范围:步骤切换稳定性、loading 态布局一致性
|
||||
- 验收:全流程在 iPad 竖屏可连续通过
|
||||
|
||||
- [ ] **T3-6 Onboarding 批次验收与冻结**
|
||||
- 输出:逐页截图 + 关键交互录屏(可选) + iPhone 回归截图
|
||||
- 验收:确认后冻结本批页面
|
||||
|
||||
## 4. 主应用页面适配(第三批)
|
||||
|
||||
- [x] **T4-1 适配 `app/(app)/home.tsx`**
|
||||
- 范围:卡片区、顶部入口、底部操作、空/加载状态
|
||||
- 验收:iPad 信息层级清晰,无文本溢出
|
||||
|
||||
- [ ] **T4-2 适配 Home 相关弹层组件**
|
||||
- 范围:`components/home/` 下弹层、设置卡片、协议入口弹窗
|
||||
- 验收:弹层在 iPad 下尺寸与点击区合理
|
||||
|
||||
- [ ] **T4-3 适配收藏/设置相关页面与入口**
|
||||
- 范围:Favorites、Settings 及关联子组件
|
||||
- 验收:列表与信息卡在 iPad 下无拥挤/空旷失衡
|
||||
|
||||
- [ ] **T4-4 主应用批次验收与冻结**
|
||||
- 输出:关键页面截图 + 回归记录
|
||||
- 验收:产品确认后冻结
|
||||
|
||||
## 5. 边缘页面与通用组件适配(第四批)
|
||||
|
||||
- [x] **T5-1 适配边缘路由页面**
|
||||
- 范围:`modal`、`+not-found`、其他辅助页
|
||||
- 验收:iPad 下无明显样式异常
|
||||
|
||||
- [ ] **T5-2 清理固定像素高风险点**
|
||||
- 范围:扫描固定宽高/绝对定位集中区域,替换为分支参数
|
||||
- 验收:高风险点清单完成闭环
|
||||
|
||||
- [ ] **T5-3 通用组件收口**
|
||||
- 范围:复用组件(如 Sheet、按钮、卡片容器)统一 iPad 参数
|
||||
- 验收:跨页面表现一致
|
||||
|
||||
## 6. iOS Widget iPad 适配
|
||||
|
||||
- [ ] **T6-1 盘点 Widget 展示尺寸与当前问题**
|
||||
- 范围:Small/Medium/Large 在 iPad 下的展示差异
|
||||
- 验收:输出问题矩阵(文字溢出/留白/层级)
|
||||
|
||||
- [ ] **T6-2 调整 Widget 排版参数**
|
||||
- 范围:边距、字号、行高、文本截断策略
|
||||
- 验收:各尺寸均无溢出、无遮挡、层级清晰
|
||||
|
||||
- [ ] **T6-3 多语言长文案回归**
|
||||
- 范围:至少 TC/EN 长短文案样本
|
||||
- 验收:不同语言下展示稳定
|
||||
|
||||
- [ ] **T6-4 Widget 验收截图归档**
|
||||
- 输出:各尺寸截图(浅色模式必选)
|
||||
- 验收:可用于最终发布验收材料
|
||||
|
||||
## 7. 全量回归与发布前验收
|
||||
|
||||
- [ ] **T7-1 iPad 全页面走查**
|
||||
- 范围:按页面清单逐项验证布局/交互/状态
|
||||
- 验收:无 P0/P1 视觉与交互问题
|
||||
|
||||
- [ ] **T7-2 iPhone 零回归冒烟**
|
||||
- 范围:启动、onboarding、home、settings、favorites、关键弹层
|
||||
- 验收:关键路径行为与样式无回归
|
||||
|
||||
- [ ] **T7-3 输出最终覆盖矩阵与验收记录**
|
||||
- 输出:页面覆盖表、问题清单、处理结论、剩余风险
|
||||
- 验收:可直接作为发布前审阅材料
|
||||
|
||||
## 8. 收尾与文档同步(全部完成后执行)
|
||||
|
||||
- [ ] **T8-1 更新 `spec_kit/overview.md` 对应条目**
|
||||
- 要求:新增 `iPad Adaptation` 小节,记录目标、范围、阶段产物、完成状态
|
||||
- 验收:`overview.md` 可一眼看出该需求“任务已全部执行完毕”
|
||||
|
||||
- [ ] **T8-2 标记本文件完成状态**
|
||||
- 要求:`task.md` 全部条目改为 `[x]`,并在文件顶部补“已完成日期/负责人”
|
||||
- 验收:任务清单闭环
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
- ETA 发送任务(幂等:同一用户同一天同一 slot 只发一次;用户中途关闭/降次数会跳过)
|
||||
- 发送文案复用推荐模块 `scene="push"`(降风险)
|
||||
- 后端:`pytest` 全量通过(27 passed)
|
||||
- 客户端:新增通知点击消费链路,支持前后台/冷启动点击每日推荐 Push 后,将文案暂存并在进入 `home` 时优先展示
|
||||
- 后端:每日推荐 Push payload 新增 `target_screen/home_text/content_id/deep_link`,保证客户端点击通知后可恢复首页展示上下文
|
||||
|
||||
## Project Bootstrap
|
||||
|
||||
@@ -202,6 +204,9 @@
|
||||
- 后端新增内置协议内容页:
|
||||
- `GET /v1/legal/privacy`
|
||||
- `GET /v1/legal/terms`
|
||||
- `GET /v1/legal/support`(技术支持页面,提供审核可用的公开支持信息)
|
||||
- 协议展示策略调整:`/v1/legal/privacy`、`/v1/legal/terms`、`/v1/legal/support` 在携带 `Accept-Language` 时按语言单语展示(EN/TC),缺省时展示 EN + TC(EN 在前、TC 在后)
|
||||
- 协议页面语义修复:HTML 根节点 `lang` 属性不再写死,改为随页面实际语言输出(单语 en/zh-Hant;双语默认 en)
|
||||
- 客户端新增协议接口封装 `client/src/services/legalApi.ts`
|
||||
- 客户端工程化:新增统一 HTTP 封装 `client/src/utils/http.ts`(baseURL/超时/JSON/统一错误),并将 `legalApi.ts` / `recoApi.ts` 接入
|
||||
- 客户端接入两处入口:`app/(splash)/splash.tsx`、`components/home/ProfileModal.tsx`
|
||||
|
||||