From d37262876bc472552bd139d1b630d20c93cd2716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Tue, 3 Mar 2026 19:55:53 +0800 Subject: [PATCH] =?UTF-8?q?Ipad=E9=80=82=E9=85=8D=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/app/(app)/home.tsx | 67 +++++--- client/app/(app)/settings.tsx | 22 ++- client/app/(splash)/splash.tsx | 58 ++++--- client/app/+not-found.tsx | 23 ++- client/app/index.tsx | 23 ++- client/app/modal.tsx | 21 ++- client/components/home/ProfileModal.tsx | 112 ++++++++----- client/components/home/ThemeModal.tsx | 13 +- .../components/onboarding/NameInputStep.tsx | 7 +- .../onboarding/OnboardingLayout.tsx | 10 +- client/components/onboarding/ReminderStep.tsx | 7 +- .../components/onboarding/SelectionStep.tsx | 21 ++- client/components/ui/SheetModal.tsx | 21 ++- client/ios/client.xcodeproj/project.pbxproj | 33 ++-- client/ios/client/Info.plist | 8 +- client/ios/情绪小组件/EmotionWidget.swift | 4 +- client/src/utils/device.ts | 9 ++ spec_kit/iPad Adaptation/plan.md | 137 ++++++++++++++++ spec_kit/iPad Adaptation/spec.md | 92 +++++++++++ spec_kit/iPad Adaptation/task.md | 151 ++++++++++++++++++ 20 files changed, 692 insertions(+), 147 deletions(-) create mode 100644 client/src/utils/device.ts create mode 100644 spec_kit/iPad Adaptation/plan.md create mode 100644 spec_kit/iPad Adaptation/spec.md create mode 100644 spec_kit/iPad Adaptation/task.md diff --git a/client/app/(app)/home.tsx b/client/app/(app)/home.tsx index 97609ae..ddefc23 100644 --- a/client/app/(app)/home.tsx +++ b/client/app/(app)/home.tsx @@ -2,13 +2,13 @@ import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } fr import { StyleSheet, View, - Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground, Platform, + useWindowDimensions, } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useFocusEffect } from 'expo-router'; @@ -57,8 +57,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 +95,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); @@ -248,8 +248,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'), }; @@ -659,6 +659,10 @@ export default function HomeScreen() { } } + const actionsBottom = isTablet + ? Math.max(insets.bottom + 36, Math.min(windowHeight * 0.12, 140)) + : windowHeight * 0.16; + return ( {themeMode === 'scenery' && ( @@ -670,7 +674,15 @@ export default function HomeScreen() { )} {/* 自绘顶部按钮:不使用系统 Header,彻底避免 iOS 导航栏的毛玻璃/液玻璃材质 */} - + setThemeOpen(true)} accessibilityLabel={t('home.theme')} @@ -685,19 +697,21 @@ export default function HomeScreen() { - { - const w = e.nativeEvent.layout.width; - if (Number.isFinite(w) && w > 0) setCardWidth(w); - }} - > - - {wrappedText || item.text} - + + { + const w = e.nativeEvent.layout.width; + if (Number.isFinite(w) && w > 0) setCardWidth(w); + }} + > + + {wrappedText || item.text} + + - + + {t('settings.version')} {version} @@ -21,12 +26,18 @@ export default function SettingsScreen() { {t('settings.widgetTitle')} {t('settings.widgetDesc')} + ); } 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, diff --git a/client/app/(splash)/splash.tsx b/client/app/(splash)/splash.tsx index 129f329..ce56923 100644 --- a/client/app/(splash)/splash.tsx +++ b/client/app/(splash)/splash.tsx @@ -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 ( {/* 中间的背景装饰 SVG (现在放在上面,作为上层) */} - - + + {/* 顶部的花图片 (现在放在下面,作为下层) */} - + {/* 文案内容:主標題兩行 + 可選二級標題(字號更小、顏色更淺);繁中為元件內常數,其餘用 i18n */} - - + + {title} {'\n'} {subtitle} {subtitleSecondary ? ( - {subtitleSecondary} + {subtitleSecondary} ) : null} - + {showConsent && ( <> - + - + void handleOpenLegal('privacy')} suppressHighlighting /> ), terms: ( 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, }, diff --git a/client/app/+not-found.tsx b/client/app/+not-found.tsx index ffb5643..56e2694 100644 --- a/client/app/+not-found.tsx +++ b/client/app/+not-found.tsx @@ -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 ( <> - This screen doesn't exist. + + This screen doesn't exist. - - Go to home screen! - + + Go to home screen! + + ); @@ -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, diff --git a/client/app/index.tsx b/client/app/index.tsx index 869780e..1ea2643 100644 --- a/client/app/index.tsx +++ b/client/app/index.tsx @@ -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 ( - + + + ); } 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', + }, }); diff --git a/client/app/modal.tsx b/client/app/modal.tsx index fcedb7a..15aabc3 100644 --- a/client/app/modal.tsx +++ b/client/app/modal.tsx @@ -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 ( - Modal - - + + Modal + + + {/* Use a light status bar on iOS to account for the black space above the modal */} @@ -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, diff --git a/client/components/home/ProfileModal.tsx b/client/components/home/ProfileModal.tsx index 200d0bf..da81e32 100644 --- a/client/components/home/ProfileModal.tsx +++ b/client/components/home/ProfileModal.tsx @@ -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('root'); const [navDirection, setNavDirection] = useState('forward'); @@ -192,6 +197,7 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props {page === 'root' ? ( 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' ? ( - + ) : page === 'dailyReminder' ? ( - go('root', 'back')} /> + go('root', 'back')} contentWidth={contentWidth} /> ) : page === 'language' ? ( - + ) : page === 'widgetHowTo' ? ( - + ) : ( - go('widgetHowTo', 'forward')} /> + go('widgetHowTo', 'forward')} widgetImageWidth={widgetImageWidth} /> )} @@ -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 ( - <> + {name || 'Hali'} @@ -276,11 +284,23 @@ function RootPage({ onPress={onOpenLanguage} /> - + + V1.0.0 + ); } -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 ( - + {favorites.length === 0 ? ( {t('favorites.empty')} ) : ( @@ -339,6 +359,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) { {item.themeMode === 'scenery' ? ( @@ -346,7 +367,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) { 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: () = - + @@ -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 }) { {showLockScreenWidget ? ( - + {t('widget.lockScreen')} ) : null} - + {t('widget.homeScreen')} @@ -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(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 }) => ( - - + + {item.desc} )} /> - + {images.map((_, i) => ( - + + {languages.map((lang, index) => ( = 768; + return ( - + = 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 ( - + {/* 显示层:文案 + 跟随的光标 */} diff --git a/client/components/onboarding/OnboardingLayout.tsx b/client/components/onboarding/OnboardingLayout.tsx index a20d685..f2ddc64 100644 --- a/client/components/onboarding/OnboardingLayout.tsx +++ b/client/components/onboarding/OnboardingLayout.tsx @@ -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({ {/* Header: Back & Skip */} - + {showBackButton && onBack && ( @@ -94,7 +97,7 @@ export function OnboardingLayout({ {/* Title & Progress Row(名字步骤后第一步且名字非空时显示招呼语 + 问题) */} - + {showGreeting && ( {t('onboardingSurvey.greeting', { name: displayName })} @@ -108,6 +111,7 @@ export function OnboardingLayout({ = 768; + const contentMaxWidth = isTablet ? 560 : undefined; const handleReduce = () => { // 本页最小为 1;不接收提醒请使用右上角 Skip @@ -32,7 +35,7 @@ export function ReminderStep({ value, onChange, onFinish, loading = false }: Rem return ( - + diff --git a/client/components/onboarding/SelectionStep.tsx b/client/components/onboarding/SelectionStep.tsx index 2b9e989..64136ae 100644 --- a/client/components/onboarding/SelectionStep.tsx +++ b/client/components/onboarding/SelectionStep.tsx @@ -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 } {options.map((option) => { const isSelected = selectedIds.includes(option.id); return ( onToggle(option.id)} activeOpacity={0.7} > diff --git a/client/components/ui/SheetModal.tsx b/client/components/ui/SheetModal.tsx index 67db110..138b670 100644 --- a/client/components/ui/SheetModal.tsx +++ b/client/components/ui/SheetModal.tsx @@ -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, diff --git a/client/ios/client.xcodeproj/project.pbxproj b/client/ios/client.xcodeproj/project.pbxproj index 11260de..17cea76 100644 --- a/client/ios/client.xcodeproj/project.pbxproj +++ b/client/ios/client.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = ""; }; - A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = ""; }; A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = ""; }; A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = ""; }; @@ -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 = ""; - }; + EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = ""; }; /* 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 = ""; @@ -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; }; diff --git a/client/ios/client/Info.plist b/client/ios/client/Info.plist index da47e68..9b2806b 100644 --- a/client/ios/client/Info.plist +++ b/client/ios/client/Info.plist @@ -38,8 +38,6 @@ 12.0 LSRequiresIPhoneOS - NSLocalNetworkUsageDescription - 用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。 NSAppTransportSecurity NSAllowsArbitraryLoads @@ -47,6 +45,8 @@ NSAllowsLocalNetworking + NSLocalNetworkUsageDescription + 用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。 NSUserActivityTypes $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route @@ -61,6 +61,8 @@ UIRequiresFullScreen + UIStatusBarHidden + UIStatusBarStyle UIStatusBarStyleDefault UISupportedInterfaceOrientations @@ -72,8 +74,6 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight UIUserInterfaceStyle Automatic diff --git a/client/ios/情绪小组件/EmotionWidget.swift b/client/ios/情绪小组件/EmotionWidget.swift index a03fde9..3bea281 100644 --- a/client/ios/情绪小组件/EmotionWidget.swift +++ b/client/ios/情绪小组件/EmotionWidget.swift @@ -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) } diff --git a/client/src/utils/device.ts b/client/src/utils/device.ts new file mode 100644 index 0000000..9d9c01b --- /dev/null +++ b/client/src/utils/device.ts @@ -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)); +} diff --git a/spec_kit/iPad Adaptation/plan.md b/spec_kit/iPad Adaptation/plan.md new file mode 100644 index 0000000..3bbce4f --- /dev/null +++ b/spec_kit/iPad Adaptation/plan.md @@ -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 与页面稳定性。 + **应对**:统一截断/换行规则,增加长文案样本回归。 + diff --git a/spec_kit/iPad Adaptation/spec.md b/spec_kit/iPad Adaptation/spec.md new file mode 100644 index 0000000..9a30e69 --- /dev/null +++ b/spec_kit/iPad Adaptation/spec.md @@ -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 零回归确认 + 发布前验收。 + diff --git a/spec_kit/iPad Adaptation/task.md b/spec_kit/iPad Adaptation/task.md new file mode 100644 index 0000000..4337e58 --- /dev/null +++ b/spec_kit/iPad Adaptation/task.md @@ -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]`,并在文件顶部补“已完成日期/负责人” + - 验收:任务清单闭环 +