- 登录:工号+密码走 account-service /api/login,自动登录、被踢下线回登录页 - 消息:会话列表(未读角标/免打扰/时间)、单聊群聊、文字/语音/图片/文件消息、失败重发、历史分页 - 通讯录:新的同事/我的群聊入口、按部门分组(好友 ex 字段)、搜索 - 通话:一对一语音通话(信令走 OpenIM 自定义消息 + LiveKit,token 走 /api/rtc_token) - 界面按确认效果图 m1/m2/m3 实现,主色 #3B87F5,四态视图齐全,无英文残留
275 lines
8.9 KiB
Dart
275 lines
8.9 KiB
Dart
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)),
|
||
);
|
||
}
|
||
}
|
||
}
|