聊天App是实时通信的经典场景。与天气App的"请求-响应"模式不同,聊天App需要WebSocket实现双向实时通信,消息列表需要高效滚动和差异化渲染,还要处理在线状态、消息已读、输入中等即时反馈。本课将构建一个功能完整的聊天App。
// lib/models/chat_message.dart
class ChatMessage {
final String id;
final String content;
final String senderId;
final String senderName;
final String? senderAvatar;
final MessageType type;
final MessageStatus status;
final DateTime timestamp;
final String? replyTo; // 回复的消息ID
final Map<String, dynamic>? extra; // 扩展数据
const ChatMessage({
required this.id,
required this.content,
required this.senderId,
required this.senderName,
this.senderAvatar,
this.type = MessageType.text,
this.status = MessageStatus.sent,
required this.timestamp,
this.replyTo,
this.extra,
});
bool get isMe => senderId == currentUserId;
bool get isImage => type == MessageType.image;
bool get isSystem => type == MessageType.system;
factory ChatMessage.fromJson(Map<String, dynamic> json) => ChatMessage(
id: json['id'] as String,
content: json['content'] as String,
senderId: json['sender_id'] as String,
senderName: json['sender_name'] as String,
senderAvatar: json['sender_avatar'] as String?,
type: MessageType.values.firstWhere(
(t) => t.name == json['type'], orElse: () => MessageType.text),
status: MessageStatus.values.firstWhere(
(s) => s.name == json['status'], orElse: () => MessageStatus.sent),
timestamp: DateTime.parse(json['timestamp'] as String),
replyTo: json['reply_to'] as String?,
extra: json['extra'] as Map<String, dynamic>?,
);
Map<String, dynamic> toJson() => {
'id': id, 'content': content, 'sender_id': senderId,
'sender_name': senderName, 'type': type.name,
'status': status.name, 'timestamp': timestamp.toIso8601String(),
'reply_to': replyTo, 'extra': extra,
};
}
enum MessageType { text, image, file, system, voice }
enum MessageStatus { sending, sent, delivered, read, failed }
extension MessageStatusUI on MessageStatus {
IconData get icon => switch (this) {
MessageStatus.sending => Icons.access_time,
MessageStatus.sent => Icons.check,
MessageStatus.delivered => Icons.done_all,
MessageStatus.read => Icons.done_all,
MessageStatus.failed => Icons.error_outline,
};
Color get color => switch (this) {
MessageStatus.sending => Colors.grey,
MessageStatus.sent => Colors.grey,
MessageStatus.delivered => Colors.grey,
MessageStatus.read => Colors.blue,
MessageStatus.failed => Colors.red,
};
}
// 会话模型
class Conversation {
final String id;
final String name;
final String? avatar;
final bool isGroup;
final List<String> participants;
final ChatMessage? lastMessage;
final int unreadCount;
final bool isOnline;
final bool isTyping;
const Conversation({
required this.id, required this.name,
this.avatar, this.isGroup = false,
this.participants = const [],
this.lastMessage, this.unreadCount = 0,
this.isOnline = false, this.isTyping = false,
});
}
// lib/services/chat_service.dart
class ChatService {
WebSocketChannel? _channel;
final String _serverUrl;
final String _userId;
final _messageController = StreamController<ChatMessage>.broadcast();
final _statusController = StreamController<UserStatus>.broadcast();
final _typingController = StreamController<TypingEvent>.broadcast();
Stream<ChatMessage> get onMessage => _messageController.stream;
Stream<UserStatus> get onStatusChange => _statusController.stream;
Stream<TypingEvent> get onTyping => _typingController.stream;
ChatService({required String serverUrl, required String userId})
: _serverUrl = serverUrl, _userId = userId;
// 连接WebSocket
Future<void> connect() async {
try {
_channel = WebSocketChannel.connect(Uri.parse('$_serverUrl/ws?user=$_userId'));
_channel!.stream.listen(
(data) {
final json = jsonDecode(data as String) as Map<String, dynamic>;
_handleMessage(json);
},
onError: (error) => _reconnect(),
onDone: () => _reconnect(),
);
} catch (e) {
await _reconnect();
}
}
// 处理收到的消息
void _handleMessage(Map<String, dynamic> json) {
final type = json['type'] as String;
switch (type) {
case 'message':
_messageController.add(ChatMessage.fromJson(json['data']));
break;
case 'status':
_statusController.add(UserStatus.fromJson(json['data']));
break;
case 'typing':
_typingController.add(TypingEvent.fromJson(json['data']));
break;
}
}
// 发送文本消息
void sendMessage(String conversationId, String content) {
final msg = ChatMessage(
id: const Uuid().v4(),
content: content,
senderId: _userId,
senderName: 'Me',
timestamp: DateTime.now(),
);
_channel?.sink.add(jsonEncode({
'type': 'message',
'conversation_id': conversationId,
'data': msg.toJson(),
}));
}
// 发送"正在输入"状态
void sendTyping(String conversationId) {
_channel?.sink.add(jsonEncode({
'type': 'typing',
'conversation_id': conversationId,
'user_id': _userId,
}));
}
// 标记已读
void markAsRead(String conversationId, List<String> messageIds) {
_channel?.sink.add(jsonEncode({
'type': 'read',
'conversation_id': conversationId,
'message_ids': messageIds,
}));
}
// 自动重连
Future<void> _reconnect() async {
await Future.delayed(const Duration(seconds: 3));
await connect();
}
void disconnect() {
_channel?.sink.close();
_messageController.close();
_statusController.close();
_typingController.close();
}
}
// lib/widgets/message_bubble.dart
class MessageBubble extends StatelessWidget {
final ChatMessage message;
final ChatMessage? replyTo;
final VoidCallback onLongPress;
final VoidCallback onAvatarTap;
const MessageBubble({
required this.message,
this.replyTo,
required this.onLongPress,
required this.onAvatarTap,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: message.isMe ? 48 : 8,
right: message.isMe ? 8 : 48,
top: 4, bottom: 4,
),
child: Row(
mainAxisAlignment:
message.isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!message.isMe)
GestureDetector(
onTap: onAvatarTap,
child: CircleAvatar(
radius: 16,
backgroundImage: message.senderAvatar != null
? NetworkImage(message.senderAvatar!) : null,
child: message.senderAvatar == null
? Text(message.senderName[0]) : null,
),
),
const SizedBox(width: 8),
Flexible(
child: GestureDetector(
onLongPress: onLongPress,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: message.isMe
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(18),
topRight: const Radius.circular(18),
bottomLeft: Radius.circular(message.isMe ? 18 : 4),
bottomRight: Radius.circular(message.isMe ? 4 : 18),
),
),
child: Column(
crossAxisAlignment: message.isMe
? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
// 回复引用
if (replyTo != null)
Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: Colors.black12,
borderRadius: BorderRadius.circular(8),
),
child: Text(replyTo!.content,
maxLines: 2, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12)),
),
// 消息内容
Text(message.content,
style: TextStyle(
color: message.isMe ? Colors.white : null,
)),
const SizedBox(height: 4),
// 时间+状态
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(fontSize: 11,
color: message.isMe ? Colors.white70 : Colors.grey),
),
if (message.isMe) ...[
const SizedBox(width: 4),
Icon(message.status.icon,
size: 14, color: message.status.color),
],
],
),
],
),
),
),
),
],
),
);
}
}
// lib/widgets/chat_input.dart
class ChatInput extends StatefulWidget {
final Function(String) onSend;
final VoidCallback onTyping;
const ChatInput({required this.onSend, required this.onTyping});
@override
State<ChatInput> createState() => _ChatInputState();
}
class _ChatInputState extends State<ChatInput> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
Timer? _typingTimer;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [BoxShadow(
color: Colors.black12, blurRadius: 4, offset: const Offset(0, -2))],
),
child: SafeArea(
child: Row(
children: [
IconButton(icon: const Icon(Icons.add_circle_outline),
onPressed: _showAttachmentSheet),
Expanded(
child: TextField(
controller: _controller,
focusNode: _focusNode,
onChanged: _onChanged,
decoration: InputDecoration(
hintText: '输入消息...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceVariant,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
),
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.send),
color: Theme.of(context).colorScheme.primary,
onPressed: _send,
),
],
),
),
);
}
void _onChanged(String value) {
// 触发"正在输入"事件
widget.onTyping();
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 2), () {
// 停止输入
});
}
void _send() {
final text = _controller.text.trim();
if (text.isEmpty) return;
widget.onSend(text);
_controller.clear();
}
}
pointycastle对消息内容加密你已构建了一个聊天App!掌握了:
下一课预告:电商App——商品列表、购物车、支付流程、搜索,构建一个完整的购物体验!