Files
tongxunruanjian/mobile/lib/screens/contacts_screen.dart
T
编码工程师andmultica-agent d6ef6eb9a4 fix(mobile): 通讯录进会话空对方 + 登录后会话列表空态(B-52 无法发送消息根因)
- contacts_screen 用 f.friendUserID(SDK 恒为空)打开会话,导致建出
  si__<空对方> 会话,所有消息发给空 userID 全部失败;改用 f.userID
- login 后补一次 refreshConversations:SDK 登录期间同步回调先于
  loggedIn=true 触发,refreshConversations 因登录态守卫提前返回,
  会话列表永久停在空态

双机实测:修复前 sendMessage userID='' 失败;修复后 A→B 消息
正常送达(B 端 onRecvNewMessage 收到 fixverify999)

Co-authored-by: multica-agent <github@multica.ai>
2026-08-16 02:14:13 +08:00

285 lines
9.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import '../services/im_service.dart';
import '../theme.dart';
import '../widgets/avatar.dart';
import '../widgets/search_box.dart';
import 'add_friend_screen.dart';
import 'chat_screen.dart';
import 'group_list_screen.dart';
import 'new_friends_screen.dart';
/// 同事扩展信息(friendInfo 的 ex 字段,JSON 格式)
class _ContactEx {
final String department;
final String title;
_ContactEx(this.department, this.title);
/// 解析 ex 字段;解析不到归「同事」组、无职务
static _ContactEx parse(String? ex) {
if (ex == null || ex.isEmpty) return _ContactEx('同事', '');
try {
final map = jsonDecode(ex);
if (map is Map<String, dynamic>) {
return _ContactEx(
map['department']?.toString() ?? '同事',
map['title']?.toString() ?? '',
);
}
} catch (_) {
// 不是 JSON 按默认组处理
}
return _ContactEx('同事', '');
}
}
/// 通讯录页(效果图 m3):
/// 标题 + 添加人图标、搜索框、「新的同事」「我的群聊」两行入口、按部门分组的联系人列表。
/// 数据源:OpenIM 好友列表,部门/职务取自好友 ex 字段(JSON)。
class ContactsScreen extends StatefulWidget {
const ContactsScreen({super.key});
@override
State<ContactsScreen> createState() => _ContactsScreenState();
}
class _ContactsScreenState extends State<ContactsScreen> {
List<FriendInfo> _friends = [];
int _pendingRequests = 0;
bool _loading = true;
bool _failed = false;
String _keyword = '';
@override
void initState() {
super.initState();
_load();
// 好友变更时刷新(IMService 会 notifyListeners
IMService.instance.addListener(_onImChanged);
}
@override
void dispose() {
IMService.instance.removeListener(_onImChanged);
super.dispose();
}
void _onImChanged() => _load();
Future<void> _load() async {
if (!IMService.instance.loggedIn) return;
try {
final friends = await OpenIM.iMManager.friendshipManager.getFriendList();
final applications = await OpenIM.iMManager.friendshipManager.getFriendApplicationListAsRecipient();
if (!mounted) return;
setState(() {
_friends = friends;
_pendingRequests = applications.where((a) => a.handleResult == 0).length;
_loading = false;
_failed = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_loading = false;
_failed = true;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('通讯录'),
actions: [
IconButton(
icon: const Icon(Icons.person_add_alt, size: 24),
onPressed: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const AddFriendScreen()))
.then((_) => _load());
},
),
],
),
body: Column(
children: [
SearchBox(onChanged: (v) => setState(() => _keyword = v)),
Expanded(child: _buildBody()),
],
),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(child: CircularProgressIndicator(color: AppColors.primary));
}
if (_failed) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('通讯录没加载出来', style: TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)),
const SizedBox(height: AppGap.x2),
const Text('检查一下网络,再点重试', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)),
const SizedBox(height: AppGap.x4),
OutlinedButton(onPressed: _load, child: const Text('重试')),
],
),
);
}
// 搜索时只过滤联系人,隐藏两行功能入口与分组
if (_keyword.isNotEmpty) {
final matched = _friends.where((f) => _displayName(f).toLowerCase().contains(_keyword.toLowerCase())).toList();
if (matched.isEmpty) {
return const Center(
child: Text('没有找到相关同事', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)),
);
}
return ListView.separated(
itemCount: matched.length,
separatorBuilder: (_, __) => const Divider(indent: 76),
itemBuilder: (_, i) => _contactTile(matched[i]),
);
}
// 按部门分组
final groups = <String, List<FriendInfo>>{};
for (final f in _friends) {
final ex = _ContactEx.parse(f.ex);
groups.putIfAbsent(ex.department, () => []).add(f);
}
final departments = groups.keys.toList()..sort((a, b) => a == '同事' ? 1 : b == '同事' ? -1 : a.compareTo(b));
return ListView(
children: [
_entryTile(),
const SizedBox(height: AppGap.x2),
for (final dept in departments) ...[
_groupHeader(dept, groups[dept]!.length),
for (var i = 0; i < groups[dept]!.length; i++) ...[
_contactTile(groups[dept]![i]),
if (i < groups[dept]!.length - 1) const Divider(indent: 76),
],
],
],
);
}
/// 「新的同事」「我的群聊」两行功能入口
Widget _entryTile() {
return Column(
children: [
ListTile(
leading: _entryIcon(Icons.person_add_alt),
title: const Text('新的同事', style: TextStyle(fontSize: AppFont.body)),
trailing: _pendingRequests > 0
? Container(
constraints: const BoxConstraints(minWidth: 20),
height: 20,
padding: const EdgeInsets.symmetric(horizontal: 5),
alignment: Alignment.center,
decoration: BoxDecoration(color: AppColors.danger, borderRadius: BorderRadius.circular(10)),
child: Text(
_pendingRequests > 99 ? '99+' : '$_pendingRequests',
style: const TextStyle(fontSize: AppFont.small, color: Colors.white),
),
)
: const Icon(Icons.chevron_right, color: AppColors.textSecondary),
onTap: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const NewFriendsScreen()))
.then((_) => _load());
},
),
const Divider(indent: 76),
ListTile(
leading: _entryIcon(Icons.people_outline),
title: const Text('我的群聊', style: TextStyle(fontSize: AppFont.body)),
trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const GroupListScreen()));
},
),
],
);
}
Widget _entryIcon(IconData icon) {
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.bubbleMine,
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: AppColors.primary),
);
}
Widget _groupHeader(String department, int count) {
return Container(
width: double.infinity,
color: AppColors.pinnedBg,
padding: const EdgeInsets.symmetric(horizontal: AppGap.x4, vertical: AppGap.x2),
child: Text(
'$department$count 人)',
style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary),
),
);
}
String _displayName(FriendInfo f) {
if (f.remark?.isNotEmpty == true) return f.remark!;
return f.nickname ?? f.userID ?? f.friendUserID ?? '';
}
Widget _contactTile(FriendInfo f) {
final name = _displayName(f);
final ex = _ContactEx.parse(f.ex);
return ListTile(
leading: NameAvatar(name: name, faceURL: f.faceURL),
title: Text(name, style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)),
trailing: ex.title.isNotEmpty
? Text(ex.title, style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary))
: null,
onTap: () => _openChat(f, name),
);
}
/// 点联系人直接进单聊。
/// 注意:OpenIM 好友接口返回的字段是 `userID``friendUserID` 恒为空),
/// 必须用 `userID` 作为会话源,否则会建出「空对方」会话导致所有消息发不出去。
Future<void> _openChat(FriendInfo f, String name) async {
final peerID = f.userID ?? f.friendUserID ?? '';
if (peerID.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('对方信息不完整,无法打开会话'), duration: Duration(seconds: 2)),
);
return;
}
try {
final conversation = await OpenIM.iMManager.conversationManager.getOneConversation(
sourceID: peerID,
sessionType: ConversationType.single,
);
conversation.showName = name;
if (!mounted) return;
Navigator.of(context).push(MaterialPageRoute(builder: (_) => ChatScreen(conversation: conversation)));
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('打开会话失败,请检查网络'), duration: Duration(seconds: 2)),
);
}
}
}