diff --git a/it0_app/lib/features/agent_call/presentation/pages/agent_call_page.dart b/it0_app/lib/features/agent_call/presentation/pages/agent_call_page.dart index 7345fb1..8a9e3cd 100644 --- a/it0_app/lib/features/agent_call/presentation/pages/agent_call_page.dart +++ b/it0_app/lib/features/agent_call/presentation/pages/agent_call_page.dart @@ -72,6 +72,12 @@ class _AgentCallPageState extends ConsumerState final List<_CallMessage> _messages = []; final ScrollController _scrollController = ScrollController(); + // PTT (Push-to-Talk) mode + bool _isPttMode = true; + bool _pttRecording = false; + final Stopwatch _pttStopwatch = Stopwatch(); + Timer? _pttTimer; + @override void initState() { super.initState(); @@ -207,8 +213,8 @@ class _AgentCallPageState extends ConsumerState ), ); - // 5. Enable microphone (publishes local audio track) - await _room!.localParticipant?.setMicrophoneEnabled(true); + // 5. Enable microphone — muted by default in PTT mode + await _room!.localParticipant?.setMicrophoneEnabled(!_isPttMode); // 6. Start duration timer _stopwatch.start(); @@ -304,6 +310,66 @@ class _AgentCallPageState extends ConsumerState setState(() {}); } + // --------------------------------------------------------------------------- + // PTT (Push-to-Talk) + // --------------------------------------------------------------------------- + + void _startPtt() { + if (!_isPttMode || _phase != _CallPhase.active || _pttRecording) return; + _room?.localParticipant?.setMicrophoneEnabled(true); + _pttStopwatch + ..reset() + ..start(); + _pttTimer = Timer.periodic(const Duration(milliseconds: 50), (_) { + if (mounted) setState(() {}); + }); + setState(() => _pttRecording = true); + } + + void _stopPtt() { + if (!_pttRecording) return; + _room?.localParticipant?.setMicrophoneEnabled(false); + _pttStopwatch.stop(); + _pttTimer?.cancel(); + _pttTimer = null; + final elapsed = _pttStopwatch.elapsed; + if (elapsed.inMilliseconds >= 300) { + _messages.add(_CallMessage( + text: '', + isUser: true, + timestamp: DateTime.now(), + isVoice: true, + voiceDurationMs: elapsed.inMilliseconds, + )); + _scrollToBottom(); + } + setState(() => _pttRecording = false); + } + + void _togglePttMode() { + if (_pttRecording) _stopPtt(); + final newPtt = !_isPttMode; + setState(() => _isPttMode = newPtt); + if (_phase == _CallPhase.active) { + _room?.localParticipant?.setMicrophoneEnabled(!newPtt); + } + } + + String _formatPttDuration() { + final ms = _pttStopwatch.elapsedMilliseconds; + final secs = ms ~/ 1000; + final tenths = (ms % 1000) ~/ 100; + return '$secs.$tenths"'; + } + + String _formatVoiceDuration(int ms) { + final secs = ms ~/ 1000; + if (secs < 60) return '${secs}s'; + final mins = secs ~/ 60; + final s = secs % 60; + return '$mins:${s.toString().padLeft(2, '0')}'; + } + void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { @@ -658,6 +724,10 @@ class _AgentCallPageState extends ConsumerState ), if (_phase == _CallPhase.active) _agentState == 'thinking' ? _buildThinkingDots() : _buildWaveform(), + if (_phase == _CallPhase.active && _isPttMode) ...[ + const SizedBox(height: 28), + _buildPttButton(), + ], const Spacer(flex: 3), _buildControls(), const SizedBox(height: 48), @@ -774,6 +844,7 @@ class _AgentCallPageState extends ConsumerState } Widget _buildMessageBubble(_CallMessage msg) { + if (msg.isVoice) return _buildVoiceMessageBubble(msg); final isUser = msg.isUser; return Padding( padding: const EdgeInsets.only(bottom: 12), @@ -1168,6 +1239,115 @@ class _AgentCallPageState extends ConsumerState ); } + /// Large press-and-hold PTT button shown in call mode. + Widget _buildPttButton() { + return Listener( + onPointerDown: (_) => _startPtt(), + onPointerUp: (_) => _stopPtt(), + onPointerCancel: (_) => _stopPtt(), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: _pttRecording ? 100 : 84, + height: _pttRecording ? 100 : 84, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _pttRecording + ? AppColors.error.withOpacity(0.15) + : AppColors.primary.withOpacity(0.12), + border: Border.all( + color: _pttRecording ? AppColors.error : AppColors.primary, + width: _pttRecording ? 3 : 2, + ), + boxShadow: _pttRecording + ? [ + BoxShadow( + color: AppColors.error.withOpacity(0.35), + blurRadius: 24, + spreadRadius: 4, + ) + ] + : [], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _pttRecording ? Icons.mic : Icons.mic_none, + size: _pttRecording ? 36 : 30, + color: _pttRecording ? AppColors.error : AppColors.primary, + ), + const SizedBox(height: 2), + Text( + _pttRecording ? _formatPttDuration() : '按住说话', + style: TextStyle( + fontSize: _pttRecording ? 12 : 11, + color: _pttRecording ? AppColors.error : AppColors.textSecondary, + fontWeight: _pttRecording ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ), + ); + } + + /// Voice message bubble: decorative waveform + duration. + Widget _buildVoiceMessageBubble(_CallMessage msg) { + final rand = Random(msg.voiceDurationMs ^ msg.timestamp.millisecondsSinceEpoch); + final bars = List.generate(18, (_) => 0.15 + rand.nextDouble() * 0.85); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.15), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(4), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.mic, size: 16, color: AppColors.primary), + const SizedBox(width: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: bars + .map((h) => Container( + width: 3, + height: 6 + h * 18, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.65), + borderRadius: BorderRadius.circular(1.5), + ), + )) + .toList(), + ), + const SizedBox(width: 6), + Text( + _formatVoiceDuration(msg.voiceDurationMs), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + ], + ), + ); + } + Widget _buildControls() { switch (_phase) { case _CallPhase.ringing: @@ -1197,6 +1377,13 @@ class _AgentCallPageState extends ConsumerState return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ + _CircleButton( + icon: _isPttMode ? Icons.touch_app : Icons.wifi_tethering, + label: _isPttMode ? 'PTT' : '自由', + isActive: _isPttMode, + onTap: _togglePttMode, + ), + const SizedBox(width: 16), _CircleButton( icon: _isInputExpanded ? Icons.keyboard_hide : Icons.keyboard, label: _isInputExpanded ? '收起' : '键盘', @@ -1204,13 +1391,15 @@ class _AgentCallPageState extends ConsumerState onTap: () => setState(() => _isInputExpanded = !_isInputExpanded), ), const SizedBox(width: 16), - _CircleButton( - icon: _isMuted ? Icons.mic_off : Icons.mic, - label: _isMuted ? '取消静音' : '静音', - isActive: _isMuted, - onTap: _toggleMute, - ), - const SizedBox(width: 16), + if (!_isPttMode) ...[ + _CircleButton( + icon: _isMuted ? Icons.mic_off : Icons.mic, + label: _isMuted ? '取消静音' : '静音', + isActive: _isMuted, + onTap: _toggleMute, + ), + const SizedBox(width: 16), + ], FloatingActionButton( heroTag: 'end', backgroundColor: AppColors.error, @@ -1240,6 +1429,8 @@ class _AgentCallPageState extends ConsumerState _scrollController.dispose(); _durationTimer?.cancel(); _waveTimer?.cancel(); + _pttTimer?.cancel(); + _pttStopwatch.stop(); _waveController.dispose(); _thinkingController.dispose(); _stopwatch.stop(); @@ -1259,12 +1450,16 @@ class _CallMessage { final bool isUser; final DateTime timestamp; final List? attachments; + final bool isVoice; + final int voiceDurationMs; _CallMessage({ required this.text, required this.isUser, required this.timestamp, this.attachments, + this.isVoice = false, + this.voiceDurationMs = 0, }); }