Files
tongxunruanjian/mobile/lib/screens/contacts_screen.dart
T
KIMI b95159f7eb 新增 mobile/:畅联手机端 Flutter 工程(B-58)
- 登录:工号+密码走 account-service /api/login,自动登录、被踢下线回登录页
- 消息:会话列表(未读角标/免打扰/时间)、单聊群聊、文字/语音/图片/文件消息、失败重发、历史分页
- 通讯录:新的同事/我的群聊入口、按部门分组(好友 ex 字段)、搜索
- 通话:一对一语音通话(信令走 OpenIM 自定义消息 + LiveKit,token 走 /api/rtc_token)
- 界面按确认效果图 m1/m2/m3 实现,主色 #3B87F5,四态视图齐全,无英文残留
2026-08-09 01:28:12 +08:00

275 lines
8.9 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.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),
);
}
/// 点联系人直接进单聊
Future<void> _openChat(FriendInfo f, String name) async {
try {
final conversation = await OpenIM.iMManager.conversationManager.getOneConversation(
sourceID: f.friendUserID ?? '',
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)),
);
}
}
}