新增 mobile/:畅联手机端 Flutter 工程(B-58)

- 登录:工号+密码走 account-service /api/login,自动登录、被踢下线回登录页
- 消息:会话列表(未读角标/免打扰/时间)、单聊群聊、文字/语音/图片/文件消息、失败重发、历史分页
- 通讯录:新的同事/我的群聊入口、按部门分组(好友 ex 字段)、搜索
- 通话:一对一语音通话(信令走 OpenIM 自定义消息 + LiveKit,token 走 /api/rtc_token)
- 界面按确认效果图 m1/m2/m3 实现,主色 #3B87F5,四态视图齐全,无英文残留
This commit is contained in:
KIMI
2026-08-09 01:28:12 +08:00
parent bef4ffcf6f
commit b95159f7eb
27 changed files with 5077 additions and 0 deletions
+274
View File
@@ -0,0 +1,274 @@
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)),
);
}
}
}