diff --git a/frontend/src/features/assistant/application/AssistantConversationService.ts b/frontend/src/features/assistant/application/AssistantConversationService.ts index cbbe9a08..bb93cc58 100644 --- a/frontend/src/features/assistant/application/AssistantConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantConversationService.ts @@ -50,6 +50,12 @@ export class AssistantConversationService implements AssistantApplicationPort { // streamId/conversationId 还没就绪时执行。endTurn() 等这个 promise,把两者 // 重新串成先后顺序,而不是让 endTurn() 在它们仍是 null 时静默不做事。 private pendingStartTurn: Promise | null = null; + /** 每次按下都有独立代次;取消旧代次不会影响紧接着开始的新一轮。 */ + private nextTurnId = 0; + private activeTurnId = 0; + private readonly canceledTurnIds = new Set(); + /** 取消时后台停止录音;下一轮只等待这个本地清理,不等待旧连接握手。 */ + private captureCleanup: Promise | null = null; /** Category events can arrive before the command result creates their local row. */ private readonly pendingCategoryUpdates = new Map(); /** 串起每条 voice.command.result 的本地落库,见 AssistantContinuousConversationService @@ -93,7 +99,12 @@ export class AssistantConversationService implements AssistantApplicationPort { } async startTurn(): Promise { - const run = this._startTurn(); + if (this.pendingStartTurn !== null) { + return; + } + const turnId = ++this.nextTurnId; + this.activeTurnId = turnId; + const run = this._startTurn(turnId); this.pendingStartTurn = run; try { await run; @@ -109,13 +120,19 @@ export class AssistantConversationService implements AssistantApplicationPort { } } - private async _startTurn(): Promise { + private async _startTurn(turnId: number): Promise { this.replyText = null; this.soundLevel = null; + const previousCaptureCleanup = this.captureCleanup; if (this.connection === null) { this.setState({ phase: 'connecting' }); - await this.connect(); } + await previousCaptureCleanup?.catch(() => undefined); + this.throwIfCanceled(turnId); + if (this.connection === null) { + await this.connect(turnId); + } + this.throwIfCanceled(turnId); const connection = this.requireConnection(); // 权限必须在 voice.stream.start 之前拿到并检查结果:这条消息一旦发出并被 @@ -123,6 +140,7 @@ export class AssistantConversationService implements AssistantApplicationPort { // 被发现拒绝,我们没有办法清理(没收到 voice.stream.started 就没有 // stream_id,发不了 voice.stream.end),这条 session 就再也开不了新流了。 const permissionGranted = await this.deps.capture.requestPermission(); + this.throwIfCanceled(turnId); if (!permissionGranted) { this.setState({ message: '没有麦克风权限', phase: 'error' }); throw new Error('麦克风权限被拒绝'); @@ -142,14 +160,19 @@ export class AssistantConversationService implements AssistantApplicationPort { type: 'voice.stream.start', }); const conversationId = await started; + this.throwIfCanceled(turnId); try { await this.deps.capture.start((chunk, soundLevel) => { + if (this.isTurnCanceled(turnId) || this.streamId === null) return; connection.sendAudioFrame(chunk); this.soundLevel = soundLevel; this.notifyListeners(); }); } catch (error) { + if (this.isTurnCanceled(turnId)) { + throw error; + } // 服务端这时候已经确认开流了(stream_id 拿到手了);采集本身失败也要把 // 这条流关掉,不然跟权限被拒是一样的后果——session 卡在"有一条活跃流"。 if (this.streamId !== null) { @@ -159,6 +182,7 @@ export class AssistantConversationService implements AssistantApplicationPort { this.setState({ message: '录音启动失败', phase: 'error' }); throw error; } + this.throwIfCanceled(turnId); this.setState({ conversationId, phase: 'recording' }); } @@ -181,6 +205,34 @@ export class AssistantConversationService implements AssistantApplicationPort { } } + async cancelTurn(): Promise { + const canceledTurnId = this.activeTurnId; + this.canceledTurnIds.add(canceledTurnId); + this.activeTurnId = ++this.nextTurnId; + this.rejectPendingStreamStart(new Error('语音已取消')); + const pendingStart = this.pendingStartTurn; + this.pendingStartTurn = null; + this.soundLevel = null; + this.replyText = null; + this.currentAudioId = null; + + const connection = this.connection; + this.unsubscribeConnection?.(); + this.unsubscribeConnection = null; + this.connection = null; + this.streamId = null; + this.conversationId = null; + try { + connection?.close(); + } catch { + // 取消的最终目标是回到可重新按下的 idle,连接关闭失败也不能阻塞 UI。 + } + if (!this.disposed) { + this.setState({ phase: 'idle' }); + } + this.startCancellationCleanup(pendingStart); + } + async dismissReply(): Promise { this.replyText = null; this.currentAudioId = null; @@ -198,7 +250,7 @@ export class AssistantConversationService implements AssistantApplicationPort { this.connection = null; } - private async connect(): Promise { + private async connect(turnId: number): Promise { // 拿不到定位(超时或权限拒绝)就不带,transport.connect() 收到 null 会跳过 // session.hello 里的 latitude/longitude,不阻塞连接本身。定位那边先拿到就 // 清掉超时定时器,不然赢了比赛的那次调用还会留一个挂到 2s 之后才触发的 @@ -215,10 +267,18 @@ export class AssistantConversationService implements AssistantApplicationPort { // session.hello → session.ready 的握手已经在 transport.connect() 内部完成 // (共享的 AuthenticatedWebSocketClient 负责),这里拿到的就是已经 ready 的连接。 const connection = await this.deps.transport.connect(sample); + if (this.isTurnCanceled(turnId)) { + connection.close(); + return; + } this.connection = connection; - const unsubscribeMessage = connection.onMessage((message) => this.handleMessage(message)); - const unsubscribeAudio = connection.onAudioFrame((chunk) => this.handleAudioFrame(chunk)); - const unsubscribeClose = connection.onClose((event) => this.handleClose(event)); + const unsubscribeMessage = connection.onMessage((message) => + this.handleMessage(message, connection), + ); + const unsubscribeAudio = connection.onAudioFrame((chunk) => + this.handleAudioFrame(chunk, connection), + ); + const unsubscribeClose = connection.onClose((event) => this.handleClose(event, connection)); // 三个都要收,其中 onClose 转发到共享的 AuthenticatedWebSocketClient 上, // 不解绑就会在那条常驻连接上一直攒监听器(dispose()/换账号时尤其明显)。 this.unsubscribeConnection = () => { @@ -228,7 +288,11 @@ export class AssistantConversationService implements AssistantApplicationPort { }; } - private handleMessage(message: AssistantServerMessage): void { + private handleMessage( + message: AssistantServerMessage, + sourceConnection: VoiceTransportConnection, + ): void { + if (sourceConnection !== this.connection) return; if (isTransportError(message)) { this.setState({ message: message.error.message, phase: 'error' }); this.rejectPendingStreamStart(new Error(message.error.message)); @@ -252,7 +316,7 @@ export class AssistantConversationService implements AssistantApplicationPort { status: message.payload.status, }; // 状态立刻回到 idle,不等写库;message.ack 必须等写库成功才发(AGENTS.md §6)。 - this.queueCommandResult(command, message.message_id); + this.queueCommandResult(command, message.message_id, sourceConnection); this.setState({ phase: 'idle' }); return; } @@ -295,15 +359,20 @@ export class AssistantConversationService implements AssistantApplicationPort { * promise 没有任何 handler,Node 的 unhandled rejection 检测在下一次调用到达前 * 就已经判定"没人接",直接把整个进程带崩——不是理论风险,用一个会抛的 state * 订阅者复现过。 */ - private queueCommandResult(command: AppliedCommand, messageId: string): void { + private queueCommandResult( + command: AppliedCommand, + messageId: string, + sourceConnection: VoiceTransportConnection, + ): void { this.commandResultChain = this.commandResultChain - .then(() => this.applyCommandResultLocally(command, messageId)) + .then(() => this.applyCommandResultLocally(command, messageId, sourceConnection)) .catch(() => {}); } private async applyCommandResultLocally( command: AppliedCommand, messageId: string, + sourceConnection: VoiceTransportConnection, ): Promise { if (this.disposed) return; try { @@ -314,7 +383,9 @@ export class AssistantConversationService implements AssistantApplicationPort { } this.lastAppliedCommand = command; this.markScheduleDataChanged(); - this.connection?.send({ message_id: messageId, status: 'applied', type: 'message.ack' }); + if (this.connection === sourceConnection) { + sourceConnection.send({ message_id: messageId, status: 'applied', type: 'message.ack' }); + } const schedules = command.schedules ?? (command.schedule ? [command.schedule] : []); await this.applyPendingCategoryUpdates( schedules.flatMap((schedule) => (typeof schedule.id === 'string' ? [schedule.id] : [])), @@ -372,14 +443,18 @@ export class AssistantConversationService implements AssistantApplicationPort { this.notifyListeners(); } - private handleAudioFrame(chunk: ArrayBuffer): void { - if (this.currentAudioId === null) { + private handleAudioFrame(chunk: ArrayBuffer, sourceConnection: VoiceTransportConnection): void { + if (sourceConnection !== this.connection || this.currentAudioId === null) { return; } this.deps.playback.pushChunk(chunk).catch(() => {}); } - private handleClose(event: { code: number; reason: string }): void { + private handleClose( + event: { code: number; reason: string }, + sourceConnection: VoiceTransportConnection, + ): void { + if (sourceConnection !== this.connection) return; // 必须真的执行:切到连续对话时共享 WS 会因 voiceMode 不同断开重连,只置空的话 // 旧服务仍订阅着新连接的 TTS/PCM,同一句话会被两个服务重复送进播放器。 this.unsubscribeConnection?.(); @@ -405,6 +480,28 @@ export class AssistantConversationService implements AssistantApplicationPort { return this.connection; } + private isTurnCanceled(turnId: number): boolean { + return this.disposed || turnId !== this.activeTurnId || this.canceledTurnIds.has(turnId); + } + + private throwIfCanceled(turnId: number): void { + if (this.isTurnCanceled(turnId)) { + throw new Error('语音已取消'); + } + } + + private startCancellationCleanup(pendingStart: Promise | null): void { + void pendingStart?.catch(() => undefined); + const stopCapture = this.deps.capture.stop().catch(() => {}); + this.captureCleanup = stopCapture; + void stopCapture.finally(() => { + if (this.captureCleanup === stopCapture) { + this.captureCleanup = null; + } + }); + void this.deps.playback.stop().catch(() => {}); + } + private setState(state: ConversationTurnState): void { this.state = state; this.notifyListeners(); diff --git a/frontend/src/features/assistant/application/interfaces/AssistantApplicationPort.ts b/frontend/src/features/assistant/application/interfaces/AssistantApplicationPort.ts index 288dc7cf..d9d4d817 100644 --- a/frontend/src/features/assistant/application/interfaces/AssistantApplicationPort.ts +++ b/frontend/src/features/assistant/application/interfaces/AssistantApplicationPort.ts @@ -39,6 +39,8 @@ export interface AssistantApplicationPort { getMessages(): readonly VoiceChatMessage[]; startTurn(): Promise; endTurn(): Promise; + /** 按住说话上滑取消:停止采集并丢弃当前未提交的语音,不等待服务端结果。 */ + cancelTurn?(): Promise; /** 用户主动关掉回复气泡:清空气泡内容并打断正在播放的 TTS。 */ dismissReply(): Promise; /** 连续模式独有:暂停/恢复麦克风推流,不挂断连接。按住说话不实现这个方法。 */ diff --git a/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx b/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx index 2bee26c4..8b62ce4a 100644 --- a/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx +++ b/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx @@ -177,6 +177,7 @@ export function AssistantVoiceOverlay({ isRecording={ptt.state.phase === 'recording'} onPressIn={ptt.startTurn} onPressOut={ptt.endTurn} + onCancel={ptt.cancelTurn} soundLevel={ptt.soundLevel} /> diff --git a/frontend/src/features/assistant/presentation/PushToTalkBar.tsx b/frontend/src/features/assistant/presentation/PushToTalkBar.tsx index a1a04d28..26c507c7 100644 --- a/frontend/src/features/assistant/presentation/PushToTalkBar.tsx +++ b/frontend/src/features/assistant/presentation/PushToTalkBar.tsx @@ -1,5 +1,15 @@ -import { useEffect, useState } from 'react'; -import { Animated, Easing, Platform, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useEffect, useRef, useState } from 'react'; +import { + Animated, + Easing, + Platform, + Pressable, + StyleSheet, + Text, + Vibration, + View, +} from 'react-native'; +import type { GestureResponderEvent } from 'react-native'; import { FLOATING_VOICE_BAR_HEIGHT } from '../../../shared/ui/floatingVoiceBarLayout'; import { colors, spacing } from '../../../shared/ui/theme'; @@ -7,6 +17,8 @@ import { colors, spacing } from '../../../shared/ui/theme'; const WAVE_BAR_HEIGHTS = [10, 16, 22, 16, 10] as const; const MIN_BAR_SCALE = 0.4; const LEVEL_ANIMATION_MS = 120; +const HOLD_FEEDBACK_DURATION_MS = 10; +const CANCEL_DISTANCE_DP = 76; interface PushToTalkBarProps { isRecording: boolean; @@ -15,6 +27,8 @@ interface PushToTalkBarProps { soundLevel: number | null; onPressIn: () => void; onPressOut: () => void; + /** 上滑到取消区域后松手触发;未提供时回退到 onPressOut,保持组件兼容。 */ + onCancel?: () => void; } /** @@ -28,10 +42,60 @@ export function PushToTalkBar({ soundLevel, onPressIn, onPressOut, + onCancel, }: PushToTalkBarProps) { const [waveValues] = useState(() => WAVE_BAR_HEIGHTS.map(() => new Animated.Value(MIN_BAR_SCALE)), ); + const [isHolding, setIsHolding] = useState(false); + const [isCanceling, setIsCanceling] = useState(false); + const pressStartY = useRef(null); + const cancelingRef = useRef(false); + const cancelHapticTriggered = useRef(false); + const isActive = isHolding || isRecording; + + function handlePressIn(event?: GestureResponderEvent) { + if (disabled) return; + pressStartY.current = pageYFrom(event); + cancelingRef.current = false; + cancelHapticTriggered.current = false; + setIsCanceling(false); + setIsHolding(true); + if (Platform.OS !== 'web') { + Vibration.vibrate(HOLD_FEEDBACK_DURATION_MS); + } + onPressIn(); + } + + function handlePressMove(event?: GestureResponderEvent) { + if (!isHolding || pressStartY.current === null) return; + const currentY = pageYFrom(event); + if (currentY === null) return; + const distance = Math.max(0, pressStartY.current - currentY); + const nextIsCanceling = distance >= CANCEL_DISTANCE_DP; + cancelingRef.current = nextIsCanceling; + setIsCanceling(nextIsCanceling); + if (nextIsCanceling && !cancelHapticTriggered.current) { + cancelHapticTriggered.current = true; + if (Platform.OS !== 'web') { + Vibration.vibrate(HOLD_FEEDBACK_DURATION_MS); + } + } + } + + function handlePressOut() { + const shouldCancel = cancelingRef.current; + setIsHolding(false); + setIsCanceling(false); + pressStartY.current = null; + cancelingRef.current = false; + cancelHapticTriggered.current = false; + if (shouldCancel && onCancel !== undefined) { + onCancel(); + return; + } + onPressOut(); + } useEffect(() => { if (!isRecording) { @@ -58,17 +122,43 @@ export function PushToTalkBar({ [ styles.bar, - isRecording && styles.barActive, + isActive && styles.barActive, disabled && styles.barDisabled, pressed && styles.barPressed, ]} > - {isRecording ? ( + {isHolding ? ( + + + + {isCanceling ? '已到取消位置' : '滑到这里取消'} + + + + ) : null} + {isCanceling ? ( + + 松开取消 + + ) : isHolding ? ( + + 松开结束 + + ) : isRecording ? ( {waveValues.map((value, index) => ( application.cancelTurn?.() ?? Promise.resolve(), dismissReply: () => application.dismissReply(), endTurn: () => application.endTurn(), lastAppliedCommand, diff --git a/frontend/src/features/schedule/presentation/DetailIcon.tsx b/frontend/src/features/schedule/presentation/DetailIcon.tsx new file mode 100644 index 00000000..81d5f5c4 --- /dev/null +++ b/frontend/src/features/schedule/presentation/DetailIcon.tsx @@ -0,0 +1,90 @@ +import Svg, { Path } from 'react-native-svg'; + +import { colors } from '../../../shared/ui/theme'; + +const DETAIL_ICON_SIZE = 22; +const DETAIL_ICON_VIEW_BOX = '0 0 1024 1024'; + +type DetailIconName = 'location' | 'time' | 'reminder'; + +interface IconPath { + d: string; + fill: string; +} + +const ICON_PATHS: Record = { + location: [ + { + d: 'M599.332 865.516h-35.15c6.236-6.458 13.141-14.143 20.816-23.294 28.053-33.447 58.99-77.915 87.112-125.212 32.315-54.348 59.58-110.178 78.848-161.452 23.211-61.771 34.979-116.72 34.979-163.319 0-73.123-28.476-141.868-80.181-193.574-51.705-51.705-120.451-80.181-193.574-80.181-73.123 0-141.868 28.476-193.573 80.181-51.706 51.706-80.181 120.451-80.181 193.574 0 98.46 53.087 214.03 89.137 281.146-11.915 16.432-18.952 36.62-18.952 58.424 0 39.74 23.621 85.399 37.701 109.079 5.134 8.634 10.571 16.998 15.985 24.628H257.882c-11.046 0-20 8.954-20 20s8.954 20 20 20h341.45c11.046 0 20-8.954 20-20s-8.954-20-20-20zM348.613 731.809c0-32.947 26.804-59.752 59.751-59.752s59.751 26.805 59.751 59.752c0 20.308-10.261 50.143-28.152 81.854-12.107 21.461-23.992 37.335-31.599 45.897-7.607-8.563-19.492-24.438-31.599-45.897-17.89-31.713-28.152-61.547-28.152-81.854z m108.253 130.223a339.406 339.406 0 0 0 3.316 3.483h-5.751a328.337 328.337 0 0 0 2.435-3.483z m55.317-703.548c128.893 0 233.755 104.862 233.755 233.754 0 75.528-37.642 183.396-103.272 295.946-25.992 44.573-55.023 87.315-81.744 120.353-28.811 35.619-43.639 46.294-48.737 49.255-4.107-2.388-14.547-9.806-33.611-31.335 13.52-25.175 29.543-61.914 29.543-94.648 0-55.003-44.749-99.752-99.751-99.752-18.311 0-35.482 4.965-50.248 13.611-51.504-97.761-79.688-187.124-79.688-253.429-0.001-128.893 104.861-233.755 233.753-233.755zM652.624 865.516h-0.223c-11.046 0-20 8.954-20 20s8.954 20 20 20h0.223c11.046 0 20-8.954 20-20s-8.954-20-20-20zM766.118 865.516h-60.911c-11.046 0-20 8.954-20 20s8.954 20 20 20h60.911c11.046 0 20-8.954 20-20s-8.954-20-20-20z', + fill: colors.text, + }, + { + d: 'M512.183 523.257c72.244 0 131.019-58.774 131.019-131.018 0-72.243-58.774-131.017-131.019-131.017-72.243 0-131.017 58.774-131.017 131.017s58.774 131.018 131.017 131.018z m0-222.035c50.188 0 91.019 40.83 91.019 91.017 0 50.188-40.831 91.018-91.019 91.018-50.187 0-91.017-40.831-91.017-91.018s40.83-91.017 91.017-91.017z', + fill: colors.text, + }, + { + d: 'M439.964 813.662c17.891-31.711 28.152-61.546 28.152-81.854 0-32.947-26.804-59.752-59.751-59.752s-59.751 26.805-59.751 59.752c0 20.308 10.261 50.142 28.153 81.854 12.107 21.46 23.992 37.335 31.599 45.897 7.606-8.562 19.49-24.436 31.598-45.897z m-61.218-84.602a28.827 28.827 0 0 0-0.172 1.674c-0.39 5.256-4.776 9.26-9.962 9.26-0.249 0-0.499-0.009-0.751-0.027-5.508-0.409-9.641-5.205-9.232-10.713 0.068-0.921 0.165-1.859 0.287-2.791 0.717-5.477 5.741-9.331 11.214-8.616 5.474 0.716 9.332 5.736 8.616 11.213z m10.659-19.151a9.963 9.963 0 0 1-6.346 2.276 9.978 9.978 0 0 1-7.729-3.648c-3.508-4.266-2.894-10.567 1.372-14.075a49.961 49.961 0 0 1 31.663-11.338c5.289 0 10.5 0.825 15.486 2.454 5.25 1.714 8.117 7.359 6.403 12.609-1.714 5.251-7.362 8.117-12.61 6.402a29.845 29.845 0 0 0-9.279-1.466c-7.007 0.001-13.563 2.348-18.96 6.786z', + fill: colors.surface, + }, + { + d: 'M430.253 698.188c1.714-5.25-1.153-10.896-6.403-12.609a49.789 49.789 0 0 0-15.486-2.454 49.961 49.961 0 0 0-31.663 11.338c-4.266 3.508-4.879 9.81-1.372 14.075a9.981 9.981 0 0 0 7.729 3.648 9.963 9.963 0 0 0 6.346-2.276c5.397-4.438 11.953-6.785 18.959-6.785 3.177 0 6.299 0.493 9.279 1.466 5.249 1.714 10.898-1.153 12.611-6.403zM370.128 717.846c-5.473-0.715-10.497 3.14-11.214 8.616-0.122 0.932-0.218 1.87-0.287 2.791-0.409 5.508 3.725 10.304 9.232 10.713 0.252 0.019 0.502 0.027 0.751 0.027 5.186 0 9.572-4.004 9.962-9.26 0.042-0.563 0.1-1.12 0.172-1.674 0.718-5.476-3.14-10.496-8.616-11.213z', + fill: colors.text, + }, + ], + reminder: [ + { + d: 'M236.539274 477.852272c17.253966 0 31.233352-13.980409 31.233352-31.233352 0-110.680798 64.816215-197.723224 173.372629-232.82058 14.792914-4.77884 23.811312-19.713994 21.168112-35.026748-0.426719-2.480494-0.64059-4.900613-0.64059-7.197936 0-24.614607 22.683628-44.63457 50.561559-44.63457 27.858488 0 50.53086 20.018939 50.53086 44.63457 0 2.379187-0.203638 4.727675-0.599657 7.005554-2.745531 15.535835 6.537903 30.674627 21.615297 35.290761 110.202914 33.714869 173.402305 118.550023 173.402305 232.748948 0 17.253966 13.980409 31.233352 31.234375 31.233352s31.233352-13.980409 31.233352-31.233352c0-133.414569-72.349795-238.259452-194.876386-284.724717-5.174859-54.537104-53.673433-97.422843-112.541169-97.422843-59.062164 0-107.691721 43.149752-112.623034 97.921193-120.482025 47.999201-194.306404 154.959258-194.306404 284.226367C205.305923 463.871863 219.285309 477.852272 236.539274 477.852272z', + fill: colors.mutedText, + }, + { + d: 'M819.911812 602.309842l0-55.889915c0-17.253966-13.980409-31.233352-31.233352-31.233352s-31.234375 13.980409-31.234375 31.233352l0 67.988464c0 7.80885 2.928702 15.34243 8.204869 21.097504 40.10951 43.780109 86.381369 99.497085 95.105055 116.89329-0.315178 10.827603-3.345188 13.552667-32.586163 13.552667L196.820668 765.951853c-26.983561 0-31.671327-7.259334-32.484855-13.735839 9.17087-17.701151 55.381331-73.072249 95.449909-116.669186 5.306866-5.765307 8.245801-13.30912 8.245801-21.137413l0-67.988464c0-17.253966-13.980409-31.233352-31.233352-31.233352s-31.233352 13.980409-31.233352 31.233352l0 55.848982C101.69617 716.315362 101.69617 737.829352 101.69617 748.300844c0 38.727023 24.991184 80.117712 95.124498 80.117712l631.347179 0c23.5166 0 95.094822 0 95.094822-80.117712C923.262668 737.788419 923.262668 716.202799 819.911812 602.309842z', + fill: colors.mutedText, + }, + { + d: 'M400.99993 366.001835c-17.253966 0-31.233352 13.980409-31.233352 31.234375l0 30.470989c0 17.253966 13.980409 31.234375 31.233352 31.234375s31.234375-13.980409 31.234375-31.234375l0-30.470989C432.234305 379.982244 418.253896 366.001835 400.99993 366.001835z', + fill: colors.mutedText, + }, + { + d: 'M623.957885 366.001835c-17.253966 0-31.234375 13.980409-31.234375 31.234375l0 30.470989c0 17.253966 13.980409 31.234375 31.234375 31.234375 17.253966 0 31.233352-13.980409 31.233352-31.234375l0-30.470989C655.19226 379.982244 641.21185 366.001835 623.957885 366.001835z', + fill: colors.mutedText, + }, + { + d: 'M512.170892 598.435605c43.963281 0 75.105558-30.318516 86.574774-48.223305 9.222035-14.396895 5.03262-33.358759-9.242502-42.763966-14.305821-9.405207-33.593096-5.38873-43.159986 8.764618-0.132006 0.193405-13.614066 19.754926-34.172287 19.754926-19.989263 0-32.43369-18.117636-33.267685-19.378349-9.181103-14.407128-28.285207-18.809391-42.834574-9.750061-14.650675 9.099239-19.155269 28.356838-10.044774 43.007513C437.238272 567.892985 467.99374 598.435605 512.170892 598.435605z', + fill: colors.mutedText, + }, + { + d: 'M601.661066 856.999498c-15.179724-8.225335-34.131355-2.593058-42.346457 12.576433-9.292644 17.142425-27.248597 27.79709-46.871517 27.79709-19.530822 0-37.476543-10.67513-46.830585-27.848255-8.256034-15.149025-27.217898-20.741393-42.366923-12.495592-15.149025 8.256034-20.741393 27.217898-12.495592 42.366923 20.304442 37.283138 59.275012 60.444651 101.6931 60.444651 42.560328 0 81.561597-23.180955 101.794407-60.494793C622.453624 884.176464 616.821347 865.224834 601.661066 856.999498z', + fill: colors.mutedText, + }, + ], + time: [ + { + d: 'M509.4912 262.4A290.3552 290.3552 0 1 0 799.8464 552.96a290.6624 290.6624 0 0 0-290.3552-290.56z m25.6 528.0768v-27.904h-51.2v27.904A239.5648 239.5648 0 0 1 271.36 576.2048h26.0096v-51.2h-25.6a239.616 239.616 0 0 1 211.8656-209.92v31.6928h51.2v-31.7952a239.616 239.616 0 0 1 211.9168 209.92h-33.5872v51.2H747.52a239.4624 239.4624 0 0 1-212.4288 214.3744z', + fill: colors.text, + }, + { + d: 'M535.0912 381.3376h-51.2v143.616H380.928v51.2h102.9632v28.8256h51.2v-28.8256h30.8736v-51.2h-30.8736V381.3376z', + fill: colors.text, + }, + { + d: 'M885.76 436.0704a183.7568 183.7568 0 1 0-258.6624-259.2256 391.7824 391.7824 0 0 0-87.808-16.64v-49.8688h30.72v-51.2H457.472v51.2h30.72v49.2032A391.424 391.424 0 0 0 394.24 176.2304a183.7568 183.7568 0 1 0-260.2496 257.9968A392.96 392.96 0 0 0 235.52 835.2768l-46.08 74.1376a34.2528 34.2528 0 0 0 29.0816 52.3264h81.92a34.0992 34.0992 0 0 0 26.6752-12.8l27.5456-34.304a393.1136 393.1136 0 0 0 325.2224-6.8096l33.0752 40.96a33.9456 33.9456 0 0 0 26.6752 12.8h81.92a34.2528 34.2528 0 0 0 28.3648-52.1728l-54.1184-87.04A393.0624 393.0624 0 0 0 885.76 436.0704zM767.3856 162.816a132.608 132.608 0 0 1 98.2528 221.6448 393.0112 393.0112 0 0 0-187.0848-187.4944 132.352 132.352 0 0 1 88.832-34.1504zM121.7536 295.424a132.608 132.608 0 0 1 220.5184-99.2768 392.704 392.704 0 0 0-187.904 186.3168 132.7104 132.7104 0 0 1-32.6144-87.04z m170.3424 615.1168h-42.9568l25.9072-41.6256a385.8944 385.8944 0 0 0 32.8704 21.9648z m455.68 0l-22.7328-28.3136a404.48 404.48 0 0 0 33.024-24.1664l32.6656 52.48z m-238.08-15.36A342.528 342.528 0 0 1 388.5056 232.2944l3.6352-1.4336a342.5792 342.5792 0 1 1 117.3504 664.4736z', + fill: colors.text, + }, + ], +}; + +export function DetailIcon({ + name, + size = DETAIL_ICON_SIZE, +}: { + name: DetailIconName; + size?: number; +}) { + return ( + + {ICON_PATHS[name].map((path, index) => ( + + ))} + + ); +} diff --git a/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx b/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx index 343f4186..90037953 100644 --- a/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx +++ b/frontend/src/features/schedule/presentation/LocationScheduleDetailSheet.tsx @@ -8,6 +8,7 @@ import { normalizeDetailText, ScheduleDetailSheet, } from './ScheduleDetailSheet'; +import { DetailIcon } from './DetailIcon'; import { scheduleCategoryLabel } from './scheduleDisplay'; export function LocationScheduleDetailSheet({ @@ -36,10 +37,12 @@ export function LocationScheduleDetailSheet({ return ( {categoryLabel ? : null} - {location ? : null} + {location ? ( + } label="地点" primary={location} /> + ) : null} {reminder ? ( } label="提醒" primary={reminder.primary} secondary={reminder.secondary} diff --git a/frontend/src/features/schedule/presentation/MonthCalendar.tsx b/frontend/src/features/schedule/presentation/MonthCalendar.tsx index 0a5c2b5f..70ab0e5f 100644 --- a/frontend/src/features/schedule/presentation/MonthCalendar.tsx +++ b/frontend/src/features/schedule/presentation/MonthCalendar.tsx @@ -61,6 +61,21 @@ export function MonthCalendar({ const selected = key === selectedKey; const todayMatch = key === todayKey; const hasItems = (occurrencesByDate.get(key)?.length ?? 0) > 0; + // Fabric 新架构下,样式数组里混入 false 会让前一项的 borderRadius 在 + // 合并时丢失:实测 `[dateBubble, false, selectedBubble]` 把选中气泡画成 + // 方块(今天气泡 `[dateBubble, todayBubble, ...]` 是圆)。先滤掉假值, + // 保证数组里全是真实样式对象。 + const bubbleStyle = [ + styles.dateBubble, + todayMatch && styles.todayBubble, + selected && styles.selectedBubble, + ].filter(Boolean); + const textStyle = [ + styles.dayText, + !inMonth && styles.muted, + todayMatch && styles.todayText, + selected && styles.selectedText, + ].filter(Boolean); return ( onSelectDate(day)} style={({ pressed }) => [styles.day, pressed && inMonth && styles.dayPressed]} > - - - {day.getDate()} - + + {day.getDate()} {hasItems ? ( - + {selectedLabel} @@ -240,6 +240,8 @@ function LogoutIcon() { } const styles = StyleSheet.create({ + // accountActions 整体上限收窄:用户名过长时 pill 内截断,不能挤占日期标题。 + // 标题 (flex:1) 因此始终保有 ~150px(360px 屏)可用空间。 accountActions: { alignItems: 'center', flexDirection: 'row', @@ -247,7 +249,7 @@ const styles = StyleSheet.create({ gap: spacing.sm, justifyContent: 'flex-end', marginLeft: 'auto', - maxWidth: 240, + maxWidth: 168, minWidth: 0, }, agenda: { paddingHorizontal: spacing.md, paddingTop: spacing.xl }, @@ -343,10 +345,11 @@ const styles = StyleSheet.create({ title: { color: colors.text, flex: 1, - fontSize: 28, + fontSize: 24, fontWeight: '800', - lineHeight: 34, + lineHeight: 30, minWidth: 0, + flexShrink: 1, }, userPill: { alignItems: 'center', @@ -357,7 +360,8 @@ const styles = StyleSheet.create({ flexDirection: 'row', flexShrink: 1, gap: spacing.sm, - maxWidth: 196, + // 用户名过长时在 pill 内截断,不让它把日期标题挤出可视区 + maxWidth: 124, minWidth: 0, paddingHorizontal: 4, paddingRight: 10, diff --git a/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx b/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx index 7f85117a..6155e263 100644 --- a/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleDetailSheet.tsx @@ -58,7 +58,7 @@ export function DetailSection({ primary, secondary, }: { - icon: string; + icon: ReactNode; label: string; primary: string; secondary?: string; @@ -66,7 +66,7 @@ export function DetailSection({ return ( - {icon} + {typeof icon === 'string' ? {icon} : icon} {label} diff --git a/frontend/src/features/schedule/presentation/ScheduleOccurrenceDetailSheet.tsx b/frontend/src/features/schedule/presentation/ScheduleOccurrenceDetailSheet.tsx index 1b0496d6..43aa57fe 100644 --- a/frontend/src/features/schedule/presentation/ScheduleOccurrenceDetailSheet.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleOccurrenceDetailSheet.tsx @@ -10,6 +10,7 @@ import { normalizeDetailText, ScheduleDetailSheet, } from './ScheduleDetailSheet'; +import { DetailIcon } from './DetailIcon'; import { dateKeyInTimezone, formatTime, scheduleCategoryLabel } from './scheduleDisplay'; export function ScheduleOccurrenceDetailSheet({ @@ -52,7 +53,7 @@ export function ScheduleOccurrenceDetailSheet({ - + 时间 @@ -91,10 +92,12 @@ export function ScheduleOccurrenceDetailSheet({ )} {categoryLabel ? : null} - {location ? : null} + {location ? ( + } label="地点" primary={location} /> + ) : null} {reminder ? ( } label="提醒" primary={reminder.primary} secondary={reminder.secondary} @@ -187,15 +190,6 @@ const styles = StyleSheet.create({ overflow: 'hidden', width: 30, }, - timeIconText: { - color: colors.text, - fontSize: 17, - height: 17, - includeFontPadding: false, - lineHeight: 17, - textAlign: 'center', - textAlignVertical: 'center', - }, timeLabel: { color: colors.mutedText, fontSize: 12, fontWeight: '700' }, timePoint: { flex: 1, minWidth: 96 }, timePointDate: { color: colors.mutedText, fontSize: 13, fontWeight: '600', marginTop: 5 }, diff --git a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts index 0124a296..a89a9686 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts @@ -214,6 +214,71 @@ describe('AssistantConversationService', () => { await expect(Promise.all([turn, endTurn])).resolves.toBeDefined(); }); + it('returns to idle immediately and allows a new turn while the canceled connection finishes', async () => { + const oldFake = createFakeConnection(); + const nextFake = createFakeConnection(); + let resolveOldConnection!: (connection: VoiceTransportConnection) => void; + const oldConnectionPending = new Promise((resolve) => { + resolveOldConnection = resolve; + }); + const deps = createDeps({ connection: oldFake.connection }); + deps.transport.connect = jest + .fn() + .mockReturnValueOnce(oldConnectionPending) + .mockResolvedValueOnce(nextFake.connection); + const service = new AssistantConversationService({ accountId: 'acc_001' }, deps); + + const firstTurn = service.startTurn(); + await flushAsync(); + const cancel = service.cancelTurn(); + expect(service.getState()).toEqual({ phase: 'idle' }); + + const nextTurn = service.startTurn(); + await flushAsync(); + expect(deps.transport.connect).toHaveBeenCalledTimes(2); + + resolveOldConnection(oldFake.connection); + await expect(Promise.all([firstTurn, cancel])).resolves.toBeDefined(); + await flushAsync(); + expect(oldFake.closeCalls.count).toBe(1); + + nextFake.emitMessage({ + ok: true, + payload: { conversation_id: 'conv_002', stream_id: 'stream_002' }, + type: 'voice.stream.started', + } as AssistantServerMessage); + await nextTurn; + + expect(oldFake.sent).toHaveLength(0); + expect(deps.capture.start).toHaveBeenCalledTimes(1); + expect(service.getState()).toEqual({ conversationId: 'conv_002', phase: 'recording' }); + }); + + it('cancels a recording without ending the stream or applying a late command', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = new AssistantConversationService({ accountId: 'acc_001' }, deps); + + await completeStreamStart(fake, service.startTurn()); + expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'recording' }); + + await service.cancelTurn(); + + expect(deps.capture.stop).toHaveBeenCalledTimes(1); + expect(fake.sent.filter((message) => message.type === 'voice.stream.end')).toHaveLength(0); + expect(fake.closeCalls.count).toBe(1); + expect(service.getState()).toEqual({ phase: 'idle' }); + + fake.emitMessage({ + conversation_id: 'conv_001', + message_id: 'msg_late', + payload: { operation: 'create_schedule', status: 'applied' }, + type: 'voice.command.result', + } as AssistantServerMessage); + await flushAsync(); + expect(deps.localScheduleWriter.applyCommandResult).not.toHaveBeenCalled(); + }); + it('sends voice.stream.end and reports an error when capture.start() fails after the stream opened', async () => { const fake = createFakeConnection(); const deps = createDeps({ diff --git a/frontend/tests/unit/features/assistant/presentation/PushToTalkBar.test.tsx b/frontend/tests/unit/features/assistant/presentation/PushToTalkBar.test.tsx index d0ee0bc4..3f82c805 100644 --- a/frontend/tests/unit/features/assistant/presentation/PushToTalkBar.test.tsx +++ b/frontend/tests/unit/features/assistant/presentation/PushToTalkBar.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, jest } from '@jest/globals'; import { fireEvent, render, screen } from '@testing-library/react-native'; +import { StyleSheet, Vibration } from 'react-native'; import { PushToTalkBar } from '../../../../../src/features/assistant/presentation/PushToTalkBar'; @@ -54,6 +55,65 @@ describe('PushToTalkBar', () => { expect(onPressOut).toHaveBeenCalledTimes(1); }); + it('gives immediate hold feedback and a short haptic pulse', () => { + const vibrate = jest.spyOn(Vibration, 'vibrate').mockImplementation(() => undefined); + render( + , + ); + + const bar = screen.getByLabelText('按住说话'); + fireEvent(bar, 'pressIn'); + + expect(screen.getByText('松开结束')).toBeTruthy(); + expect(screen.getByText('滑到这里取消')).toBeTruthy(); + expect( + StyleSheet.flatten(screen.getByTestId('push-to-talk-cancel-target').props.style), + ).toMatchObject({ bottom: 88, position: 'absolute' }); + expect(screen.queryByText(/dp/)).toBeNull(); + expect(vibrate).toHaveBeenCalledWith(10); + + fireEvent(bar, 'pressOut'); + expect(screen.getByText('按住说话')).toBeTruthy(); + vibrate.mockRestore(); + }); + + it('shows a fixed cancel target and vibrates once when the target is reached', () => { + const onCancel = jest.fn(); + const onPressOut = jest.fn(); + const vibrate = jest.spyOn(Vibration, 'vibrate').mockImplementation(() => undefined); + render( + , + ); + + const bar = screen.getByLabelText('按住说话'); + fireEvent(bar, 'pressIn', { nativeEvent: { pageY: 400 } }); + fireEvent(bar, 'pressMove', { nativeEvent: { pageY: 360 } }); + expect(screen.getByText('滑到这里取消')).toBeTruthy(); + + fireEvent(bar, 'pressMove', { nativeEvent: { pageY: 324 } }); + expect(screen.getByText('松开取消')).toBeTruthy(); + expect(screen.getByText('已到取消位置')).toBeTruthy(); + expect(vibrate).toHaveBeenCalledTimes(2); + + fireEvent(bar, 'pressOut'); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onPressOut).not.toHaveBeenCalled(); + vibrate.mockRestore(); + }); + it('marks the control as accessibility-disabled while another voice mode is active', () => { render( void>(); const onChangeMonth = jest.fn<(offset: number) => void>(); render( { width: '92%', }); }); + + it('keeps a selected non-today date circular', () => { + renderCalendar({ selectedDate: new Date(2026, 7, 14), today: new Date(2026, 7, 13) }); + + const selectedText = screen.getByText('14'); + const bubble = selectedText.parent?.parent; + expect(bubble).toBeTruthy(); + expect(StyleSheet.flatten(bubble?.props.style)).toMatchObject({ + backgroundColor: colors.text, + borderRadius: 999, + height: 34, + overflow: 'hidden', + width: 34, + }); + }); }); diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx index fa937314..7d69e470 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx @@ -252,12 +252,32 @@ describe('ScheduleCalendarScreen location schedules', () => { ).toMatchObject({ flexShrink: 1, minWidth: 0 }); expect( StyleSheet.flatten(screen.getByTestId('schedule-account-actions').props.style), - ).toMatchObject({ marginLeft: 'auto', maxWidth: 240, minWidth: 0 }); + ).toMatchObject({ marginLeft: 'auto', maxWidth: 168, minWidth: 0 }); fireEvent.press(screen.getByRole('button', { name: '退出登录' })); expect(onSignOut).toHaveBeenCalledTimes(1); }); + it('keeps the selected date fully visible when the header is narrow', async () => { + const service = createService(); + render( + {}} + onSignOut={() => {}} + service={service} + timezone="Asia/Shanghai" + username="zhangsan-with-an-extremely-long-account-name" + />, + ); + + await waitFor(() => expect(service.getSchedulesByRange).toHaveBeenCalled()); + 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 }); + }); + it('opens permissions when the user pill is pressed', async () => { const service = createService(); const onOpenPermissions = jest.fn(); diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx index 64b59f25..c0773ec1 100644 --- a/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx +++ b/frontend/tests/unit/features/schedule/presentation/ScheduleDetailSheet.test.tsx @@ -77,6 +77,17 @@ describe('schedule detail sheets', () => { expect(screen.queryByText('全天')).toBeNull(); }); + it('uses the same SVG size for time, location, and reminder icons', () => { + render( {}} />); + + for (const name of ['time', 'location', 'reminder']) { + expect(screen.getByTestId(`detail-icon-${name}`).props).toMatchObject({ + height: 22, + width: 22, + }); + } + }); + it('shows all-day status without empty location or reminder sections', () => { render( {}} />);