76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
576 lines
22 KiB
Dart
576 lines
22 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../api/oa_client.dart';
|
||
import '../im/chat_prefs.dart';
|
||
import '../pages/chat_page.dart';
|
||
import '../pages/notices_page.dart';
|
||
import '../pages/pick_people_page.dart';
|
||
import '../pages/scan_login_page.dart';
|
||
import '../pages/system_notice_page.dart';
|
||
import '../session/session.dart';
|
||
import '../theme.dart';
|
||
import '../widgets/common.dart';
|
||
import '../widgets/desktop_ui.dart';
|
||
import '../widgets/wecom.dart';
|
||
|
||
class MessagesPage extends StatefulWidget {
|
||
const MessagesPage({
|
||
super.key,
|
||
required this.session,
|
||
required this.api,
|
||
this.desktopPane = false,
|
||
this.desktopStyle = false,
|
||
this.selectedId,
|
||
this.onSelectConv,
|
||
});
|
||
final SessionStore session;
|
||
final OaClient api;
|
||
final bool desktopPane;
|
||
final bool desktopStyle;
|
||
final String? selectedId;
|
||
final void Function(Map<String, dynamic> row)? onSelectConv;
|
||
|
||
@override
|
||
State<MessagesPage> createState() => _MessagesPageState();
|
||
}
|
||
|
||
class _MessagesPageState extends State<MessagesPage> {
|
||
List<Map<String, dynamic>> _items = [];
|
||
bool _loading = true;
|
||
String _err = '';
|
||
String _filter = 'all';
|
||
String _q = '';
|
||
bool _showSearch = false;
|
||
Timer? _imDebounce;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
widget.session.im.addListener(_onIm);
|
||
ChatPrefs.ensure().then((_) {
|
||
if (mounted) setState(() {});
|
||
});
|
||
_load();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_imDebounce?.cancel();
|
||
widget.session.im.removeListener(_onIm);
|
||
super.dispose();
|
||
}
|
||
|
||
void _onIm() {
|
||
_imDebounce?.cancel();
|
||
_imDebounce = Timer(const Duration(milliseconds: 250), () {
|
||
if (mounted) _load();
|
||
});
|
||
}
|
||
|
||
Future<void> _load() async {
|
||
try {
|
||
final data = await widget.api.get('/im/conversations');
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_items = asMaps(data);
|
||
_loading = false;
|
||
_err = '';
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_loading = false;
|
||
if (_items.isEmpty) _err = '$e';
|
||
});
|
||
}
|
||
}
|
||
|
||
int get _unread {
|
||
var n = 0;
|
||
for (final r in _items) {
|
||
if (ChatPrefs.muted('${r['id'] ?? ''}')) continue;
|
||
final id = '${r['id'] ?? ''}';
|
||
final fake = ChatPrefs.fakeUnread(id);
|
||
if (fake > 0) {
|
||
n += fake;
|
||
continue;
|
||
}
|
||
n += (r['unread'] as num?)?.toInt() ?? 0;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
Future<void> _showDesktopConvMenu(Map<String, dynamic> r, Offset pos) async {
|
||
final id = '${r['id'] ?? ''}';
|
||
if (id.isEmpty) return;
|
||
final muted = ChatPrefs.muted(id);
|
||
final pinned = ChatPrefs.pinned(id);
|
||
final unread = (r['unread'] as num?)?.toInt() ?? 0;
|
||
final fake = ChatPrefs.fakeUnread(id);
|
||
final selected = await showMenu<String>(
|
||
context: context,
|
||
position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx + 1, pos.dy + 1),
|
||
items: [
|
||
if (unread == 0 && fake == 0)
|
||
const PopupMenuItem(value: 'unread', child: Text('标为未读')),
|
||
PopupMenuItem(value: 'mute', child: Text(muted ? '取消免打扰' : '消息免打扰')),
|
||
PopupMenuItem(value: 'pin', child: Text(pinned ? '取消置顶' : '置顶')),
|
||
const PopupMenuItem(value: 'hide', child: Text('不显示')),
|
||
const PopupMenuDivider(),
|
||
const PopupMenuItem(value: 'clear', child: Text('清空聊天记录')),
|
||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||
],
|
||
);
|
||
if (!mounted || selected == null) return;
|
||
switch (selected) {
|
||
case 'unread':
|
||
await ChatPrefs.setFakeUnread(id, 1);
|
||
case 'mute':
|
||
await ChatPrefs.setMuted(id, !muted);
|
||
case 'pin':
|
||
await ChatPrefs.setPinned(id, !pinned);
|
||
case 'hide':
|
||
await ChatPrefs.setHidden(id, true);
|
||
case 'clear':
|
||
await ChatPrefs.clearHistory(id);
|
||
case 'delete':
|
||
await ChatPrefs.setHidden(id, true);
|
||
}
|
||
if (mounted) {
|
||
setState(() {});
|
||
await _load();
|
||
}
|
||
}
|
||
|
||
Widget _wrapConv(Map<String, dynamic> r, Widget child) {
|
||
if (!widget.desktopStyle) return child;
|
||
return GestureDetector(
|
||
onSecondaryTapDown: (d) => _showDesktopConvMenu(r, d.globalPosition),
|
||
child: child,
|
||
);
|
||
}
|
||
|
||
Future<void> _newChat({required bool group}) async {
|
||
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
||
MaterialPageRoute(
|
||
builder: (_) => PickPeoplePage(
|
||
api: widget.api,
|
||
title: group ? '选择联系人' : '发起单聊',
|
||
multiple: group,
|
||
exclude: {widget.session.userId},
|
||
),
|
||
),
|
||
);
|
||
if (picked == null || picked.isEmpty) return;
|
||
if (!group) {
|
||
final p = picked.first;
|
||
if (!mounted) return;
|
||
await Navigator.of(context).push(MaterialPageRoute(
|
||
builder: (_) => ChatPage(
|
||
session: widget.session,
|
||
api: widget.api,
|
||
peerId: '${p['id']}',
|
||
peerName: '${p['name'] ?? '同事'}',
|
||
peerAvatarFileId: '${p['avatarFileId'] ?? ''}',
|
||
),
|
||
));
|
||
_load();
|
||
return;
|
||
}
|
||
final name = TextEditingController();
|
||
if (!mounted) return;
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('群名称'),
|
||
content: TextField(
|
||
controller: name,
|
||
decoration: const InputDecoration(hintText: '例如:三维项目组')),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: const Text('取消')),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: const Text('创建')),
|
||
],
|
||
),
|
||
);
|
||
if (ok == true) {
|
||
await widget.api.post('/im/groups', {
|
||
'name': name.text.trim().isEmpty ? '群聊' : name.text.trim(),
|
||
'memberIds': picked.map((e) => '${e['id']}').toList(),
|
||
});
|
||
await _load();
|
||
}
|
||
}
|
||
|
||
List<Map<String, dynamic>> get _shown {
|
||
var list = _items;
|
||
// 只有用户明确执行“不显示/删除”后才隐藏;阅读状态不会改变会话是否存在。
|
||
list = list.where((e) {
|
||
final id = '${e['id'] ?? ''}';
|
||
return !ChatPrefs.hidden(id);
|
||
}).toList();
|
||
if (_filter == 'unread')
|
||
list =
|
||
list.where((e) => ((e['unread'] as num?)?.toInt() ?? 0) > 0).toList();
|
||
if (_filter == 'dm')
|
||
list = list.where((e) => e['type'] != 'group').toList();
|
||
if (_filter == 'group')
|
||
list = list.where((e) => e['type'] == 'group').toList();
|
||
if (_q.isNotEmpty) {
|
||
list = list
|
||
.where((e) =>
|
||
'${e['name']}${e['peerName']}${e['lastText']}'.contains(_q))
|
||
.toList();
|
||
}
|
||
list = [...list]..sort((a, b) {
|
||
final ap = ChatPrefs.pinned('${a['id'] ?? ''}');
|
||
final bp = ChatPrefs.pinned('${b['id'] ?? ''}');
|
||
if (ap == bp) return 0;
|
||
return ap ? -1 : 1;
|
||
});
|
||
return list;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final header = widget.desktopStyle
|
||
? DesktopPaneHeader(
|
||
title: _unread > 0 ? '消息($_unread)' : '消息',
|
||
actions: [
|
||
IconButton(
|
||
onPressed: () => setState(() => _showSearch = !_showSearch),
|
||
icon: const Icon(Icons.search, color: kInk, size: 20),
|
||
),
|
||
Builder(
|
||
builder: (ctx) => IconButton(
|
||
onPressed: () => showPlusMenu(ctx, [
|
||
(Icons.qr_code_scanner_outlined, '扫一扫登录电脑', () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ScanLoginPage(api: widget.api)))),
|
||
(Icons.chat_bubble_outline, '发起单聊', () => _newChat(group: false)),
|
||
(Icons.group_outlined, '发起群聊', () => _newChat(group: true)),
|
||
]),
|
||
icon: const Icon(Icons.add, color: kInk, size: 22),
|
||
),
|
||
),
|
||
],
|
||
bottom: Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
|
||
child: Column(
|
||
children: [
|
||
if (_showSearch) ...[
|
||
DesktopSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
|
||
const SizedBox(height: 8),
|
||
],
|
||
Row(
|
||
children: [
|
||
_filterChip('all', '全部'),
|
||
const SizedBox(width: 8),
|
||
_filterChip('unread', '未读'),
|
||
const SizedBox(width: 8),
|
||
_filterChip('dm', '单聊'),
|
||
const SizedBox(width: 8),
|
||
_filterChip('group', '群聊'),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
)
|
||
: Column(
|
||
children: [
|
||
WxHeader(
|
||
title: _unread > 0 ? '消息($_unread)' : '消息',
|
||
actions: [
|
||
IconButton(
|
||
onPressed: () => setState(() => _showSearch = !_showSearch),
|
||
icon: const Icon(Icons.search, color: kInk),
|
||
),
|
||
Builder(
|
||
builder: (ctx) => IconButton(
|
||
onPressed: () => showPlusMenu(ctx, [
|
||
(Icons.qr_code_scanner_outlined, '扫一扫登录电脑', () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ScanLoginPage(api: widget.api)))),
|
||
(Icons.chat_bubble_outline, '发起单聊', () => _newChat(group: false)),
|
||
(Icons.group_outlined, '发起群聊', () => _newChat(group: true)),
|
||
]),
|
||
icon: const Icon(Icons.add_circle_outline, color: kInk),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (_showSearch) WxSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
|
||
child: Row(
|
||
children: [
|
||
_filterChip('all', '全部'),
|
||
const SizedBox(width: 8),
|
||
_filterChip('unread', '未读'),
|
||
const SizedBox(width: 8),
|
||
_filterChip('dm', '单聊'),
|
||
const SizedBox(width: 8),
|
||
_filterChip('group', '群聊'),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
|
||
return ColoredBox(
|
||
color: Colors.white,
|
||
child: Column(
|
||
children: [
|
||
header,
|
||
if (_loading)
|
||
const LinearProgressIndicator(minHeight: 2, color: kBind),
|
||
if (_err.isNotEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Text(_err, style: const TextStyle(color: kDanger))),
|
||
Expanded(
|
||
child: RefreshIndicator(
|
||
onRefresh: _load,
|
||
child: ListView(
|
||
padding: EdgeInsets.zero,
|
||
children: [
|
||
ConvTile(
|
||
title: '公告',
|
||
preview: '公司通知与制度',
|
||
avatarIcon: Icons.campaign,
|
||
avatarColor: const Color(0xFFE75D5D),
|
||
avatarLabel: '告',
|
||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||
builder: (_) => NoticesPage(api: widget.api),
|
||
)),
|
||
),
|
||
if (_shown.isEmpty && !_loading)
|
||
const Padding(
|
||
padding: EdgeInsets.only(top: 48),
|
||
child: EmptyHint('还没有会话。点右上角 + 或到通讯录找同事。'),
|
||
),
|
||
for (final r in _shown)
|
||
_wrapConv(
|
||
r,
|
||
widget.desktopStyle
|
||
? ConvTile(
|
||
title: '${r['name'] ?? r['peerName'] ?? '同事'}',
|
||
preview: '${r['lastText'] ?? ''}',
|
||
time: shortTime(r['lastAt']),
|
||
muted: ChatPrefs.muted('${r['id'] ?? ''}'),
|
||
unread: ChatPrefs.muted('${r['id'] ?? ''}')
|
||
? 0
|
||
: (ChatPrefs.fakeUnread('${r['id'] ?? ''}') > 0
|
||
? ChatPrefs.fakeUnread('${r['id'] ?? ''}')
|
||
: ((r['unread'] as num?)?.toInt() ?? 0)),
|
||
avatarLabel: '${r['peerId']}' == '0' ? '?' : '${r['name'] ?? r['peerName'] ?? '同'}',
|
||
avatarIcon: '${r['peerId']}' == '0' ? Icons.notifications : (r['type'] == 'group' ? Icons.groups : null),
|
||
avatarColor: '${r['peerId']}' == '0' ? const Color(0xFF07C160) : (r['type'] == 'group' ? const Color(0xFF07C160) : null),
|
||
avatarFileId: r['type'] == 'group' ? null : '${r['avatarFileId'] ?? ''}',
|
||
api: widget.api,
|
||
selected: widget.desktopPane && widget.selectedId == '${r['id'] ?? ''}',
|
||
onTap: () async {
|
||
final id = '${r['id'] ?? ''}';
|
||
if (id.isNotEmpty) await ChatPrefs.setFakeUnread(id, 0);
|
||
if (widget.desktopPane && widget.onSelectConv != null) {
|
||
widget.onSelectConv!(r);
|
||
return;
|
||
}
|
||
await Navigator.of(context).push(MaterialPageRoute(
|
||
builder: (_) => '${r['peerId']}' == '0'
|
||
? SystemNoticePage(session: widget.session, api: widget.api, conversationId: id)
|
||
: ChatPage(
|
||
session: widget.session,
|
||
api: widget.api,
|
||
peerId: '${r['peerId'] ?? ''}',
|
||
conversationId: id,
|
||
peerName: '${r['name'] ?? r['peerName'] ?? '同事'}',
|
||
isGroup: r['type'] == 'group',
|
||
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
|
||
),
|
||
));
|
||
_load();
|
||
},
|
||
)
|
||
: _SwipeConversation(
|
||
id: '${r['id'] ?? ''}',
|
||
api: widget.api,
|
||
onChanged: _load,
|
||
child: ConvTile(
|
||
title: '${r['name'] ?? r['peerName'] ?? '同事'}',
|
||
preview: '${r['lastText'] ?? ''}',
|
||
time: shortTime(r['lastAt']),
|
||
muted: ChatPrefs.muted('${r['id'] ?? ''}'),
|
||
unread: ChatPrefs.muted('${r['id'] ?? ''}')
|
||
? 0
|
||
: ((r['unread'] as num?)?.toInt() ?? 0),
|
||
avatarLabel: '${r['peerId']}' == '0'
|
||
? '?'
|
||
: '${r['name'] ?? r['peerName'] ?? '同'}',
|
||
avatarIcon: '${r['peerId']}' == '0'
|
||
? Icons.notifications
|
||
: (r['type'] == 'group' ? Icons.groups : null),
|
||
avatarColor: '${r['peerId']}' == '0'
|
||
? const Color(0xFF07C160)
|
||
: (r['type'] == 'group'
|
||
? const Color(0xFF07C160)
|
||
: null),
|
||
avatarFileId: r['type'] == 'group'
|
||
? null
|
||
: '${r['avatarFileId'] ?? ''}',
|
||
api: widget.api,
|
||
selected: widget.desktopPane && widget.selectedId == '${r['id'] ?? ''}',
|
||
onTap: () async {
|
||
if (widget.desktopPane && widget.onSelectConv != null) {
|
||
widget.onSelectConv!(r);
|
||
return;
|
||
}
|
||
await Navigator.of(context).push(MaterialPageRoute(
|
||
builder: (_) => '${r['peerId']}' == '0'
|
||
? SystemNoticePage(session: widget.session, api: widget.api, conversationId: '${r['id'] ?? ''}')
|
||
: ChatPage(
|
||
session: widget.session,
|
||
api: widget.api,
|
||
peerId: '${r['peerId'] ?? ''}',
|
||
conversationId: '${r['id'] ?? ''}',
|
||
peerName: '${r['name'] ?? r['peerName'] ?? '同事'}',
|
||
isGroup: r['type'] == 'group',
|
||
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
|
||
),
|
||
));
|
||
_load();
|
||
},
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _filterChip(String id, String label) {
|
||
final on = _filter == id;
|
||
return GestureDetector(
|
||
onTap: () => setState(() => _filter = id),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: on ? const Color(0xFFE8F3FF) : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Text(label,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: on ? kBind : kMute,
|
||
fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 微信式左滑操作栏:操作不会误删服务器会话,删除仅隐藏本机列表。
|
||
class _SwipeConversation extends StatefulWidget {
|
||
const _SwipeConversation(
|
||
{required this.id,
|
||
required this.api,
|
||
required this.child,
|
||
required this.onChanged});
|
||
final String id;
|
||
final OaClient api;
|
||
final Widget child;
|
||
final Future<void> Function() onChanged;
|
||
|
||
@override
|
||
State<_SwipeConversation> createState() => _SwipeConversationState();
|
||
}
|
||
|
||
class _SwipeConversationState extends State<_SwipeConversation> {
|
||
double _offset = 0;
|
||
static const _width = 216.0;
|
||
|
||
Future<void> _hide() async {
|
||
if (!mounted) return;
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('隐藏会话'),
|
||
content: const Text('仅从消息列表隐藏,不会删除聊天记录。确定继续吗?'),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true) {
|
||
if (mounted) setState(() => _offset = 0);
|
||
return;
|
||
}
|
||
await ChatPrefs.setHidden(widget.id, true);
|
||
await widget.onChanged();
|
||
}
|
||
|
||
Future<void> _toggleMute() async {
|
||
await ChatPrefs.setMuted(widget.id, !ChatPrefs.muted(widget.id));
|
||
if (mounted) setState(() => _offset = 0);
|
||
await widget.onChanged();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox(
|
||
height: 76,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onHorizontalDragUpdate: (d) => setState(() => _offset = (_offset + d.delta.dx).clamp(-_width, 0)),
|
||
onHorizontalDragEnd: (_) => setState(() => _offset = _offset < -80 ? -_width : 0),
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
Align(
|
||
alignment: Alignment.centerRight,
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_action('免打扰', const Color(0xFFFFA940), _toggleMute),
|
||
_action('不显示', const Color(0xFF8C8C8C), _hide),
|
||
_action('删除', const Color(0xFFF5222D), _hide),
|
||
],
|
||
),
|
||
),
|
||
AnimatedContainer(
|
||
duration: const Duration(milliseconds: 160),
|
||
curve: Curves.easeOut,
|
||
transform: Matrix4.translationValues(_offset, 0, 0),
|
||
color: Colors.white,
|
||
child: widget.child,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _action(String label, Color color, Future<void> Function() onTap) {
|
||
return SizedBox(
|
||
width: 72,
|
||
height: double.infinity,
|
||
child: Material(
|
||
color: color,
|
||
child: InkWell(
|
||
onTap: () async => onTap(),
|
||
child: Center(
|
||
child: Text(label,
|
||
style: const TextStyle(color: Colors.white, fontSize: 13))),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|