fix: improve voice call page UI centering and error display

1. 布局居中:将 Column 包裹在 Center 中,所有文本添加
   textAlign: TextAlign.center,确保头像、标题、副标题
   在各种屏幕尺寸上居中显示。

2. 错误展示优化:将 SnackBar 大面积红色块替换为行内错误卡片,
   采用圆角容器 + error icon + 简洁文案,视觉上更融洽。
   新增 _errorMessage 状态字段 + _friendlyError() 方法,
   将 DioException 等异常转换为中文友好提示(如 "语音服务
   暂不可用 (503)"),避免用户看到大段英文 stacktrace。

3. 错误状态清理:点击接听时自动清除上一次的 _errorMessage。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 21:10:49 -08:00
parent b7814d42a9
commit 45c54acb87
1 changed files with 106 additions and 38 deletions

View File

@ -26,6 +26,7 @@ class AgentCallPage extends ConsumerStatefulWidget {
class _AgentCallPageState extends ConsumerState<AgentCallPage>
with SingleTickerProviderStateMixin {
_CallPhase _phase = _CallPhase.ringing;
String? _errorMessage;
String? _sessionId;
WebSocketChannel? _audioChannel;
StreamSubscription? _audioSubscription;
@ -77,7 +78,10 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
/// Accept call: create voice session, connect WebSocket, start mic + player.
Future<void> _acceptCall() async {
setState(() => _phase = _CallPhase.connecting);
setState(() {
_phase = _CallPhase.connecting;
_errorMessage = null;
});
try {
final dio = ref.read(dioClientProvider);
@ -136,13 +140,10 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
setState(() => _phase = _CallPhase.active);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('通话连接失败: $e'),
backgroundColor: AppColors.error,
),
);
setState(() => _phase = _CallPhase.ringing);
setState(() {
_phase = _CallPhase.ringing;
_errorMessage = '连接失败: ${_friendlyError(e)}';
});
}
}
}
@ -434,39 +435,78 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
body: SafeArea(
child: Stack(
children: [
Column(
children: [
const Spacer(flex: 2),
_buildAvatar(),
const SizedBox(height: 24),
Text(
_statusText,
style: const TextStyle(
fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
_subtitleText,
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 15),
),
const SizedBox(height: 32),
if (_phase == _CallPhase.active)
Center(
child: Column(
children: [
const Spacer(flex: 2),
_buildAvatar(),
const SizedBox(height: 24),
Text(
_durationLabel,
_statusText,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.w300,
color: AppColors.textPrimary,
letterSpacing: 4,
),
fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
if (_phase == _CallPhase.active) _buildWaveform(),
const Spacer(flex: 3),
_buildControls(),
const SizedBox(height: 48),
],
const SizedBox(height: 8),
Text(
_subtitleText,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 15),
),
// Inline error message
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.error.withOpacity(0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline,
size: 18, color: AppColors.error),
const SizedBox(width: 8),
Flexible(
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.error,
fontSize: 13,
),
),
),
],
),
),
),
],
const SizedBox(height: 32),
if (_phase == _CallPhase.active)
Text(
_durationLabel,
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.w300,
color: AppColors.textPrimary,
letterSpacing: 4,
),
),
const SizedBox(height: 24),
if (_phase == _CallPhase.active) _buildWaveform(),
const Spacer(flex: 3),
_buildControls(),
const SizedBox(height: 48),
],
),
),
// Reconnecting overlay
if (_isReconnecting)
@ -522,6 +562,9 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
}
String get _subtitleText {
if (_errorMessage != null && _phase == _CallPhase.ringing) {
return '';
}
switch (_phase) {
case _CallPhase.ringing:
return '智能运维语音助手';
@ -534,6 +577,31 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
}
}
/// Extract a short user-friendly message from an exception.
String _friendlyError(dynamic e) {
final s = e.toString();
// Extract status code info for Dio errors
final match = RegExp(r'status code of (\d+)').firstMatch(s);
if (match != null) {
final code = match.group(1);
switch (code) {
case '503':
return '语音服务暂不可用 ($code)';
case '502':
return '语音服务网关错误 ($code)';
case '404':
return '语音服务未找到 ($code)';
default:
return '服务器错误 ($code)';
}
}
if (s.contains('SocketException') || s.contains('Connection refused')) {
return '无法连接到服务器';
}
if (s.length > 80) return '${s.substring(0, 80)}...';
return s;
}
Widget _buildAvatar() {
final isActive = _phase == _CallPhase.active;
return AnimatedContainer(