Skip to content
Open
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
Expand Up @@ -118,20 +118,10 @@ export function AssistantVoiceOverlay({
}

return (
<View pointerEvents="box-none" style={StyleSheet.absoluteFill}>
{/* 铺满全屏的蒙层:点气泡和长条以外的任何地方(包括上面的日历区域)都会
命中这层,因为它渲染在长条之前、又是 box-none 容器唯一会拦截"空白处"
点击的兜底。 */}
{ptt.replyText ? (
<Pressable
accessibilityLabel="关闭回复"
onPress={ptt.dismissReply}
style={StyleSheet.absoluteFill}
/>
) : null}
<View style={styles.container}>
<View pointerEvents="box-none" style={styles.overlay}>
{ptt.replyText ? (
<Pressable onPress={() => {}} style={styles.bubble}>
<Pressable onPress={ptt.dismissReply} style={styles.bubble}>
<Text style={styles.bubbleText}>{ptt.replyText}</Text>
</Pressable>
) : null}
Expand Down Expand Up @@ -174,6 +164,15 @@ const styles = StyleSheet.create({
gap: spacing.sm,
width: '100%',
},
container: {
backgroundColor: colors.surface,
borderTopColor: colors.border,
borderTopWidth: StyleSheet.hairlineWidth,
paddingBottom: spacing.md,
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm,
width: '100%',
},
bubble: {
backgroundColor: colors.surface,
borderColor: colors.border,
Expand Down Expand Up @@ -203,10 +202,6 @@ const styles = StyleSheet.create({
},
overlay: {
alignItems: 'center',
bottom: spacing.xl,
left: 0,
paddingHorizontal: spacing.lg,
position: 'absolute',
right: 0,
width: '100%',
},
});
10 changes: 6 additions & 4 deletions frontend/src/features/assistant/presentation/PushToTalkBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,10 @@ function normalizeLevel(dbfs: number | null): number {
const styles = StyleSheet.create({
bar: {
alignItems: 'center',
backgroundColor: colors.text,
borderRadius: 999,
backgroundColor: colors.input,
borderColor: colors.border,
borderRadius: 14,
borderWidth: 1,
flex: 1,
height: 52,
justifyContent: 'center',
Expand All @@ -117,12 +119,12 @@ const styles = StyleSheet.create({
opacity: 0.86,
},
label: {
color: colors.onPrimary,
color: colors.text,
fontSize: 14,
fontWeight: '600',
},
labelDisabled: {
color: colors.onPrimary,
color: colors.mutedText,
},
wave: {
alignItems: 'center',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { MonthCalendar } from './MonthCalendar';
import { ScheduleOccurrenceDetailSheet } from './ScheduleOccurrenceDetailSheet';
import { ScheduleOccurrenceRow } from './ScheduleOccurrenceRow';
import { useScheduleCalendar } from './useScheduleCalendar';
import type { CalendarFocusTarget } from './calendarFocus';

const SELECTED_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
day: 'numeric',
Expand All @@ -30,6 +31,7 @@ interface ScheduleCalendarScreenProps {
isSigningOut?: boolean;
/** 外部触发刷新用(比如语音写完一条日程);变化即重取,不用管具体数值。 */
refreshSignal?: number;
focusTarget?: CalendarFocusTarget | null;
}

export function ScheduleCalendarScreen({
Expand All @@ -40,8 +42,16 @@ export function ScheduleCalendarScreen({
onSignOut,
isSigningOut = false,
refreshSignal,
focusTarget,
}: ScheduleCalendarScreenProps) {
const calendar = useScheduleCalendar(service, accountId, timezone, undefined, refreshSignal);
const calendar = useScheduleCalendar(
service,
accountId,
timezone,
undefined,
refreshSignal,
focusTarget,
);
const [selectedOccurrence, setSelectedOccurrence] = useState<ScheduleOccurrenceView | null>(null);
const [selectedLocation, setSelectedLocation] = useState<LocationScheduleView | null>(null);
const selectedLabel = SELECTED_DATE_FORMATTER.format(calendar.selectedDate);
Expand Down Expand Up @@ -276,7 +286,7 @@ const styles = StyleSheet.create({
},
retryText: { color: colors.onPrimary, fontWeight: '700' },
screen: { backgroundColor: colors.background, flex: 1 },
scrollContent: { paddingBottom: spacing.xxl * 3 },
scrollContent: { paddingBottom: spacing.lg },
sectionCount: { color: colors.mutedText, fontSize: 12, fontWeight: '600' },
sectionEyebrow: { color: colors.mutedText, fontSize: 12, fontWeight: '600', marginBottom: 3 },
sectionHeader: {
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/features/schedule/presentation/calendarFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { AppliedCommand } from '../../assistant/domain/ConversationTurn';

export interface CalendarFocusTarget {
scheduleId: string;
kind: 'time' | 'location';
recurrenceMode: 'once' | 'recurring' | null;
recurrenceRule: string | null;
startTime: string | null;
timezone: string | null;
}

export function calendarFocusTargetFromCommand(
command: AppliedCommand | null,
): CalendarFocusTarget | null {
if (command === null || command.status !== 'applied' || command.operation !== 'create_schedule') {
return null;
}
const raw = command.schedule;
if (raw === undefined || typeof raw.id !== 'string' || raw.id.length === 0) return null;

const kind =
raw.schedule_type === 'location' || typeof raw.start_time !== 'string' ? 'location' : 'time';
const recurrenceMode =
raw.schedule_kind === 'recurring' ? 'recurring' : raw.schedule_kind === 'once' ? 'once' : null;

return {
kind,
recurrenceMode,
recurrenceRule: typeof raw.recurrence_rule === 'string' ? raw.recurrence_rule : null,
scheduleId: raw.id,
startTime: typeof raw.start_time === 'string' ? raw.start_time : null,
timezone: typeof raw.timezone === 'string' ? raw.timezone : null,
};
}
104 changes: 104 additions & 0 deletions frontend/src/features/schedule/presentation/useScheduleCalendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@ import type {
ScheduleCalendarReadService,
ScheduleOccurrenceView,
} from '../application';
import {
floatingDateToLocalParts,
instantToZonedParts,
localPartsToFloatingDate,
zonedPartsToInstant,
} from '../domain/scheduleDateTime';
import {
normalizeUtcUntilForFloatingRrule,
parseScheduleRrule,
} from '../domain/scheduleRecurrence';
import { addDays, dateKey, dateKeyInTimezone, startOfMonth } from './scheduleDisplay';
import type { CalendarFocusTarget } from './calendarFocus';

export interface ScheduleCalendarState {
selectedDate: Date;
Expand Down Expand Up @@ -34,6 +45,7 @@ export function useScheduleCalendar(
initialDate = new Date(),
/** 外部触发刷新用(比如语音写完一条日程);变化时跟 retry() 走同一条重取路径。 */
refreshSignal = 0,
focusTarget?: CalendarFocusTarget | null,
): ScheduleCalendarState {
const [selectedDate, setSelectedDate] = useState(() => initialDate);
const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(initialDate));
Expand All @@ -47,6 +59,45 @@ export function useScheduleCalendar(
const [locationsError, setLocationsError] = useState<string | null>(null);
const [reloadToken, setReloadToken] = useState(0);

const focusKey = focusTarget
? `${focusTarget.scheduleId}:${focusTarget.kind}:${focusTarget.recurrenceMode}:${focusTarget.startTime ?? ''}`
: null;

useEffect(() => {
if (focusTarget === undefined || focusTarget === null || focusTarget.kind === 'location') {
return;
}

let cancelled = false;
const applyDate = (date: Date) => {
if (cancelled) return;
setSelectedDate(date);
setVisibleMonth(startOfMonth(date));
};

if (focusTarget.recurrenceMode !== 'recurring') {
const dateKeyForTarget = focusTarget.startTime
? dateKeyInTimezone(focusTarget.startTime, timezone)
: null;
const date = dateKeyForTarget ? parseDateKey(dateKeyForTarget) : null;
if (date !== null) applyDate(date);
return () => {
cancelled = true;
};
}

void findNextOccurrenceDate(service, accountId, timezone, focusTarget)
.then((date) => {
if (date !== null) applyDate(date);
})
.catch(() => {
if (!cancelled) setOccurrencesError('日程加载失败,请重试');
});
return () => {
cancelled = true;
};
}, [accountId, focusKey, focusTarget, refreshSignal, service, timezone]);

useEffect(() => {
let cancelled = false;
const dates = monthGridDates(visibleMonth);
Expand Down Expand Up @@ -162,3 +213,56 @@ export function useScheduleCalendar(
retry,
};
}

function parseDateKey(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return null;
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return dateKey(date) === value ? date : null;
}

async function findNextOccurrenceDate(
service: ScheduleCalendarReadService,
accountId: string,
timezone: string,
target: CalendarFocusTarget,
): Promise<Date | null> {
const now = new Date();
const scheduleTimezone = target.timezone ?? timezone;
if (target.startTime === null || target.recurrenceRule === null) return null;
const scheduleStart = new Date(target.startTime);
if (Number.isNaN(scheduleStart.getTime())) return null;
const floatingStart = localPartsToFloatingDate(
instantToZonedParts(scheduleStart, scheduleTimezone),
);
const floatingNow = localPartsToFloatingDate(instantToZonedParts(now, scheduleTimezone));
const rule = parseScheduleRrule(
normalizeUtcUntilForFloatingRrule(target.recurrenceRule, scheduleTimezone),
floatingStart,
);
const nextFloating = rule.after(floatingNow, false);
if (nextFloating === null) return null;
const nextInstant = zonedPartsToInstant(floatingDateToLocalParts(nextFloating), scheduleTimezone);
const startKey = dateKeyInTimezone(nextInstant.toISOString(), timezone);
const startDate = startKey ? parseDateKey(startKey) : null;
if (startDate === null || startKey === null) return null;
const occurrences = await service.getSchedulesByRange({
accountId,
endDate: dateKey(addDays(startDate, 1)),
startDate: startKey,
timezone,
});
const next = occurrences
.filter(
(occurrence) =>
occurrence.scheduleId === target.scheduleId &&
occurrence.occurrenceStart !== null &&
new Date(occurrence.occurrenceStart).getTime() >= now.getTime(),
)
.sort(
(left, right) =>
new Date(left.occurrenceStart!).getTime() - new Date(right.occurrenceStart!).getTime(),
)[0];
const nextKey = next?.occurrenceStart ? dateKeyInTimezone(next.occurrenceStart, timezone) : null;
return nextKey ? parseDateKey(nextKey) : null;
}
8 changes: 8 additions & 0 deletions frontend/src/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { AssistantVoiceOverlay } from '../features/assistant/presentation/Assist
import { useAssistantConversation } from '../features/assistant/presentation/useAssistantConversation';
import type { ScheduleCalendarReadService } from '../features/schedule/application';
import { ScheduleCalendarScreen } from '../features/schedule/presentation/ScheduleCalendarScreen';
import {
calendarFocusTargetFromCommand,
type CalendarFocusTarget,
} from '../features/schedule/presentation/calendarFocus';

interface HomeScreenProps {
pushToTalkApplication: AssistantApplicationPort;
Expand Down Expand Up @@ -38,6 +42,7 @@ export function HomeScreen({
const [trackedPttCommand, setTrackedPttCommand] = useState(pttCommand);
const [trackedCallCommand, setTrackedCallCommand] = useState(callCommand);
const [refreshSignal, setRefreshSignal] = useState(0);
const [focusTarget, setFocusTarget] = useState<CalendarFocusTarget | null>(null);

// command.result 写完本地库之后 lastAppliedCommand 才会更新(见
// AssistantConversationService.applyCommandResultLocally),所以这里发现它
Expand All @@ -46,12 +51,14 @@ export function HomeScreen({
if (trackedPttCommand !== pttCommand) {
setTrackedPttCommand(pttCommand);
if (pttCommand !== null) {
setFocusTarget(calendarFocusTargetFromCommand(pttCommand));
setRefreshSignal((value) => value + 1);
}
}
if (trackedCallCommand !== callCommand) {
setTrackedCallCommand(callCommand);
if (callCommand !== null) {
setFocusTarget(calendarFocusTargetFromCommand(callCommand));
setRefreshSignal((value) => value + 1);
}
}
Expand All @@ -63,6 +70,7 @@ export function HomeScreen({
isSigningOut={isSigningOut}
onSignOut={onSignOut}
refreshSignal={refreshSignal}
focusTarget={focusTarget}
service={scheduleService}
timezone={timezone}
username={username}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it, jest } from '@jest/globals';
import { fireEvent, render, screen } from '@testing-library/react-native';

import type { AssistantApplicationPort } from '../../../../../src/features/assistant/application/AssistantApplication';
import { AssistantVoiceOverlay } from '../../../../../src/features/assistant/presentation/AssistantVoiceOverlay';

jest.mock('../../../../../src/features/assistant/presentation/useAssistantConversation', () => ({
useAssistantConversation: (application: AssistantApplicationPort) => ({
dismissReply: mockDismissReply,
endTurn: application.endTurn,
lastAppliedCommand: null,
replyText: application === mockPttApplication ? '已创建' : null,
soundLevel: null,
startTurn: application.startTurn,
state: { phase: 'idle' as const },
togglePause: () => {},
}),
}));

let mockPttApplication: AssistantApplicationPort;
const mockDismissReply = jest.fn<AssistantApplicationPort['dismissReply']>();

function createApplication(): AssistantApplicationPort {
return {
dismissReply: async () => {},
dispose: () => {},
endTurn: async () => {},
getLastAppliedCommand: () => null,
getReplyText: () => null,
getSoundLevel: () => null,
getState: () => ({ phase: 'idle' }),
startTurn: async () => {},
subscribe: () => () => {},
};
}

describe('AssistantVoiceOverlay layout', () => {
it('does not add a fullscreen reply dismiss target over the calendar', () => {
mockPttApplication = createApplication();
const continuousApplication = createApplication();
render(
<AssistantVoiceOverlay
continuousApplication={continuousApplication}
pushToTalkApplication={mockPttApplication}
/>,
);

expect(screen.queryByLabelText('关闭回复')).toBeNull();
expect(screen.getByText('按住说话')).toBeTruthy();
});

it('dismisses the reply when the bubble itself is pressed', () => {
mockPttApplication = createApplication();
const continuousApplication = createApplication();
render(
<AssistantVoiceOverlay
continuousApplication={continuousApplication}
pushToTalkApplication={mockPttApplication}
/>,
);

fireEvent.press(screen.getByText('已创建'));
expect(mockDismissReply).toHaveBeenCalledTimes(1);
});
});
Loading
Loading