新增 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
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import '../theme.dart';
/// 添加同事:输入对方工号(userID)发送好友申请
class AddFriendScreen extends StatefulWidget {
const AddFriendScreen({super.key});
@override
State<AddFriendScreen> createState() => _AddFriendScreenState();
}
class _AddFriendScreenState extends State<AddFriendScreen> {
final TextEditingController _idCtrl = TextEditingController();
bool _sending = false;
@override
void dispose() {
_idCtrl.dispose();
super.dispose();
}
Future<void> _send() async {
final userID = _idCtrl.text.trim();
if (userID.isEmpty) {
_toast('请输入对方工号');
return;
}
setState(() => _sending = true);
try {
await OpenIM.iMManager.friendshipManager.addFriend(userID: userID, reason: '你好,我想加你为同事');
if (!mounted) return;
_toast('申请已发送,等对方通过');
Navigator.of(context).pop();
} catch (_) {
_toast('发送失败,请确认工号正确、网络正常');
} finally {
if (mounted) setState(() => _sending = false);
}
}
void _toast(String text) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text), duration: const Duration(seconds: 2)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('添加同事')),
body: Padding(
padding: const EdgeInsets.all(AppGap.x4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _idCtrl,
decoration: InputDecoration(
hintText: '请输入对方工号',
hintStyle: const TextStyle(color: AppColors.textSecondary),
filled: true,
fillColor: AppColors.searchBg,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: AppGap.x6),
SizedBox(
height: 48,
child: FilledButton(
onPressed: _sending ? null : _send,
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: _sending
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('发送申请', style: TextStyle(fontSize: AppFont.body)),
),
),
],
),
),
);
}
}
+185
View File
@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import '../nav.dart';
import '../services/call_service.dart';
import '../theme.dart';
import '../utils/format.dart';
import '../widgets/avatar.dart';
/// 一对一语音通话界面:呼出 / 来电 / 通话中三种状态。
/// 界面只读 CallService 的状态,信令与房间管理都在 service 里。
class CallScreen extends StatefulWidget {
const CallScreen({super.key});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
CallService get _call => CallService.instance;
@override
void initState() {
super.initState();
// 通话状态变化时刷新界面;回到空闲态时关闭页面
_call.addListener(_onCallChanged);
}
@override
void dispose() {
_call.removeListener(_onCallChanged);
super.dispose();
}
void _onCallChanged() {
if (!mounted) return;
if (_call.phase == CallPhase.idle) {
final hint = _call.takeEndHint();
Navigator.of(context).maybePop();
if (hint != null) {
// 页面关闭后,用全局导航键把提示弹到底层页面上
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = navigatorKey.currentContext;
if (ctx != null) {
ScaffoldMessenger.maybeOf(ctx)?.showSnackBar(
SnackBar(content: Text(hint), duration: const Duration(seconds: 2)),
);
}
});
}
return;
}
setState(() {});
}
@override
Widget build(BuildContext context) {
final phase = _call.phase;
return Scaffold(
backgroundColor: const Color(0xFF2B3038),
body: SafeArea(
child: Column(
children: [
const SizedBox(height: AppGap.x12),
NameAvatar(name: _call.peerName, size: 88),
const SizedBox(height: AppGap.x4),
Text(
_call.peerName,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Colors.white),
),
const SizedBox(height: AppGap.x2),
Text(
_statusText(phase),
style: const TextStyle(fontSize: AppFont.sub, color: Color(0xFFB0B6BF)),
),
const Spacer(),
_buttons(phase),
const SizedBox(height: AppGap.x12),
],
),
),
);
}
String _statusText(CallPhase phase) {
switch (phase) {
case CallPhase.outgoing:
return '正在呼叫,等待对方接听…';
case CallPhase.incoming:
return '邀请你语音通话';
case CallPhase.incall:
return FormatUtils.callDuration(_call.callSeconds);
case CallPhase.idle:
return '通话已结束';
}
}
Widget _buttons(CallPhase phase) {
switch (phase) {
case CallPhase.outgoing:
return _roundButton(icon: Icons.call_end, color: AppColors.danger, label: '取消', onTap: _call.leave);
case CallPhase.incoming:
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_roundButton(icon: Icons.call_end, color: AppColors.danger, label: '拒绝', onTap: _call.reject),
_roundButton(icon: Icons.call, color: const Color(0xFF2BA245), label: '接听', onTap: _call.accept),
],
);
case CallPhase.incall:
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_toggleButton(
icon: _call.micOn ? Icons.mic : Icons.mic_off,
label: _call.micOn ? '静音' : '已静音',
active: !_call.micOn,
onTap: _call.toggleMic,
),
_roundButton(icon: Icons.call_end, color: AppColors.danger, label: '挂断', onTap: _call.hangup),
_toggleButton(
icon: Icons.volume_up,
label: _call.speakerOn ? '免提开' : '免提',
active: _call.speakerOn,
onTap: _call.toggleSpeaker,
),
],
);
case CallPhase.idle:
return const SizedBox.shrink();
}
}
Widget _roundButton({
required IconData icon,
required Color color,
required String label,
required VoidCallback onTap,
}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(32),
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
child: Icon(icon, size: 30, color: Colors.white),
),
),
const SizedBox(height: AppGap.x2),
Text(label, style: const TextStyle(fontSize: AppFont.sub, color: Color(0xFFB0B6BF))),
],
);
}
Widget _toggleButton({
required IconData icon,
required String label,
required bool active,
required VoidCallback onTap,
}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(32),
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: active ? Colors.white : const Color(0xFF3A4048),
shape: BoxShape.circle,
),
child: Icon(icon, size: 28, color: active ? AppColors.textPrimary : Colors.white),
),
),
const SizedBox(height: AppGap.x2),
Text(label, style: const TextStyle(fontSize: AppFont.sub, color: Color(0xFFB0B6BF))),
],
);
}
}
+414
View File
@@ -0,0 +1,414 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:image_picker/image_picker.dart';
import 'package:just_audio/just_audio.dart';
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import '../services/call_service.dart';
import '../services/im_service.dart';
import '../theme.dart';
import '../utils/format.dart';
import '../widgets/chat_input_bar.dart';
import '../widgets/message_bubble.dart';
import 'call_screen.dart';
/// 单聊 / 群聊聊天页(效果图 m2)
class ChatScreen extends StatefulWidget {
final ConversationInfo conversation;
const ChatScreen({super.key, required this.conversation});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
/// 消息列表,新消息在前(配合 reverse 列表)
final List<Message> _messages = [];
final ScrollController _scroll = ScrollController();
final AudioPlayer _player = AudioPlayer();
final Dio _dio = Dio();
StreamSubscription<Message>? _msgSub;
StreamSubscription<String>? _revokeSub;
bool _loadingHistory = false;
bool _hasMore = true;
String? _playingVoiceId;
static const int _pageSize = 30;
/// 相邻消息间隔超过 5 分钟显示时间分隔条
static const int _dividerGapMillis = 5 * 60 * 1000;
ConversationInfo get _conv => widget.conversation;
bool get _isGroup =>
_conv.conversationType == ConversationType.superGroup || _conv.conversationType == ConversationType.group;
String get _peerID => _conv.userID ?? '';
String get _groupID => _conv.groupID ?? '';
String get _title => _conv.showName ?? '';
@override
void initState() {
super.initState();
_loadHistory(first: true);
IMService.instance.markConversationRead(_conv.conversationID);
_msgSub = IMService.instance.onNewMessage.listen(_onNewMessage);
_revokeSub = IMService.instance.onMessageRevoked.listen(_onRevoked);
_scroll.addListener(() {
// 滚动到顶部加载更早的历史消息
if (_scroll.position.pixels >= _scroll.position.maxScrollExtent - 40) {
_loadHistory();
}
});
}
@override
void dispose() {
_msgSub?.cancel();
_revokeSub?.cancel();
_scroll.dispose();
_player.dispose();
super.dispose();
}
// ---------------- 历史消息 ----------------
Future<void> _loadHistory({bool first = false}) async {
if (_loadingHistory || (!_hasMore && !first)) return;
_loadingHistory = true;
try {
final result = await OpenIM.iMManager.messageManager.getAdvancedHistoryMessageList(
conversationID: _conv.conversationID,
count: _pageSize,
startMsg: first ? null : (_messages.isEmpty ? null : _messages.last),
);
final list = result.messageList ?? [];
if (!mounted) return;
setState(() {
if (first) _messages.clear();
_messages.addAll(list);
if (list.length < _pageSize || result.isEnd == true) _hasMore = false;
});
} catch (_) {
if (first && mounted) _toast('消息没加载出来,请检查网络');
} finally {
_loadingHistory = false;
}
}
// ---------------- 新消息 / 撤回 ----------------
bool _belongsHere(Message msg) {
if (_isGroup) return msg.groupID == _groupID;
final myID = IMService.instance.currentUserID;
// 对方发来的,或自己在别的设备发的
return (msg.sendID == _peerID && msg.recvID == myID) || (msg.sendID == myID && msg.recvID == _peerID);
}
void _onNewMessage(Message msg) {
if (!_belongsHere(msg)) return;
if (!mounted) return;
setState(() {
final idx = _messages.indexWhere((m) => m.clientMsgID == msg.clientMsgID);
if (idx >= 0) {
_messages[idx] = msg;
} else {
_messages.insert(0, msg);
}
});
IMService.instance.markConversationRead(_conv.conversationID);
}
void _onRevoked(String clientMsgID) {
final idx = _messages.indexWhere((m) => m.clientMsgID == clientMsgID);
if (idx < 0 || !mounted) return;
setState(() => _messages.removeAt(idx));
_toast('对方撤回了一条消息');
}
// ---------------- 发送 ----------------
Future<void> _sendMessage(Message message) async {
setState(() {
message.status = MessageStatus.sending;
_messages.insert(0, message);
});
if (_scroll.hasClients) _scroll.jumpTo(0);
try {
final sent = await OpenIM.iMManager.messageManager.sendMessage(
message: message,
userID: _isGroup ? null : _peerID,
groupID: _isGroup ? _groupID : null,
offlinePushInfo: OfflinePushInfo(),
);
if (!mounted) return;
setState(() => message.status = sent.status ?? MessageStatus.succeeded);
} catch (_) {
if (!mounted) return;
setState(() => message.status = MessageStatus.failed);
}
}
/// 点红色感叹号重发
Future<void> _resend(Message message) async {
setState(() => message.status = MessageStatus.sending);
try {
final sent = await OpenIM.iMManager.messageManager.sendMessage(
message: message,
userID: _isGroup ? null : _peerID,
groupID: _isGroup ? _groupID : null,
offlinePushInfo: OfflinePushInfo(),
);
if (!mounted) return;
setState(() => message.status = sent.status ?? MessageStatus.succeeded);
} catch (_) {
if (!mounted) return;
setState(() => message.status = MessageStatus.failed);
}
}
Future<void> _sendText(String text) async {
try {
final message = await OpenIM.iMManager.messageManager.createTextMessage(text: text);
_sendMessage(message);
} catch (_) {
_toast('消息没发出去,请重试');
}
}
Future<void> _sendVoice(String path, int duration) async {
try {
final message = await OpenIM.iMManager.messageManager.createSoundMessageFromFullPath(
soundPath: path,
duration: duration,
);
_sendMessage(message);
} catch (_) {
_toast('语音没发出去,请重试');
}
}
Future<void> _pickAndSendImage() async {
try {
final picked = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 80);
if (picked == null) return;
final message = await OpenIM.iMManager.messageManager.createImageMessageFromFullPath(imagePath: picked.path);
_sendMessage(message);
} catch (_) {
_toast('图片没发出去,请重试');
}
}
Future<void> _pickAndSendFile() async {
try {
final result = await FilePicker.platform.pickFiles();
final file = result?.files.single;
if (file == null || file.path == null) return;
final message = await OpenIM.iMManager.messageManager.createFileMessageFromFullPath(
filePath: file.path!,
fileName: file.name,
);
_sendMessage(message);
} catch (_) {
_toast('文件没发出去,请重试');
}
}
// ---------------- 语音播放 / 文件打开 ----------------
Future<void> _playVoice(Message message) async {
try {
// 再点一次同一条语音 = 停止
if (_playingVoiceId == message.clientMsgID && _player.playing) {
await _player.stop();
if (mounted) setState(() => _playingVoiceId = null);
return;
}
final localPath = message.soundElem?.soundPath;
final url = message.soundElem?.sourceUrl;
if (localPath != null && localPath.isNotEmpty && File(localPath).existsSync()) {
await _player.setFilePath(localPath);
} else if (url != null && url.isNotEmpty) {
await _player.setUrl(url);
} else {
_toast('语音文件找不到了');
return;
}
if (mounted) setState(() => _playingVoiceId = message.clientMsgID);
// 播放后去掉未读红点
setState(() => message.isRead = true);
await _player.play();
if (mounted) setState(() => _playingVoiceId = null);
} catch (_) {
if (mounted) setState(() => _playingVoiceId = null);
_toast('语音播放失败');
}
}
Future<void> _openFile(Message message) async {
final localPath = message.fileElem?.filePath;
final url = message.fileElem?.sourceUrl;
final fileName = message.fileElem?.fileName ?? 'file';
try {
String path;
if (localPath != null && localPath.isNotEmpty && File(localPath).existsSync()) {
path = localPath;
} else if (url != null && url.isNotEmpty) {
_toast('正在下载,请稍候');
final dir = await getTemporaryDirectory();
path = '${dir.path}/$fileName';
await _dio.download(url, path);
} else {
_toast('文件找不到了');
return;
}
final result = await OpenFilex.open(path);
if (result.type != ResultType.done) {
_toast('没有能打开这个文件的应用');
}
} catch (_) {
_toast('文件打不开,请稍后再试');
}
}
// ---------------- 语音通话 ----------------
Future<void> _startVoiceCall() async {
final error = await CallService.instance.startCall(peerUserID: _peerID, peerName: _title);
if (!mounted) return;
if (error != null) {
_toast(error);
return;
}
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CallScreen(), fullscreenDialog: true));
}
void _toast(String text) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text), duration: const Duration(seconds: 2)));
}
// ---------------- 界面 ----------------
@override
Widget build(BuildContext context) {
final self = IMService.instance.selfInfo;
return Scaffold(
backgroundColor: AppColors.chatBg,
appBar: AppBar(
title: Text(_title),
actions: [
if (!_isGroup)
IconButton(
icon: const Icon(Icons.more_horiz),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: ListTile(
leading: const Icon(Icons.phone_outlined),
title: const Text('语音通话'),
onTap: () {
Navigator.of(ctx).pop();
_startVoiceCall();
},
),
),
);
},
),
],
),
body: Column(
children: [
Expanded(
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: ListView.builder(
controller: _scroll,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: AppGap.x2),
itemCount: _messages.length,
itemBuilder: (context, i) {
final msg = _messages[i];
return Column(
children: [
_buildMessageRow(msg, self),
if (_showDividerAt(i)) _timeDivider(msg),
],
);
},
),
),
),
SafeArea(
top: false,
child: ChatInputBar(
onSendText: _sendText,
onSendVoice: _sendVoice,
onPickImage: _pickAndSendImage,
onPickFile: _pickAndSendFile,
onVoiceCall: _isGroup ? null : _startVoiceCall,
),
),
],
),
);
}
Widget _buildMessageRow(Message msg, UserInfo? self) {
final isMine = msg.sendID == IMService.instance.currentUserID;
// 通知类消息(如入群提示):居中灰字
if ((msg.contentType ?? 0) >= MessageType.notificationBegin) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppGap.x2),
child: Center(
child: Text(
FormatUtils.messagePreview(msg, isGroup: _isGroup),
style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary),
),
),
);
}
return MessageBubble(
message: msg,
isMine: isMine,
selfName: self?.nickname ?? '',
selfFaceURL: self?.faceURL,
peerName: _title,
peerFaceURL: _isGroup ? null : _conv.faceURL,
showSenderName: _isGroup,
playingVoiceId: _playingVoiceId,
onTapVoice: _playVoice,
onTapFile: _openFile,
onResend: _resend,
);
}
/// 与上一条(更旧的)消息间隔超过 5 分钟时显示时间分隔条
bool _showDividerAt(int i) {
final current = _messages[i].sendTime ?? 0;
if (current <= 0) return false;
if (i == _messages.length - 1) return true; // 最早一条也显示
final older = _messages[i + 1].sendTime ?? 0;
return current - older > _dividerGapMillis;
}
Widget _timeDivider(Message msg) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppGap.x2),
child: Center(
child: Text(
FormatUtils.chatDividerTime(msg.sendTime ?? 0),
style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary),
),
),
);
}
}
+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)),
);
}
}
}
@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import '../services/im_service.dart';
import '../theme.dart';
import '../utils/format.dart';
import '../widgets/avatar.dart';
import '../widgets/search_box.dart';
import '../widgets/state_views.dart';
import 'add_friend_screen.dart';
import 'chat_screen.dart';
/// 消息列表页(效果图 m1):
/// 标题「畅联」+ 圆形加号、搜索框、会话行(头像/名称/预览/时间/未读角标/免打扰铃铛)、
/// 置顶置灰,空/加载/失败/断网四态按 states.png。
class ConversationListScreen extends StatefulWidget {
/// 空态「去找同事」按钮:切到通讯录 Tab
final VoidCallback? onFindContacts;
const ConversationListScreen({super.key, this.onFindContacts});
@override
State<ConversationListScreen> createState() => _ConversationListScreenState();
}
class _ConversationListScreenState extends State<ConversationListScreen> {
String _keyword = '';
bool get _offline => IMService.instance.connectStatus != ConnectStatus.success;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('畅联'),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 26),
onPressed: () {
// 加号:从简只保留「添加同事」入口
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: ListTile(
leading: const Icon(Icons.person_add_alt),
title: const Text('添加同事'),
onTap: () {
Navigator.of(ctx).pop();
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AddFriendScreen()));
},
),
),
);
},
),
],
),
body: AnimatedBuilder(
animation: IMService.instance,
builder: (context, _) {
return Column(
children: [
if (_offline) const OfflineBanner(),
SearchBox(onChanged: (v) => setState(() => _keyword = v)),
Expanded(child: _buildBody()),
],
);
},
),
);
}
Widget _buildBody() {
final im = IMService.instance;
// 四态:加载中 / 加载失败 / 空 / 列表
if (!im.conversationsLoaded && im.syncing && im.conversations.isEmpty) {
return const LoadingConversations();
}
if (im.syncFailed && im.conversations.isEmpty) {
return ErrorConversations(onRetry: () => im.refreshConversations());
}
final list = _keyword.isEmpty
? im.conversations
: im.conversations
.where((c) => (c.showName ?? '').toLowerCase().contains(_keyword.toLowerCase()))
.toList();
if (list.isEmpty) {
if (_keyword.isNotEmpty) {
return const Center(
child: Text('没有找到相关会话', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)),
);
}
return EmptyConversations(onFindContacts: widget.onFindContacts);
}
return ListView.separated(
itemCount: list.length + (_offline ? 1 : 0),
separatorBuilder: (_, i) => const Divider(indent: 80),
itemBuilder: (context, i) {
if (i >= list.length) return const OfflineFooter();
return _conversationTile(list[i]);
},
);
}
Widget _conversationTile(ConversationInfo c) {
final isGroup = c.conversationType == ConversationType.superGroup || c.conversationType == ConversationType.group;
final muted = (c.recvMsgOpt ?? 0) != 0;
return Material(
color: c.isPinned == true ? AppColors.pinnedBg : AppColors.pageBg,
child: InkWell(
onTap: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => ChatScreen(conversation: c)))
.then((_) => IMService.instance.refreshConversations());
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x3),
child: Row(
children: [
_avatarWithBadge(c, isGroup, muted),
const SizedBox(width: AppGap.x3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
c.showName ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary),
),
const SizedBox(height: AppGap.x1),
Row(
children: [
Expanded(
child: Text(
FormatUtils.messagePreview(c.latestMsg, isGroup: isGroup),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary),
),
),
if (muted)
const Icon(Icons.notifications_off_outlined, size: 16, color: AppColors.textSecondary),
],
),
],
),
),
const SizedBox(width: AppGap.x2),
Text(
FormatUtils.conversationTime(c.latestMsgSendTime),
style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary),
),
],
),
),
),
);
}
Widget _avatarWithBadge(ConversationInfo c, bool isGroup, bool muted) {
final unread = c.unreadCount;
return Stack(
clipBehavior: Clip.none,
children: [
NameAvatar(name: c.showName ?? '', faceURL: c.faceURL, isGroup: isGroup, size: 56),
if (unread > 0)
Positioned(
right: -6,
top: -6,
child: Container(
constraints: const BoxConstraints(minWidth: 18),
height: 18,
padding: const EdgeInsets.symmetric(horizontal: 4),
alignment: Alignment.center,
decoration: BoxDecoration(
// 免打扰会话的角标弱化(参考微信习惯)
color: muted ? AppColors.textSecondary : AppColors.danger,
borderRadius: BorderRadius.circular(9),
),
child: Text(
unread > 99 ? '99+' : '$unread',
style: const TextStyle(fontSize: 11, color: Colors.white),
),
),
),
],
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import '../theme.dart';
import '../widgets/avatar.dart';
import 'chat_screen.dart';
/// 「我的群聊」:已加入的群列表
class GroupListScreen extends StatefulWidget {
const GroupListScreen({super.key});
@override
State<GroupListScreen> createState() => _GroupListScreenState();
}
class _GroupListScreenState extends State<GroupListScreen> {
List<GroupInfo> _groups = [];
bool _loading = true;
bool _failed = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final list = await OpenIM.iMManager.groupManager.getJoinedGroupList();
if (!mounted) return;
setState(() {
_groups = list;
_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('我的群聊')),
body: _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.x4),
OutlinedButton(onPressed: _load, child: const Text('重试')),
],
),
);
}
if (_groups.isEmpty) {
return const Center(
child: Text('你还没有加入任何群聊', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)),
);
}
return ListView.separated(
itemCount: _groups.length,
separatorBuilder: (_, __) => const Divider(indent: 76),
itemBuilder: (_, i) => _tile(_groups[i]),
);
}
Widget _tile(GroupInfo g) {
final name = g.groupName ?? '';
return ListTile(
leading: NameAvatar(name: name, isGroup: true),
title: Text(name, style: const TextStyle(fontSize: AppFont.body)),
subtitle: Text('${g.memberCount ?? 0}', style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)),
onTap: () => _openChat(g),
);
}
Future<void> _openChat(GroupInfo g) async {
try {
final conversation = await OpenIM.iMManager.conversationManager.getOneConversation(
sourceID: g.groupID,
sessionType: ConversationType.superGroup,
);
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)),
);
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'contacts_screen.dart';
import 'conversation_list_screen.dart';
import 'mine_screen.dart';
/// 主页:底部三 Tab —— 消息 / 通讯录 / 我的
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _index = 0;
void switchTab(int index) => setState(() => _index = index);
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _index,
children: [
ConversationListScreen(onFindContacts: () => switchTab(1)),
const ContactsScreen(),
const MineScreen(),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _index,
onTap: (i) => setState(() => _index = i),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.chat_bubble), label: '消息'),
BottomNavigationBarItem(icon: Icon(Icons.people), label: '通讯录'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),
],
),
);
}
}
+171
View File
@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/auth_api.dart';
import '../services/call_service.dart';
import '../services/im_service.dart';
import '../theme.dart';
import 'home_screen.dart';
/// 登录页:工号 + 密码,登录中按钮转菊花,失败在按钮下方红字提示。
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
/// 本地保存登录凭证的键(自动登录用)
static const String keyUserID = 'login_userID';
static const String keyToken = 'login_token';
static const String keyNickname = 'login_nickname';
/// 保存登录凭证
static Future<void> saveCredential(String userID, String token, String nickname) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(keyUserID, userID);
await prefs.setString(keyToken, token);
await prefs.setString(keyNickname, nickname);
}
/// 清除登录凭证(退出登录 / token 失效时)
static Future<void> clearCredential() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(keyUserID);
await prefs.remove(keyToken);
await prefs.remove(keyNickname);
}
/// 读取已保存的凭证
static Future<(String, String)?> readCredential() async {
final prefs = await SharedPreferences.getInstance();
final userID = prefs.getString(keyUserID);
final token = prefs.getString(keyToken);
if (userID == null || userID.isEmpty || token == null || token.isEmpty) return null;
return (userID, token);
}
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _idCtrl = TextEditingController();
final TextEditingController _pwdCtrl = TextEditingController();
final AuthApi _authApi = AuthApi();
bool _logging = false;
String? _error;
@override
void dispose() {
_idCtrl.dispose();
_pwdCtrl.dispose();
super.dispose();
}
Future<void> _login() async {
final staffNo = _idCtrl.text.trim();
final password = _pwdCtrl.text;
if (staffNo.isEmpty || password.isEmpty) {
setState(() => _error = '请输入工号和密码');
return;
}
setState(() {
_logging = true;
_error = null;
});
try {
// 1. 公司账号登录,拿 OpenIM 的 userID/imToken
final result = await _authApi.login(
staffNo: staffNo,
password: password,
platformID: IMService.instance.platformID,
);
// 2. 登录 OpenIM SDK
await IMService.instance.login(userID: result.userID, token: result.token);
// 3. 启动通话信令监听
CallService.instance.start();
// 4. 存凭证用于下次自动登录
await LoginScreen.saveCredential(result.userID, result.token, result.nickname);
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
} on AuthException catch (e) {
setState(() => _error = e.message);
} catch (_) {
setState(() => _error = '登录出错了,请稍后再试');
} finally {
if (mounted) setState(() => _logging = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.pageBg,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: AppGap.x8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 96),
const Text(
'畅联',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 32, fontWeight: FontWeight.w600, color: AppColors.primary),
),
const SizedBox(height: AppGap.x12),
_input(_idCtrl, '请输入工号', Icons.person_outline, false),
const SizedBox(height: AppGap.x4),
_input(_pwdCtrl, '请输入密码', Icons.lock_outline, true),
const SizedBox(height: AppGap.x6),
SizedBox(
height: 48,
child: FilledButton(
onPressed: _logging ? null : _login,
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: _logging
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('登录', style: TextStyle(fontSize: AppFont.body)),
),
),
if (_error != null) ...[
const SizedBox(height: AppGap.x3),
Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: AppFont.sub, color: AppColors.danger),
),
],
],
),
),
),
);
}
Widget _input(TextEditingController ctrl, String hint, IconData icon, bool obscure) {
return TextField(
controller: ctrl,
obscureText: obscure,
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: AppColors.textSecondary, fontSize: AppFont.body),
prefixIcon: Icon(icon, color: AppColors.textSecondary),
filled: true,
fillColor: AppColors.searchBg,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: AppGap.x4),
),
);
}
}
+182
View File
@@ -0,0 +1,182 @@
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 'login_screen.dart';
/// 「我的」页:大头像 + 姓名 + 工号,设置 / 关于 / 退出登录
class MineScreen extends StatefulWidget {
const MineScreen({super.key});
@override
State<MineScreen> createState() => _MineScreenState();
}
class _MineScreenState extends State<MineScreen> {
@override
void initState() {
super.initState();
IMService.instance.refreshSelfInfo();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('我的')),
body: AnimatedBuilder(
animation: IMService.instance,
builder: (context, _) {
final user = IMService.instance.selfInfo;
final nickname = user?.nickname?.isNotEmpty == true ? user!.nickname! : '';
final userID = IMService.instance.currentUserID ?? '';
return ListView(
children: [
// 头部:大头像 + 姓名 + 工号
Container(
color: AppColors.pageBg,
padding: const EdgeInsets.all(AppGap.x4),
child: Row(
children: [
NameAvatar(name: nickname.isNotEmpty ? nickname : userID, faceURL: user?.faceURL, size: 64),
const SizedBox(width: AppGap.x4),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nickname.isNotEmpty ? nickname : userID,
style: const TextStyle(fontSize: AppFont.title, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
),
const SizedBox(height: AppGap.x1),
Text(
'工号:$userID',
style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary),
),
],
),
],
),
),
const SizedBox(height: AppGap.x3),
_item(Icons.settings_outlined, '设置', () => _showSettings(nickname)),
const Divider(indent: AppGap.x4),
_item(Icons.info_outline, '关于', _showAbout),
const Divider(indent: AppGap.x4),
ListTile(
leading: const Icon(Icons.logout, color: AppColors.danger),
title: const Text('退出登录', style: TextStyle(fontSize: AppFont.body, color: AppColors.danger)),
onTap: _confirmLogout,
),
],
);
},
),
);
}
Widget _item(IconData icon, String label, VoidCallback onTap) {
return ListTile(
leading: Icon(icon, color: AppColors.textPrimary),
title: Text(label, style: const TextStyle(fontSize: AppFont.body)),
trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary),
onTap: onTap,
);
}
/// 设置:目前只有修改昵称
void _showSettings(String currentNickname) {
final ctrl = TextEditingController(text: currentNickname);
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) => Padding(
padding: EdgeInsets.only(
left: AppGap.x4,
right: AppGap.x4,
top: AppGap.x4,
bottom: MediaQuery.of(ctx).viewInsets.bottom + AppGap.x4,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('修改昵称', style: TextStyle(fontSize: AppFont.title, fontWeight: FontWeight.w600)),
const SizedBox(height: AppGap.x4),
TextField(
controller: ctrl,
decoration: InputDecoration(
hintText: '请输入新昵称',
filled: true,
fillColor: AppColors.searchBg,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: AppGap.x4),
FilledButton(
onPressed: () async {
final name = ctrl.text.trim();
if (name.isEmpty) return;
try {
await OpenIM.iMManager.userManager.setSelfInfo(nickname: name);
await IMService.instance.refreshSelfInfo();
if (ctx.mounted) Navigator.of(ctx).pop();
} catch (_) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
const SnackBar(content: Text('修改失败,请检查网络后重试'), duration: Duration(seconds: 2)),
);
}
}
},
style: FilledButton.styleFrom(backgroundColor: AppColors.primary),
child: const Text('保存', style: TextStyle(fontSize: AppFont.body)),
),
],
),
),
);
}
void _showAbout() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('关于畅联'),
content: const Text('畅联 1.0.0\n公司内部通讯工具,供同事之间消息、文件与语音通话使用。'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('知道了')),
],
),
);
}
void _confirmLogout() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('退出登录'),
content: const Text('退出后要重新输入工号和密码,确定退出吗?'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
TextButton(
onPressed: () async {
Navigator.of(ctx).pop();
await LoginScreen.clearCredential();
await IMService.instance.logout();
if (!mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(route) => false,
);
},
child: const Text('退出', style: TextStyle(color: AppColors.danger)),
),
],
),
);
}
}
+140
View File
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import '../theme.dart';
import '../widgets/avatar.dart';
/// 「新的同事」:收到的好友申请列表,可接受 / 拒绝
class NewFriendsScreen extends StatefulWidget {
const NewFriendsScreen({super.key});
@override
State<NewFriendsScreen> createState() => _NewFriendsScreenState();
}
class _NewFriendsScreenState extends State<NewFriendsScreen> {
List<FriendApplicationInfo> _list = [];
bool _loading = true;
bool _failed = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final list = await OpenIM.iMManager.friendshipManager.getFriendApplicationListAsRecipient();
if (!mounted) return;
setState(() {
_list = list;
_loading = false;
_failed = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_loading = false;
_failed = true;
});
}
}
Future<void> _handle(FriendApplicationInfo a, bool accept) async {
try {
if (accept) {
await OpenIM.iMManager.friendshipManager.acceptFriendApplication(userID: a.fromUserID ?? '', handleMsg: '');
} else {
await OpenIM.iMManager.friendshipManager.refuseFriendApplication(userID: a.fromUserID ?? '', handleMsg: '');
}
if (!mounted) return;
setState(() => a.handleResult = accept ? 1 : -1);
_toast(accept ? '已添加为同事' : '已拒绝');
} catch (_) {
_toast('操作失败,请检查网络后重试');
}
}
void _toast(String text) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text), duration: const Duration(seconds: 2)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('新的同事')),
body: _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.x4),
OutlinedButton(onPressed: _load, child: const Text('重试')),
],
),
);
}
if (_list.isEmpty) {
return const Center(
child: Text('暂时没有新的申请', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)),
);
}
return ListView.separated(
itemCount: _list.length,
separatorBuilder: (_, __) => const Divider(indent: 76),
itemBuilder: (_, i) => _tile(_list[i]),
);
}
Widget _tile(FriendApplicationInfo a) {
final name = a.fromNickname?.isNotEmpty == true ? a.fromNickname! : (a.fromUserID ?? '');
Widget trailing;
if (a.handleResult == 1) {
trailing = const Text('已添加', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary));
} else if (a.handleResult == -1) {
trailing = const Text('已拒绝', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary));
} else {
trailing = Row(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => _handle(a, true),
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: AppGap.x3),
),
child: const Text('接受', style: TextStyle(fontSize: AppFont.sub)),
),
const SizedBox(width: AppGap.x2),
OutlinedButton(
onPressed: () => _handle(a, false),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: AppGap.x3),
),
child: const Text('拒绝', style: TextStyle(fontSize: AppFont.sub)),
),
],
);
}
return ListTile(
leading: NameAvatar(name: name),
title: Text(name, style: const TextStyle(fontSize: AppFont.body)),
subtitle: a.reqMsg?.isNotEmpty == true
? Text(a.reqMsg!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary))
: null,
trailing: trailing,
);
}
}