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) { 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 createState() => _ContactsScreenState(); } class _ContactsScreenState extends State { List _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 _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 = >{}; 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 _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)), ); } } }