refactor: 重构 chat_page 对接完整架构,集成全部 stream event widget

## 问题
chat_page.dart 包含内联的简化版 ChatMessage/ChatState/ChatNotifier(约180行),
绕过了已实现的完整 Clean Architecture 层:
- domain/entities/chat_message.dart(含 ToolExecution、ApprovalRequest)
- domain/entities/stream_event.dart(9种 sealed class 事件)
- chat_providers.dart(完整 ChatNotifier 支持审批/工具/常驻指令)
- 5 个独立 widget 全部闲置未使用

## 变更
1. 删除内联重复代码(~180行):ChatRole、ChatContentType、内联 ChatMessage、
   内联 ChatState、内联 ChatNotifier、chatMessagesProvider
2. 切换到正式 chatProvider(chat_providers.dart),支持全部 9 种 StreamEvent
3. 集成 5 个已有 widget:
   - MessageBubble — 用户/AI 消息气泡(带时间戳)
   - StreamTextWidget — AI 流式回复动画光标
   - ToolExecutionCard — 工具执行详情(名称/输入/输出/状态/风险等级)
   - ApprovalActionCard — 审批卡片(倒计时/通过/拒绝/过期处理)
   - AgentThinkingIndicator — 思考动画指示器
4. 新增 _AgentStatusBar — 实时状态条(思考中/执行中/等待审批)
5. 新增 _StandingOrderDraftCard — 常驻指令草案渲染
6. AppBar + 输入区添加停止按钮,审批等待时显示提示
7. 消息渲染按 MessageType 分发:text/thinking/toolUse/toolResult/approval/standingOrderDraft

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 03:21:44 -08:00
parent 15e6fca6c0
commit 1075a6b265
1 changed files with 352 additions and 377 deletions

View File

@ -8,193 +8,14 @@ import '../../../../core/audio/noise_reducer.dart';
import '../../../../core/audio/speech_enhancer.dart'; import '../../../../core/audio/speech_enhancer.dart';
import '../../../../core/config/api_endpoints.dart'; import '../../../../core/config/api_endpoints.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../core/network/websocket_client.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../domain/entities/chat_message.dart';
// --------------------------------------------------------------------------- import '../providers/chat_providers.dart';
// TODO 39 & 40 Chat page: message send + voice input import '../widgets/message_bubble.dart';
// --------------------------------------------------------------------------- import '../widgets/tool_execution_card.dart';
import '../widgets/approval_action_card.dart';
// ---- Chat message model ---------------------------------------------------- import '../widgets/agent_thinking_indicator.dart';
import '../widgets/stream_text_widget.dart';
enum ChatRole { user, assistant, system }
enum ChatContentType { text, toolUse }
class ChatMessage {
final String id;
final ChatRole role;
final ChatContentType contentType;
final String content;
final DateTime timestamp;
const ChatMessage({
required this.id,
required this.role,
required this.contentType,
required this.content,
required this.timestamp,
});
}
// ---- Chat state notifier ---------------------------------------------------
class ChatState {
final List<ChatMessage> messages;
final bool isStreaming;
final String? sessionId;
final String? error;
const ChatState({
this.messages = const [],
this.isStreaming = false,
this.sessionId,
this.error,
});
ChatState copyWith({
List<ChatMessage>? messages,
bool? isStreaming,
String? sessionId,
String? error,
}) {
return ChatState(
messages: messages ?? this.messages,
isStreaming: isStreaming ?? this.isStreaming,
sessionId: sessionId ?? this.sessionId,
error: error,
);
}
}
class ChatNotifier extends StateNotifier<ChatState> {
final Ref _ref;
StreamSubscription<Map<String, dynamic>>? _wsSubscription;
ChatNotifier(this._ref) : super(const ChatState());
/// Sends a user prompt, creates a task via the agent endpoint, subscribes to
/// the session WebSocket, and streams response messages into the chat.
Future<void> sendMessage(String prompt) async {
if (prompt.trim().isEmpty) return;
// Add the user message locally
final userMsg = ChatMessage(
id: DateTime.now().microsecondsSinceEpoch.toString(),
role: ChatRole.user,
contentType: ChatContentType.text,
content: prompt,
timestamp: DateTime.now(),
);
state = state.copyWith(
messages: [...state.messages, userMsg],
isStreaming: true,
error: null,
);
try {
// POST to agent tasks endpoint
final dio = _ref.read(dioClientProvider);
final response = await dio.post(
ApiEndpoints.tasks,
data: {'prompt': prompt},
);
final data = response.data as Map<String, dynamic>;
final sessionId = data['sessionId'] as String? ?? data['session_id'] as String?;
final taskId = data['taskId'] as String? ?? data['task_id'] as String?;
if (sessionId == null) {
state = state.copyWith(isStreaming: false, error: '未返回 sessionId');
return;
}
state = state.copyWith(sessionId: sessionId);
// Connect to agent WebSocket and subscribe
final wsClient = _ref.read(webSocketClientProvider);
await wsClient.connect('/ws/agent');
wsClient.send({
'event': 'subscribe_session',
'data': {'sessionId': sessionId, 'taskId': taskId},
});
// Listen for stream_event messages
_wsSubscription?.cancel();
_wsSubscription = wsClient.messages.listen((msg) {
final event = msg['event'] as String? ?? msg['type'] as String? ?? '';
if (event == 'stream_event' || event == 'message') {
_handleStreamEvent(msg['data'] as Map<String, dynamic>? ?? msg);
} else if (event == 'stream_end' || event == 'done' || event == 'complete') {
state = state.copyWith(isStreaming: false);
_wsSubscription?.cancel();
} else if (event == 'error') {
state = state.copyWith(
isStreaming: false,
error: msg['message'] as String? ?? '流式传输错误',
);
_wsSubscription?.cancel();
}
});
} catch (e) {
state = state.copyWith(isStreaming: false, error: e.toString());
}
}
void _handleStreamEvent(Map<String, dynamic> data) {
final type = data['type'] as String? ?? 'text';
final content = data['content'] as String? ?? data['text'] as String? ?? '';
final ChatContentType contentType;
if (type == 'tool_use' || type == 'tool_call') {
contentType = ChatContentType.toolUse;
} else {
contentType = ChatContentType.text;
}
// If the last message is an assistant text message that is still streaming,
// append to it instead of creating a new bubble.
if (contentType == ChatContentType.text && state.messages.isNotEmpty) {
final last = state.messages.last;
if (last.role == ChatRole.assistant && last.contentType == ChatContentType.text) {
final updated = ChatMessage(
id: last.id,
role: ChatRole.assistant,
contentType: ChatContentType.text,
content: last.content + content,
timestamp: last.timestamp,
);
state = state.copyWith(
messages: [...state.messages.sublist(0, state.messages.length - 1), updated],
);
return;
}
}
final msg = ChatMessage(
id: DateTime.now().microsecondsSinceEpoch.toString(),
role: ChatRole.assistant,
contentType: contentType,
content: content,
timestamp: DateTime.now(),
);
state = state.copyWith(messages: [...state.messages, msg]);
}
void clearChat() {
_wsSubscription?.cancel();
state = const ChatState();
}
@override
void dispose() {
_wsSubscription?.cancel();
super.dispose();
}
}
final chatMessagesProvider =
StateNotifierProvider<ChatNotifier, ChatState>((ref) {
return ChatNotifier(ref);
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Chat page ConsumerStatefulWidget // Chat page ConsumerStatefulWidget
@ -315,7 +136,7 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
final text = _messageController.text.trim(); final text = _messageController.text.trim();
if (text.isEmpty) return; if (text.isEmpty) return;
_messageController.clear(); _messageController.clear();
ref.read(chatMessagesProvider.notifier).sendMessage(text); ref.read(chatProvider.notifier).sendMessage(text);
_scrollToBottom(); _scrollToBottom();
} }
@ -331,26 +152,150 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
}); });
} }
// -- Message widget dispatch -----------------------------------------------
Widget _buildMessageWidget(ChatMessage message, ChatState chatState) {
final isLastMessage = chatState.messages.last.id == message.id;
final isStreamingNow = isLastMessage && chatState.isStreaming;
switch (message.type) {
case MessageType.toolUse:
case MessageType.toolResult:
if (message.toolExecution != null) {
return ToolExecutionCard(toolExecution: message.toolExecution!);
}
return MessageBubble(message: message);
case MessageType.approval:
if (message.approvalRequest != null) {
return ApprovalActionCard(
approvalRequest: message.approvalRequest!,
onApprove: () => ref.read(chatProvider.notifier)
.approveCommand(message.approvalRequest!.taskId),
onReject: (reason) => ref.read(chatProvider.notifier)
.rejectCommand(message.approvalRequest!.taskId, reason: reason),
);
}
return MessageBubble(message: message);
case MessageType.standingOrderDraft:
return _StandingOrderDraftCard(
draft: message.metadata ?? {},
onConfirm: () => ref.read(chatProvider.notifier)
.confirmStandingOrder(message.metadata ?? {}),
);
case MessageType.thinking:
// AI thinking content italic label + streaming text
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.78,
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'思考中...',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 11,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 4),
StreamTextWidget(
text: message.content,
isStreaming: isStreamingNow,
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
),
),
],
),
),
);
case MessageType.text:
default:
// User messages standard bubble
if (message.role == MessageRole.user) {
return MessageBubble(message: message);
}
// AI text messages use StreamTextWidget when actively streaming
if (isStreamingNow) {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.78,
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
),
child: StreamTextWidget(
text: message.content,
isStreaming: true,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 15,
),
),
),
);
}
return MessageBubble(message: message);
}
}
// -- Build ----------------------------------------------------------------- // -- Build -----------------------------------------------------------------
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final chatState = ref.watch(chatMessagesProvider); final chatState = ref.watch(chatProvider);
// Auto-scroll when messages change // Auto-scroll when messages change
ref.listen(chatMessagesProvider, (_, __) => _scrollToBottom()); ref.listen(chatProvider, (_, __) => _scrollToBottom());
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('AI 对话'), title: const Text('AI 对话'),
actions: [ actions: [
// Stop button during streaming
if (chatState.isStreaming)
IconButton(
icon: const Icon(Icons.stop_circle_outlined),
tooltip: '停止',
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
),
if (chatState.messages.isNotEmpty) if (chatState.messages.isNotEmpty)
IconButton( IconButton(
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
tooltip: '清空对话', tooltip: '清空对话',
onPressed: () => ref.read(chatMessagesProvider.notifier).clearChat(), onPressed: () => ref.read(chatProvider.notifier).clearChat(),
), ),
// Voice input button (TODO 40) // Voice input button
GestureDetector( GestureDetector(
onLongPressStart: (_) => _startListening(), onLongPressStart: (_) => _startListening(),
onLongPressEnd: (_) => _stopListening(autoSubmit: true), onLongPressEnd: (_) => _stopListening(autoSubmit: true),
@ -388,12 +333,16 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
actions: [ actions: [
TextButton( TextButton(
onPressed: () => onPressed: () =>
ref.read(chatMessagesProvider.notifier).clearChat(), ref.read(chatProvider.notifier).clearChat(),
child: const Text('关闭'), child: const Text('关闭'),
), ),
], ],
), ),
// Agent status bar
if (chatState.agentStatus != AgentStatus.idle && chatState.agentStatus != AgentStatus.error)
_AgentStatusBar(status: chatState.agentStatus),
// Message list // Message list
Expanded( Expanded(
child: chatState.messages.isEmpty child: chatState.messages.isEmpty
@ -418,13 +367,17 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
: ListView.builder( : ListView.builder(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
itemCount: chatState.messages.length + (chatState.isStreaming ? 1 : 0), itemCount: chatState.messages.length +
(chatState.agentStatus == AgentStatus.thinking ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
// Trailing thinking indicator
if (index == chatState.messages.length) { if (index == chatState.messages.length) {
// Typing indicator return const AgentThinkingIndicator();
return _TypingIndicator();
} }
return _ChatBubble(message: chatState.messages[index]); return _buildMessageWidget(
chatState.messages[index],
chatState,
);
}, },
), ),
), ),
@ -470,14 +423,34 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
), ),
// Input row // Input row
Container( _buildInputArea(chatState),
],
),
);
}
Widget _buildInputArea(ChatState chatState) {
final isDisabled = chatState.isStreaming || chatState.agentStatus == AgentStatus.awaitingApproval;
return Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surface, color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))), border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
), ),
child: SafeArea( child: SafeArea(
child: Row( child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (chatState.agentStatus == AgentStatus.awaitingApproval)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'等待审批中...',
style: TextStyle(color: AppColors.warning, fontSize: 12),
),
),
Row(
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
@ -491,26 +464,26 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
), ),
textInputAction: TextInputAction.send, textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(), onSubmitted: (_) => _send(),
enabled: !chatState.isStreaming, enabled: !isDisabled,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
if (chatState.isStreaming)
IconButton( IconButton(
icon: chatState.isStreaming icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error),
? const SizedBox( tooltip: '停止',
width: 24, onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.send), else
onPressed: chatState.isStreaming ? null : _send, IconButton(
icon: const Icon(Icons.send),
onPressed: isDisabled ? null : _send,
), ),
], ],
), ),
),
),
], ],
), ),
),
); );
} }
@ -527,81 +500,114 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Private helper widgets // Agent status bar
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
class _ChatBubble extends StatelessWidget { class _AgentStatusBar extends StatelessWidget {
final ChatMessage message; final AgentStatus status;
const _ChatBubble({required this.message}); const _AgentStatusBar({required this.status});
bool get _isUser => message.role == ChatRole.user;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (message.contentType == ChatContentType.toolUse) { final (String label, Color color, IconData icon) = switch (status) {
return _ToolUseCard(content: message.content); AgentStatus.thinking => ('AI 正在思考...', AppColors.info, Icons.psychology),
} AgentStatus.executing => ('正在执行命令...', AppColors.primary, Icons.terminal),
AgentStatus.awaitingApproval => ('等待审批', AppColors.warning, Icons.shield_outlined),
_ => ('', AppColors.textMuted, Icons.info_outline),
};
return Align( if (label.isEmpty) return const SizedBox.shrink();
alignment: _isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container( return Container(
margin: const EdgeInsets.symmetric(vertical: 4), width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
constraints: BoxConstraints( color: color.withOpacity(0.1),
maxWidth: MediaQuery.of(context).size.width * 0.78, child: Row(
), children: [
decoration: BoxDecoration( SizedBox(
color: _isUser ? AppColors.primary : AppColors.surface, width: 16,
borderRadius: BorderRadius.only( height: 16,
topLeft: const Radius.circular(16), child: status == AgentStatus.executing
topRight: const Radius.circular(16), ? CircularProgressIndicator(strokeWidth: 2, color: color)
bottomLeft: Radius.circular(_isUser ? 16 : 4), : Icon(icon, size: 16, color: color),
bottomRight: Radius.circular(_isUser ? 4 : 16),
),
),
child: Text(
message.content,
style: TextStyle(
color: _isUser ? Colors.white : AppColors.textPrimary,
fontSize: 15,
), ),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w500),
), ),
],
), ),
); );
} }
} }
class _ToolUseCard extends StatelessWidget { // ---------------------------------------------------------------------------
final String content; // Standing order draft card
const _ToolUseCard({required this.content}); // ---------------------------------------------------------------------------
class _StandingOrderDraftCard extends StatelessWidget {
final Map<String, dynamic> draft;
final VoidCallback onConfirm;
const _StandingOrderDraftCard({
required this.draft,
required this.onConfirm,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final name = draft['name'] as String? ?? draft['orderName'] as String? ?? '未命名指令';
final cron = draft['cron'] as String? ?? draft['schedule'] as String? ?? '';
final command = draft['command'] as String? ?? draft['prompt'] as String? ?? '';
final targets = draft['targets'] as List? ?? draft['servers'] as List? ?? [];
return Container( return Container(
margin: const EdgeInsets.symmetric(vertical: 6), margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceLight.withOpacity(0.5), color: AppColors.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.primary.withOpacity(0.3)), border: Border.all(color: AppColors.primary.withOpacity(0.4)),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header
Row( Row(
children: [ children: [
Icon(Icons.code, size: 16, color: AppColors.primary), Icon(Icons.schedule, size: 18, color: AppColors.primary),
const SizedBox(width: 6), const SizedBox(width: 8),
Text( Expanded(
'工具执行', child: Text(
'常驻指令草案',
style: TextStyle( style: TextStyle(
color: AppColors.primary,
fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 14,
color: AppColors.primary,
),
), ),
), ),
], ],
), ),
const SizedBox(height: 10),
// Name
Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
if (cron.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.timer_outlined, size: 14, color: AppColors.textMuted),
const SizedBox(width: 6),
Text(cron, style: const TextStyle(color: AppColors.textSecondary, fontSize: 12, fontFamily: 'monospace')),
],
),
],
if (command.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
width: double.infinity, width: double.infinity,
@ -611,88 +617,57 @@ class _ToolUseCard extends StatelessWidget {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: SelectableText( child: SelectableText(
content, command,
style: const TextStyle( style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppColors.textSecondary),
fontFamily: 'monospace',
fontSize: 12,
color: AppColors.textSecondary,
), ),
), ),
],
if (targets.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.dns, size: 14, color: AppColors.textMuted),
const SizedBox(width: 6),
Text(
'目标: ${targets.join(", ")}',
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12),
),
],
),
],
const SizedBox(height: 12),
// Action buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: () {
// Dismiss no specific API call needed
},
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.textSecondary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: Size.zero,
),
child: const Text('取消', style: TextStyle(fontSize: 13)),
),
const SizedBox(width: 8),
FilledButton(
onPressed: onConfirm,
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: Size.zero,
),
child: const Text('确认', style: TextStyle(fontSize: 13)),
),
],
), ),
], ],
), ),
); );
} }
} }
class _TypingIndicator extends StatefulWidget {
@override
State<_TypingIndicator> createState() => _TypingIndicatorState();
}
class _TypingIndicatorState extends State<_TypingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
),
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
final delay = i * 0.33;
final t = ((_controller.value + delay) % 1.0);
final opacity = (t < 0.5) ? 0.3 + t * 1.0 : 1.3 - t * 1.0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Opacity(
opacity: opacity.clamp(0.3, 1.0),
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.textSecondary,
shape: BoxShape.circle,
),
),
),
);
}),
);
},
),
),
);
}
}
/// A mini [AnimatedWidget] wrapper used in lieu of [AnimatedBuilder].
/// Flutter's actual class is [AnimatedBuilder]; we re-export via this alias
/// so that the code reads naturally and works with the stable API.
/// (AnimatedBuilder is the stable name since Flutter 3.x.)