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 row)? onSelectConv; @override State createState() => _MessagesPageState(); } class _MessagesPageState extends State { List> _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 _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 _showDesktopConvMenu(Map 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( 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 r, Widget child) { if (!widget.desktopStyle) return child; return GestureDetector( onSecondaryTapDown: (d) => _showDesktopConvMenu(r, d.globalPosition), child: child, ); } Future _newChat({required bool group}) async { final picked = await Navigator.of(context).push>>( 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( 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> 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 Function() onChanged; @override State<_SwipeConversation> createState() => _SwipeConversationState(); } class _SwipeConversationState extends State<_SwipeConversation> { double _offset = 0; static const _width = 216.0; Future _hide() async { if (!mounted) return; final ok = await showDialog( 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 _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 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))), ), ), ); } }