diff --git a/frontend/src/features/reminder/presentation/PermissionOnboardingScreen.tsx b/frontend/src/features/reminder/presentation/PermissionOnboardingScreen.tsx index 3b34893f..1956468b 100644 --- a/frontend/src/features/reminder/presentation/PermissionOnboardingScreen.tsx +++ b/frontend/src/features/reminder/presentation/PermissionOnboardingScreen.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { DeviceCapabilityPort, @@ -109,6 +110,7 @@ export function PermissionOnboardingScreen({ readonly onContinue: () => void; readonly onPermissionsUpdated: () => void; }) { + const insets = useSafeAreaInsets(); const [status, setStatus] = useState(null); const [busyPermission, setBusyPermission] = useState(null); const [busyOemKind, setBusyOemKind] = useState<'autostart' | 'backgroundPopup' | null>(null); @@ -179,8 +181,12 @@ export function PermissionOnboardingScreen({ }); return ( - - + + 需要这些权限 点击每一项单独开启;通知是必需的,其余可以先跳过,用到对应功能时会再提醒你。 @@ -198,7 +204,7 @@ export function PermissionOnboardingScreen({ style={[styles.row, highlighted ? styles.rowHighlighted : null]} testID={`permission-row-${row.permission}`} > - + {row.title} {row.required ? 必需 : null} @@ -272,7 +278,10 @@ export function PermissionOnboardingScreen({ ) : null} - + {!notificationsGranted ? ( 需要先开启通知权限才能进入 ) : null} @@ -298,6 +307,7 @@ const styles = StyleSheet.create({ actionButton: { backgroundColor: colors.text, borderRadius: 8, + flexShrink: 0, minWidth: 84, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, @@ -389,9 +399,11 @@ const styles = StyleSheet.create({ rowText: { flex: 1, gap: spacing.xs, + minWidth: 0, }, rowTitle: { color: colors.text, + flexShrink: 1, fontSize: 15, fontWeight: '700', }, @@ -414,6 +426,9 @@ const styles = StyleSheet.create({ backgroundColor: colors.background, flex: 1, }, + scroll: { + flex: 1, + }, subtitle: { color: colors.mutedText, }, diff --git a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx index 3225a869..c005ba0d 100644 --- a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx @@ -6,12 +6,13 @@ import { ScrollView, StyleSheet, Text, + useWindowDimensions, View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Svg, { Path } from 'react-native-svg'; -import { floatingVoiceContentBottomInset } from '../../../shared/ui/floatingVoiceBarLayout'; +import { floatingVoiceViewportBottomInset } from '../../../shared/ui/floatingVoiceBarLayout'; import { colors, spacing } from '../../../shared/ui/theme'; import type { ScheduleCalendarReadService, ScheduleOccurrenceView } from '../application'; import { LocationScheduleDetailSheet } from './LocationScheduleDetailSheet'; @@ -65,12 +66,14 @@ export function ScheduleCalendarScreen({ ); const [selectedOccurrenceKey, setSelectedOccurrenceKey] = useState(null); const [selectedLocationId, setSelectedLocationId] = useState(null); + const { fontScale, width } = useWindowDimensions(); const selectedOccurrence = calendar.selectedOccurrences.find((item) => occurrenceKey(item) === selectedOccurrenceKey) ?? null; const selectedLocation = calendar.locationSchedules.find((item) => item.scheduleId === selectedLocationId) ?? null; const topSafeAreaPadding = Platform.OS === 'android' ? insets.top : 0; + const stackHeader = shouldStackScheduleHeader(width, fontScale); const selectedLabel = SELECTED_DATE_FORMATTER.format(calendar.selectedDate); const agendaTitle = formatAgendaSectionTitle(calendar.selectedDate); const emptyAgenda = emptyAgendaMessage(calendar.selectedDate); @@ -78,26 +81,30 @@ export function ScheduleCalendarScreen({ const avatarInitial = Array.from(displayUsername)[0]?.toLocaleUpperCase() ?? '用'; return ( - + - - + + {selectedLabel} - + ({ + useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }), +})); + jest.mock('../../../src/infrastructure/database', () => ({ openTimeflowDatabase: jest .fn<() => Promise>() diff --git a/frontend/tests/unit/features/reminder/presentation/PermissionOnboardingScreen.test.tsx b/frontend/tests/unit/features/reminder/presentation/PermissionOnboardingScreen.test.tsx index 49cd177d..68056fca 100644 --- a/frontend/tests/unit/features/reminder/presentation/PermissionOnboardingScreen.test.tsx +++ b/frontend/tests/unit/features/reminder/presentation/PermissionOnboardingScreen.test.tsx @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { StyleSheet } from 'react-native'; import type { DeviceCapabilityPort, @@ -8,6 +9,13 @@ import type { } from '../../../../../src/features/reminder/application/interfaces'; import { PermissionOnboardingScreen } from '../../../../../src/features/reminder/presentation/PermissionOnboardingScreen'; +let mockBottomInset = 0; +let mockTopInset = 0; + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: mockBottomInset, left: 0, right: 0, top: mockTopInset }), +})); + function grantedPermissions(): Record { return { notifications: true, @@ -91,6 +99,56 @@ describe('PermissionOnboardingScreen', () => { afterEach(() => { jest.useRealTimers(); + mockBottomInset = 0; + mockTopInset = 0; + }); + + it('keeps the list between the display cutout and gesture navigation areas', async () => { + mockTopInset = 24; + mockBottomInset = 20; + const device = createDevice(deniedPermissions()); + + render( + , + ); + await waitFor(() => expect(device.getStatus).toHaveBeenCalled()); + + expect( + StyleSheet.flatten(screen.getByTestId('permission-onboarding-screen').props.style), + ).toMatchObject({ paddingTop: 24 }); + expect( + StyleSheet.flatten(screen.getByTestId('permission-list-scroll').props.style), + ).toMatchObject({ flex: 1 }); + expect(StyleSheet.flatten(screen.getByTestId('permission-footer').props.style)).toMatchObject({ + paddingBottom: 36, + }); + }); + + it('lets permission copy shrink without compressing its action', async () => { + const device = createDevice(deniedPermissions()); + + render( + , + ); + await waitFor(() => expect(device.getStatus).toHaveBeenCalled()); + + expect( + StyleSheet.flatten(screen.getByTestId('permission-copy-location_background').props.style), + ).toMatchObject({ flex: 1, minWidth: 0 }); + expect( + StyleSheet.flatten(screen.getByTestId('permission-action-location_background').props.style), + ).toMatchObject({ flexShrink: 0, minWidth: 84 }); + expect(StyleSheet.flatten(screen.getByText('后台定位(始终允许)').props.style)).toMatchObject({ + flexShrink: 1, + }); }); it('shows every permission row with its current status', async () => { diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index 7d69e470..30357748 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -7,14 +7,22 @@ import type { ScheduleOccurrenceView, } from '../../../../../src/features/schedule/application'; import { ScheduleCalendarScreen } from '../../../../../src/features/schedule/presentation/ScheduleCalendarScreen'; +import * as ScheduleCalendarScreenModule from '../../../../../src/features/schedule/presentation/ScheduleCalendarScreen'; let mockBottomInset = 0; let mockTopInset = 0; +let mockFontScale = 1; +let mockWindowWidth = 430; jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: mockBottomInset, left: 0, right: 0, top: mockTopInset }), })); +jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + __esModule: true, + default: () => ({ fontScale: mockFontScale, height: 932, scale: 1, width: mockWindowWidth }), +})); + function occurrenceOnSelectedDay( hourUtc: number, overrides: Partial = {}, @@ -67,6 +75,8 @@ function createService( describe('ScheduleCalendarScreen location schedules', () => { beforeEach(() => { + mockFontScale = 1; + mockWindowWidth = 430; Platform.OS = 'ios'; }); @@ -116,11 +126,14 @@ describe('ScheduleCalendarScreen location schedules', () => { ); await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalled()); + expect( + StyleSheet.flatten(screen.getByTestId('schedule-calendar-scroll').props.style), + ).toMatchObject({ marginBottom: 118 }); expect( StyleSheet.flatten( screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle, ), - ).toMatchObject({ paddingBottom: 118, paddingTop: 0 }); + ).toMatchObject({ paddingBottom: 24 }); fireEvent.press(screen.getByLabelText(/月13日$/)); const lastRow = await screen.findByLabelText(/当日最后一条日程$/); @@ -147,10 +160,15 @@ describe('ScheduleCalendarScreen location schedules', () => { await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalled()); expect( - StyleSheet.flatten( - screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle, - ), + StyleSheet.flatten(screen.getByTestId('schedule-calendar-screen').props.style), ).toMatchObject({ paddingTop: 24 }); + expect( + StyleSheet.flatten(screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle) + .paddingTop, + ).toBeUndefined(); + expect( + screen.getByTestId('schedule-calendar-scroll').props.contentInsetAdjustmentBehavior, + ).toBe('never'); }); it('leaves iOS to its own automatic safe-area adjustment instead of double-padding the top', async () => { @@ -170,10 +188,11 @@ describe('ScheduleCalendarScreen location schedules', () => { await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalled()); expect( - StyleSheet.flatten( - screen.getByTestId('schedule-calendar-scroll').props.contentContainerStyle, - ), + StyleSheet.flatten(screen.getByTestId('schedule-calendar-screen').props.style), ).toMatchObject({ paddingTop: 0 }); + expect( + screen.getByTestId('schedule-calendar-scroll').props.contentInsetAdjustmentBehavior, + ).toBe('automatic'); }); it('keeps accountId in the calendar data flow without rendering it', async () => { @@ -259,6 +278,7 @@ describe('ScheduleCalendarScreen location schedules', () => { }); it('keeps the selected date fully visible when the header is narrow', async () => { + mockWindowWidth = 360; const service = createService(); render( { ); await waitFor(() => expect(service.getSchedulesByRange).toHaveBeenCalled()); + const shouldStackScheduleHeader = ( + ScheduleCalendarScreenModule as typeof ScheduleCalendarScreenModule & { + shouldStackScheduleHeader?: (width: number, fontScale: number) => boolean; + } + ).shouldStackScheduleHeader; + expect(shouldStackScheduleHeader).toBeDefined(); + expect(shouldStackScheduleHeader?.(0, 1)).toBe(false); + expect(shouldStackScheduleHeader?.(320, 1)).toBe(true); + expect(shouldStackScheduleHeader?.(360, 1)).toBe(true); + expect(shouldStackScheduleHeader?.(393, 1)).toBe(true); + expect(shouldStackScheduleHeader?.(430, 1)).toBe(false); + expect(shouldStackScheduleHeader?.(430, 1.3)).toBe(true); + expect(shouldStackScheduleHeader?.(430, 1.5)).toBe(true); const selectedDate = screen.getByTestId('schedule-selected-date'); expect(selectedDate.props.children).toBeTruthy(); expect(selectedDate.props.numberOfLines).toBeUndefined(); - expect(selectedDate.props.style).toMatchObject({ flex: 1, flexShrink: 1, minWidth: 0 }); + expect(StyleSheet.flatten(selectedDate.props.style)).toMatchObject({ + flex: 0, + flexShrink: 1, + minWidth: 0, + width: '100%', + }); + expect( + StyleSheet.flatten(screen.getByTestId('schedule-account-actions').props.style), + ).toMatchObject({ alignSelf: 'flex-end', marginLeft: 0 }); }); it('opens permissions when the user pill is pressed', async () => {