Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -109,6 +110,7 @@ export function PermissionOnboardingScreen({
readonly onContinue: () => void;
readonly onPermissionsUpdated: () => void;
}) {
const insets = useSafeAreaInsets();
const [status, setStatus] = useState<DeviceCapabilityStatus | null>(null);
const [busyPermission, setBusyPermission] = useState<DevicePermission | null>(null);
const [busyOemKind, setBusyOemKind] = useState<'autostart' | 'backgroundPopup' | null>(null);
Expand Down Expand Up @@ -179,8 +181,12 @@ export function PermissionOnboardingScreen({
});

return (
<View style={styles.screen}>
<ScrollView contentContainerStyle={styles.content}>
<View style={[styles.screen, { paddingTop: insets.top }]} testID="permission-onboarding-screen">
<ScrollView
contentContainerStyle={styles.content}
style={styles.scroll}
testID="permission-list-scroll"
>
<Text style={styles.title}>需要这些权限</Text>
<Text style={styles.subtitle}>
点击每一项单独开启;通知是必需的,其余可以先跳过,用到对应功能时会再提醒你。
Expand All @@ -198,7 +204,7 @@ export function PermissionOnboardingScreen({
style={[styles.row, highlighted ? styles.rowHighlighted : null]}
testID={`permission-row-${row.permission}`}
>
<View style={styles.rowText}>
<View style={styles.rowText} testID={`permission-copy-${row.permission}`}>
<View style={styles.rowTitleLine}>
<Text style={styles.rowTitle}>{row.title}</Text>
{row.required ? <Text style={styles.requiredBadge}>必需</Text> : null}
Expand Down Expand Up @@ -272,7 +278,10 @@ export function PermissionOnboardingScreen({
</>
) : null}
</ScrollView>
<View style={styles.footer}>
<View
style={[styles.footer, { paddingBottom: Math.max(spacing.lg, insets.bottom + spacing.md) }]}
testID="permission-footer"
>
{!notificationsGranted ? (
<Text style={styles.footerHint}>需要先开启通知权限才能进入</Text>
) : null}
Expand All @@ -298,6 +307,7 @@ const styles = StyleSheet.create({
actionButton: {
backgroundColor: colors.text,
borderRadius: 8,
flexShrink: 0,
minWidth: 84,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
Expand Down Expand Up @@ -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',
},
Expand All @@ -414,6 +426,9 @@ const styles = StyleSheet.create({
backgroundColor: colors.background,
flex: 1,
},
scroll: {
flex: 1,
},
subtitle: {
color: colors.mutedText,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -65,39 +66,45 @@ export function ScheduleCalendarScreen({
);
const [selectedOccurrenceKey, setSelectedOccurrenceKey] = useState<string | null>(null);
const [selectedLocationId, setSelectedLocationId] = useState<string | null>(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);
const displayUsername = username.trim() || '用户';
const avatarInitial = Array.from(displayUsername)[0]?.toLocaleUpperCase() ?? '用';

return (
<View style={styles.screen}>
<View
style={[styles.screen, { paddingTop: topSafeAreaPadding }]}
testID="schedule-calendar-screen"
>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{
paddingBottom: floatingVoiceContentBottomInset(insets.bottom),
paddingTop: topSafeAreaPadding,
},
]}
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={styles.scrollContent}
contentInsetAdjustmentBehavior={Platform.OS === 'ios' ? 'automatic' : 'never'}
showsVerticalScrollIndicator={false}
style={{ marginBottom: floatingVoiceViewportBottomInset(insets.bottom) }}
testID="schedule-calendar-scroll"
>
<View style={styles.content}>
<View style={styles.header}>
<View style={styles.headerTop}>
<Text testID="schedule-selected-date" style={styles.title}>
<View style={[styles.headerTop, stackHeader && styles.headerTopStacked]}>
<Text
testID="schedule-selected-date"
style={[styles.title, stackHeader && styles.titleStacked]}
>
{selectedLabel}
</Text>
<View style={styles.accountActions} testID="schedule-account-actions">
<View
style={[styles.accountActions, stackHeader && styles.accountActionsStacked]}
testID="schedule-account-actions"
>
<Pressable
accessibilityLabel={`当前用户 ${displayUsername},点击查看权限设置`}
accessibilityRole="button"
Expand Down Expand Up @@ -221,6 +228,12 @@ export function ScheduleCalendarScreen({
);
}

export function shouldStackScheduleHeader(width: number, fontScale: number): boolean {
if (width <= 0) return false;
const effectiveWidth = width / Math.max(fontScale, 1);
return effectiveWidth < 400;
}

function occurrenceKey(item: ScheduleOccurrenceView): string {
return `${item.scheduleId}\u0000${item.occurrenceStart ?? ''}`;
}
Expand Down Expand Up @@ -252,6 +265,11 @@ const styles = StyleSheet.create({
maxWidth: 168,
minWidth: 0,
},
accountActionsStacked: {
alignSelf: 'flex-end',
flexShrink: 0,
marginLeft: 0,
},
agenda: { paddingHorizontal: spacing.md, paddingTop: spacing.xl },
avatar: {
alignItems: 'center',
Expand Down Expand Up @@ -306,6 +324,11 @@ const styles = StyleSheet.create({
minWidth: 0,
width: '100%',
},
headerTopStacked: {
alignItems: 'stretch',
flexDirection: 'column',
gap: spacing.sm,
},
locationSection: {
borderTopColor: colors.border,
borderTopWidth: StyleSheet.hairlineWidth,
Expand Down Expand Up @@ -351,6 +374,10 @@ const styles = StyleSheet.create({
minWidth: 0,
flexShrink: 1,
},
titleStacked: {
flex: 0,
width: '100%',
},
userPill: {
alignItems: 'center',
backgroundColor: colors.surface,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/shared/ui/floatingVoiceBarLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ export function floatingVoiceBarBottomOffset(bottomInset: number): number {
return Math.max(spacing.xl, bottomInset + spacing.md);
}

export function floatingVoiceContentBottomInset(bottomInset: number): number {
export function floatingVoiceViewportBottomInset(bottomInset: number): number {
return floatingVoiceBarBottomOffset(bottomInset) + FLOATING_VOICE_BAR_HEIGHT + spacing.md;
}
4 changes: 4 additions & 0 deletions frontend/tests/unit/app/AppRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import type {
import { FakeAuthSessionStore } from '../../fakes/FakeAuthSessionStore';
import { openTimeflowDatabase } from '../../../src/infrastructure/database';

jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 }),
}));

jest.mock('../../../src/infrastructure/database', () => ({
openTimeflowDatabase: jest
.fn<() => Promise<unknown>>()
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<DevicePermission, boolean> {
return {
notifications: true,
Expand Down Expand Up @@ -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(
<PermissionOnboardingScreen
device={device}
onContinue={jest.fn()}
onPermissionsUpdated={jest.fn()}
/>,
);
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(
<PermissionOnboardingScreen
device={device}
onContinue={jest.fn()}
onPermissionsUpdated={jest.fn()}
/>,
);
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 () => {
Expand Down
Loading
Loading