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