import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; import '../../api/oa_client.dart'; import '../../device/device_bridge.dart'; import '../../im/chat_local_store.dart'; import '../../im/chat_prefs.dart'; import '../../im/im_seq_store.dart'; import '../../im/sticker_store.dart'; import '../../pages/call_page.dart'; import '../../pages/location_pick_page.dart'; import 'desk_chat_detail_page.dart'; import 'desk_pick_people_page.dart'; import 'desk_record_detail_page.dart'; import '../../session/session.dart'; import '../../widgets/auth_image.dart'; import '../../widgets/location_view_page.dart'; import '../../widgets/tencent_map_thumb.dart'; import '../../widgets/voice_message.dart'; import '../desk_theme.dart'; import '../widgets/desk_widgets.dart'; const _emojis = ['😀', '😁', '😂', '🤣', '😊', '😎', '😍', '🥰', '🙂', '🤔', '😐', '😏', '👍', '👏', '🙏', '❤️', '🎉', '🔥', '✅', '❌']; class DeskChatPage extends StatefulWidget { const DeskChatPage({ super.key, required this.session, required this.api, required this.conversationId, required this.peerId, required this.peerName, this.isGroup = false, this.peerAvatarFileId = '', }); final SessionStore session; final OaClient api; final String conversationId; final String peerId; final String peerName; final bool isGroup; final String peerAvatarFileId; @override State createState() => _DeskChatPageState(); } class _DeskChatPageState extends State { final _input = TextEditingController(); final _scroll = ScrollController(); final _focus = FocusNode(); List> _items = []; String _convId = ''; bool _sending = false; bool _emoji = false; int _emojiTab = 0; bool _voice = false; bool _recording = false; bool _cancelVoice = false; bool _pressed = false; bool _firstLoad = true; String? _playingId; final Set _voiceTextLoading = {}; Timer? _playTimer; Timer? _receiptPoll; Timer? _imDebounce; DateTime? _voiceDown; Future? _voiceStart; int _loadSeq = 0; @override void initState() { super.initState(); _convId = widget.conversationId; widget.session.im.addListener(_onIm); DeviceBridge.watchScreenshot(_onScreenshot); ChatPrefs.ensure().then((_) => StickerStore.ensure()); _load(jump: true); _ackRead(); _receiptPoll = Timer.periodic(const Duration(seconds: 10), (_) { if (mounted) { _ackRead(); _load(); } }); } @override void dispose() { _receiptPoll?.cancel(); _imDebounce?.cancel(); _playTimer?.cancel(); DeviceBridge.stopPlay(); DeviceBridge.unwatchScreenshot(_onScreenshot); widget.session.im.removeListener(_onIm); _input.dispose(); _scroll.dispose(); _focus.dispose(); super.dispose(); } void _onIm() { _ackRead(); _imDebounce?.cancel(); _imDebounce = Timer(const Duration(milliseconds: 400), () { if (mounted) _load(); }); } Map get _query { if (_convId.isNotEmpty) return {'conversationId': _convId}; return {'peerId': widget.peerId}; } Map _metaOf(Map r) { final m = r['meta']; if (m is Map) return Map.from(m); if (m is String && m.isNotEmpty) { try { final d = jsonDecode(m); if (d is Map) return Map.from(d); } catch (_) {} } return {}; } Future _ackRead() async { if (_convId.isEmpty && widget.peerId.isEmpty) return; try { await widget.api.post('/im/read', { if (_convId.isNotEmpty) 'conversationId': _convId, if (widget.peerId.isNotEmpty) 'peerId': widget.peerId, }); } catch (_) {} } Future _load({bool jump = false}) async { final seq = ++_loadSeq; try { await ImSeqStore.ensure(); final query = Map.from(_query); if (_convId.isNotEmpty) { final since = await ImSeqStore.lastSeq(_convId); if (since > 0) query['sinceSeq'] = '$since'; } final data = await widget.api.get('/im/messages', query: query); final items = data is Map ? asMaps(data['items'] ?? data) : asMaps(data); if (data is Map && data['conversationId'] != null) _convId = '${data['conversationId']}'; final local = await ChatLocalStore.load(_convId); final byKey = >{}; for (final e in [...local, ...items]) { final fp = '${e['fingerprint'] ?? ''}'; final s = (e['seq'] as num?)?.toInt() ?? 0; final key = fp.isNotEmpty ? 'fp:$fp' : (s > 0 ? 'seq:$s' : 'id:${e['id'] ?? ''}'); if (key != '|' && key != 'id:') byKey[key] = e; } final merged = byKey.values.toList() ..sort((a, b) { final sa = (a['seq'] as num?)?.toInt() ?? 0; final sb = (b['seq'] as num?)?.toInt() ?? 0; if (sa > 0 && sb > 0 && sa != sb) return sa.compareTo(sb); return '${a['createdAt'] ?? ''}'.compareTo('${b['createdAt'] ?? ''}'); }); final cut = ChatPrefs.clearedAt(_convId); final shown = cut == null ? merged : merged.where((e) { final t = DateTime.tryParse('${e['createdAt'] ?? ''}'); return t == null || t.isAfter(cut); }).toList(); if (!mounted || seq != _loadSeq) return; final pending = _items.where((e) => e['_pending'] == true).toList(); final fps = {for (final e in shown) '${e['fingerprint'] ?? ''}'}; final keep = pending.where((e) => !fps.contains('${e['fingerprint'] ?? ''}')).toList(); setState(() => _items = [...shown, ...keep]); await ChatLocalStore.save(_convId, merged); if (_convId.isNotEmpty) await ImSeqStore.applyItems(_convId, merged); if (jump || _firstLoad) { _firstLoad = false; await Future.delayed(const Duration(milliseconds: 50)); _jumpBottom(); } } catch (_) {} } void _jumpBottom() { if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent); } Future _post(Map body) async { final fp = '${body['fingerprint'] ?? const Uuid().v4()}'; final local = { 'id': 'local-$fp', 'fromUserId': widget.session.userId, 'fromName': widget.session.displayName, 'body': body['body'], 'contentType': body['contentType'] ?? 'text', 'kind': 'chat', 'fingerprint': fp, 'meta': body['meta'], 'createdAt': DateTime.now().toIso8601String(), '_pending': true, }; if (mounted) { setState(() => _items = [..._items, local]); WidgetsBinding.instance.addPostFrameCallback((_) => _jumpBottom()); } try { await widget.api.post('/im/messages', { if (widget.peerId.isNotEmpty) 'peerId': widget.peerId, if (_convId.isNotEmpty) 'conversationId': _convId, 'fingerprint': fp, ...body, }); widget.session.im.bump(); await _load(jump: true); } catch (e) { if (mounted) { setState(() => _items = _items.where((e) => e['id'] != local['id']).toList()); deskToast(context, '$e', error: true); } } } Future _send() async { final text = _input.text.trim(); if (text.isEmpty || _sending) return; _input.clear(); setState(() => _emoji = false); await _post({'body': text}); } Future _onScreenshot() async { try { if (_convId.isEmpty && widget.peerId.isEmpty) return; await widget.api.post('/im/events/screenshot', { if (_convId.isNotEmpty) 'conversationId': _convId, if (widget.peerId.isNotEmpty) 'peerId': widget.peerId, }); await _load(); } catch (_) {} } Future> _upload(String path, String name, String bizType, {String? mime}) async { final bizId = _convId.isNotEmpty ? _convId : widget.session.userId; return widget.api.uploadFile(filePath: path, filename: name, bizType: bizType, bizId: bizId, mime: mime); } Future _pickImage() async { try { final picked = await DeviceBridge.pickMedia(); if (picked == null || (picked['path'] ?? '').isEmpty) return; setState(() => _sending = true); final file = await _upload(picked['path']!, picked['name'] ?? 'image.jpg', 'IM_IMAGE', mime: picked['mime']); await _post({ 'body': '[图片]', 'contentType': 'image', 'meta': {'fileId': file['id'], 'fileName': file['fileName'] ?? picked['name']}, }); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } finally { if (mounted) setState(() => _sending = false); } } Future _pickFile() async { try { final picked = await DeviceBridge.pickFile(); if (picked == null || (picked['path'] ?? '').isEmpty) return; setState(() => _sending = true); final name = picked['name'] ?? '文件'; final file = await _upload(picked['path']!, name, 'IM_FILE', mime: picked['mime']); await _post({ 'body': '[文件] $name', 'contentType': 'file', 'meta': {'fileId': file['id'], 'fileName': file['fileName'] ?? name, 'size': file['size']}, }); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } finally { if (mounted) setState(() => _sending = false); } } Future _sendSticker(String fileId) async { await _post({ 'body': '[表情]', 'contentType': 'sticker', 'meta': {'fileId': fileId}, }); if (mounted) setState(() => _emoji = false); } Future _addCustomSticker() async { try { final picked = await DeviceBridge.pickMedia(); if (picked == null || (picked['path'] ?? '').isEmpty) return; setState(() => _sending = true); final file = await _upload(picked['path']!, picked['name'] ?? 'sticker.gif', 'IM_STICKER', mime: picked['mime']); final id = '${file['id']}'; await StickerStore.add(id); await _sendSticker(id); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } finally { if (mounted) setState(() => _sending = false); } } Future _saveChatImage(String fileId) async { try { final bytes = await widget.api.fileBytes(fileId); if (bytes == null || bytes.isEmpty) throw Exception('图片无法下载'); final dir = await getTemporaryDirectory(); final f = File('${dir.path}/img_$fileId.jpg'); await f.writeAsBytes(bytes, flush: true); await DeviceBridge.saveImage(f.path); if (mounted) deskToast(context, '已保存'); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } } Future _call(String kind) async { try { final data = await widget.api.post('/im/calls', { if (_convId.isNotEmpty) 'conversationId': _convId, if (widget.peerId.isNotEmpty) 'peerId': widget.peerId, 'kind': kind, }); if (!mounted || data is! Map) return; openCallPage(context, session: widget.session, api: widget.api, call: Map.from(data), peerName: widget.peerName); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } } Future _location() async { final picked = await Navigator.of(context).push(MaterialPageRoute( builder: (_) => LocationPickPage(api: widget.api), )); if (picked == null) return; await _post({ 'body': picked.title, 'contentType': 'location', 'meta': picked.toMeta(), }); } Future _forwardMessage(Map r) async { final picked = await Navigator.of(context).push>>(MaterialPageRoute( builder: (_) => DeskPickPeoplePage(api: widget.api, title: '转发给', multiple: true, exclude: {widget.session.userId}), )); if (picked == null || picked.isEmpty) return; final meta = _metaOf(r); try { for (final p in picked) { await widget.api.post('/im/messages', { 'peerId': '${p['id']}', 'body': r['body'] ?? '', 'contentType': r['contentType'] ?? 'text', if (meta.isNotEmpty) 'meta': meta, }); } if (mounted) deskToast(context, '已转发给 ${picked.length} 人'); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } } Future _editMessage(Map r) async { final id = '${r['id'] ?? ''}'; if (id.isEmpty || id.startsWith('local-')) return; final c = TextEditingController(text: '${r['body'] ?? ''}'); final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(r['recalledAt'] != null ? '重新编辑' : '编辑消息'), content: TextField(controller: c, maxLines: 4, autofocus: true), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')), ], ), ); if (ok != true) return; try { await widget.api.patch('/im/messages/$id', {'body': c.text.trim()}); await _load(); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } } void _openLocation(Map meta, String fallback) { Navigator.of(context).push(MaterialPageRoute( builder: (_) => LocationViewPage( api: widget.api, title: '${meta['title'] ?? fallback}', address: '${meta['address'] ?? ''}', lat: (meta['lat'] as num?)?.toDouble(), lng: (meta['lng'] as num?)?.toDouble(), ), )); } Future _recall(Map r) async { final id = '${r['id'] ?? ''}'; if (id.isEmpty || id.startsWith('local-')) return; try { await widget.api.post('/im/messages/$id/recall', {}); await _load(); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } } Future _playVoice(Map r) async { final meta = _metaOf(r); final fileId = '${meta['fileId'] ?? ''}'; final sec = (meta['seconds'] as num?)?.toInt() ?? 1; if (fileId.isEmpty) return; if (_playingId == fileId) { await DeviceBridge.stopPlay(); _playTimer?.cancel(); setState(() => _playingId = null); return; } try { final bytes = await widget.api.fileBytes(fileId); if (bytes == null || bytes.isEmpty) throw Exception('语音无法下载'); final dir = await getTemporaryDirectory(); final f = File('${dir.path}/voice_$fileId.m4a'); await f.writeAsBytes(bytes, flush: true); await DeviceBridge.playVoice(f.path); _playTimer?.cancel(); setState(() => _playingId = fileId); _playTimer = Timer(Duration(seconds: sec.clamp(1, 60) + 1), () { if (mounted) setState(() => _playingId = null); }); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } } Future _voiceToText(Map row, String fileId) async { final key = _voiceKey(row, fileId); final cached = ChatPrefs.voiceText(key); if (cached != null && cached.isNotEmpty) { setState(() {}); return; } if (_voiceTextLoading.contains(key)) return; setState(() => _voiceTextLoading.add(key)); try { final bytes = await widget.api.fileBytes(fileId); if (bytes == null || bytes.isEmpty) throw Exception('语音无法下载'); final dir = await getTemporaryDirectory(); final file = File('${dir.path}/asr_$fileId.m4a'); await file.writeAsBytes(bytes, flush: true); final text = await DeviceBridge.speechToText(audioPath: file.path); if (!mounted) return; if (text == null || text.trim().isEmpty) { deskToast(context, '未识别到文字,请在手机端使用', error: true); return; } await ChatPrefs.setVoiceText(key, text.trim()); if (mounted) setState(() {}); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } finally { if (mounted) setState(() => _voiceTextLoading.remove(key)); } } String _voiceKey(Map row, String fileId) { final id = '${row['id'] ?? ''}'; return id.isNotEmpty ? id : fileId; } Future _beginVoice() async { setState(() { _recording = true; _cancelVoice = false; }); _voiceDown = DateTime.now(); _voiceStart = DeviceBridge.startVoice(); try { await _voiceStart; if (!_pressed) { await DeviceBridge.cancelVoice(); if (mounted) setState(() => _recording = false); } } catch (e) { if (mounted) { setState(() => _recording = false); deskToast(context, '$e', error: true); } } } Future _endVoice() async { if (!_pressed && !_recording) return; final cancel = _cancelVoice; final start = _voiceDown; _pressed = false; _voiceDown = null; if (mounted) { setState(() { _recording = false; _cancelVoice = false; }); } try { await _voiceStart; } catch (_) { await DeviceBridge.cancelVoice(); return; } if (cancel || start == null) { await DeviceBridge.cancelVoice(); return; } final ms = DateTime.now().difference(start).inMilliseconds; if (ms < 700) { await DeviceBridge.cancelVoice(); if (mounted) deskToast(context, '说话时间太短'); return; } try { final file = await DeviceBridge.stopVoice(); final path = '${file?['path'] ?? ''}'; if (path.isEmpty) { if (mounted) deskToast(context, '说话时间太短'); return; } setState(() => _sending = true); final sec = (ms / 1000).round().clamp(1, 60); final up = await _upload(path, file?['name']?.toString() ?? 'voice.m4a', 'IM_VOICE', mime: 'audio/mp4'); await _post({ 'body': '[语音] $sec″', 'contentType': 'voice', 'meta': {'fileId': up['id'], 'seconds': sec, 'fileName': up['fileName']}, }); } catch (e) { await DeviceBridge.cancelVoice(); if (mounted) deskToast(context, '$e', error: true); } finally { if (mounted) setState(() => _sending = false); } } Future _bubbleMenu(Map r, Offset pos) async { final mine = '${r['fromUserId']}' == widget.session.userId; final ctype = '${r['contentType'] ?? 'text'}'; final meta = _metaOf(r); final fileId = '${meta['fileId'] ?? ''}'; final created = DateTime.tryParse('${r['createdAt'] ?? ''}'); final canRecall = mine && ctype == 'text' && r['recalledAt'] == null && created != null && DateTime.now().difference(created).inMinutes < 2; final canEdit = mine && ctype == 'text' && (r['recalledAt'] != null || (created != null && DateTime.now().difference(created).inMinutes < 2)); final action = await showMenu( context: context, position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx + 1, pos.dy + 1), items: [ if (ctype == 'text') const PopupMenuItem(value: 'copy', child: Text('复制')), if (fileId.isNotEmpty && (ctype == 'image' || ctype == 'sticker')) const PopupMenuItem(value: 'view', child: Text('查看')), if (fileId.isNotEmpty && (ctype == 'image' || ctype == 'sticker')) const PopupMenuItem(value: 'save', child: Text('保存图片')), if (ctype == 'sticker' && fileId.isNotEmpty) PopupMenuItem(value: 'sticker', child: Text(StickerStore.has(fileId) ? '已在表情' : '添加到表情')), if (ctype == 'voice' && fileId.isNotEmpty) const PopupMenuItem(value: 'voiceText', child: Text('语音转文字')), const PopupMenuItem(value: 'forward', child: Text('转发')), if (canEdit) PopupMenuItem(value: 'edit', child: Text(r['recalledAt'] != null ? '重新编辑' : '编辑')), if (canRecall) const PopupMenuItem(value: 'recall', child: Text('撤回')), ], ); if (!mounted || action == null) return; if (action == 'copy') { await Clipboard.setData(ClipboardData(text: '${r['body'] ?? ''}')); deskToast(context, '已复制'); } else if (action == 'view') { _previewImage(fileId); } else if (action == 'save') { await _saveChatImage(fileId); } else if (action == 'sticker') { await StickerStore.add(fileId); if (mounted) deskToast(context, '已添加到表情'); } else if (action == 'voiceText') { await _voiceToText(r, fileId); } else if (action == 'forward') { await _forwardMessage(r); } else if (action == 'edit') { await _editMessage(r); } else if (action == 'recall') { await _recall(r); } } void _previewImage(String fileId) { showDialog( context: context, builder: (_) => Dialog( backgroundColor: Colors.black, insetPadding: const EdgeInsets.all(24), child: InteractiveViewer(child: AuthImage(fileId: fileId, api: widget.api, fit: BoxFit.contain)), ), ); } Future _openBusiness(Map meta) async { final path = '${meta['fetchPath'] ?? ''}'; if (!path.startsWith('/') || path.contains('..')) return; try { final raw = await widget.api.get(path); if (!mounted) return; final row = raw is Map ? Map.from(raw) : {}; row['_fetch'] = path; Navigator.of(context).push(MaterialPageRoute( builder: (_) => DeskRecordDetailPage(api: widget.api, seed: row, title: '${meta['title'] ?? '详情'}'), )); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } } Future _openFile(String fileId, String name, String mime) async { try { final bytes = await widget.api.fileBytes(fileId); if (bytes == null) throw Exception('无法下载'); final dir = await getTemporaryDirectory(); final f = File('${dir.path}/${name.replaceAll(RegExp(r'[/\\]'), '_')}'); await f.writeAsBytes(bytes, flush: true); await DeviceBridge.openFile(f.path, mime: mime); } catch (e) { if (mounted) deskToast(context, '$e', error: true); } } void _openDetail() { Navigator.of(context).push(MaterialPageRoute( builder: (_) => DeskChatDetailPage( session: widget.session, api: widget.api, conversationId: _convId, peerId: widget.peerId, peerName: widget.peerName, isGroup: widget.isGroup, peerAvatarFileId: widget.peerAvatarFileId, onCall: _call, ), )); } @override Widget build(BuildContext context) { final me = widget.session.userId; final myAvatar = '${widget.session.user['avatarFileId'] ?? ''}'; return ColoredBox( color: kDeskBg, child: Column( children: [ DeskPaneHeader( title: widget.peerName, actions: [ IconButton(onPressed: () => _call('voice'), icon: const Icon(Icons.call_outlined, size: 20), tooltip: '语音通话'), IconButton(onPressed: () => _call('video'), icon: const Icon(Icons.videocam_outlined, size: 20), tooltip: '视频通话'), IconButton(onPressed: _openDetail, icon: const Icon(Icons.more_horiz, size: 20)), ], ), Expanded( child: ListView.builder( controller: _scroll, padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), itemCount: _items.length, itemBuilder: (_, i) => _bubble(_items[i], i, me, myAvatar), ), ), _composer(), if (_emoji) _emojiPanel(), ], ), ); } Widget _composer() { return ColoredBox( color: kDeskPane, child: Column( children: [ const Divider(height: 1, color: kDeskLine), Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), child: Row( children: [ _toolBtn( _voice ? Icons.keyboard_outlined : Icons.mic_none_outlined, () => setState(() { _voice = !_voice; _emoji = false; }), tooltip: _voice ? '键盘' : '语音', ), _toolBtn(Icons.emoji_emotions_outlined, () => setState(() { _emoji = !_emoji; _voice = false; })), _toolBtn(Icons.content_cut_outlined, _onScreenshot, tooltip: '截屏通知'), _toolBtn(Icons.image_outlined, _pickImage), _toolBtn(Icons.folder_open_outlined, _pickFile), _toolBtn(Icons.place_outlined, _location, tooltip: '位置'), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: _voice ? Listener( onPointerDown: (_) { _pressed = true; _beginVoice(); }, onPointerUp: (_) => _endVoice(), onPointerCancel: (_) => _endVoice(), child: Container( height: 80, alignment: Alignment.center, decoration: BoxDecoration( color: _recording ? const Color(0xFFE0E0E0) : const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(4), border: Border.all(color: kDeskLine), ), child: Text( _recording ? '松开 发送' : '按住 说话', style: TextStyle(fontSize: 14, color: _recording ? kDeskGreen : kDeskInk), ), ), ) : Container( constraints: const BoxConstraints(minHeight: 80, maxHeight: 160), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(4), ), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: TextField( controller: _input, focusNode: _focus, maxLines: 6, minLines: 3, style: const TextStyle(fontSize: 14), decoration: const InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, hintText: '输入消息', isDense: true, contentPadding: EdgeInsets.zero, ), onSubmitted: (_) => _send(), ), ), ), const SizedBox(width: 10), if (!_voice) FilledButton( style: FilledButton.styleFrom( backgroundColor: kDeskGreen, minimumSize: const Size(72, 36), padding: const EdgeInsets.symmetric(horizontal: 16), ), onPressed: _sending ? null : _send, child: const Text('发送(S)', style: TextStyle(fontSize: 13)), ), ], ), ), ], ), ); } Widget _toolBtn(IconData icon, VoidCallback onTap, {String? tooltip}) { return IconButton( onPressed: onTap, icon: Icon(icon, size: 20, color: const Color(0xFF5C5C5C)), tooltip: tooltip, visualDensity: VisualDensity.compact, padding: const EdgeInsets.all(6), constraints: const BoxConstraints(minWidth: 32, minHeight: 32), ); } Widget _emojiPanel() { return ColoredBox( color: kDeskPane, child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 180, child: _emojiTab == 0 ? GridView.count( crossAxisCount: 10, padding: const EdgeInsets.all(8), children: [ for (final e in _emojis) InkWell( onTap: () { _input.text += e; _input.selection = TextSelection.collapsed(offset: _input.text.length); }, child: Center(child: Text(e, style: const TextStyle(fontSize: 22))), ), ], ) : GridView.count( crossAxisCount: 6, padding: const EdgeInsets.all(8), children: [ InkWell( onTap: _addCustomSticker, child: Container( margin: const EdgeInsets.all(4), decoration: BoxDecoration(border: Border.all(color: kDeskLine), borderRadius: BorderRadius.circular(6)), child: const Icon(Icons.add, color: kDeskMute), ), ), for (final id in StickerStore.ids) GestureDetector( onTap: () => _sendSticker(id), child: Padding( padding: const EdgeInsets.all(4), child: AuthImage(fileId: id, api: widget.api, width: 48, height: 48, fit: BoxFit.contain), ), ), ], ), ), Row( children: [ _emojiTabBtn(0, '表情'), _emojiTabBtn(1, '自定义'), ], ), ], ), ); } Widget _emojiTabBtn(int i, String label) { final on = _emojiTab == i; return InkWell( onTap: () => setState(() => _emojiTab = i), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: on ? kDeskBg : Colors.transparent, border: on ? const Border(top: BorderSide(color: kDeskLine)) : null, ), child: Text(label, style: TextStyle(fontSize: 13, color: on ? kDeskInk : kDeskMute)), ), ); } Widget _bubble(Map r, int i, String me, String myAvatar) { final mine = '${r['fromUserId']}' == me; final ctype = '${r['contentType'] ?? 'text'}'; var body = '${r['body'] ?? ''}'; final showTime = i == 0 || shortTime(r['createdAt']) != shortTime(_items[i - 1]['createdAt']); if (r['recalledAt'] != null) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Center(child: Text(mine ? '你撤回了一条消息' : '对方撤回了一条消息', style: const TextStyle(fontSize: 12, color: kDeskMute))), ); } final meta = _metaOf(r); final fileId = '${meta['fileId'] ?? ''}'; final avatarId = mine ? myAvatar : '${r['fromAvatarFileId'] ?? widget.peerAvatarFileId}'; final sticker = ctype == 'sticker' && fileId.isNotEmpty; final image = fileId.isNotEmpty && ctype == 'image'; final fileMsg = ctype == 'file'; final business = ctype == 'business'; final voice = ctype == 'voice'; final loc = ctype == 'location'; final sec = (meta['seconds'] as num?)?.toInt() ?? 1; final locLat = (meta['lat'] as num?)?.toDouble(); final locLng = (meta['lng'] as num?)?.toDouble(); return Column( children: [ if (showTime) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text(shortTime(r['createdAt']), style: const TextStyle(fontSize: 11, color: kDeskMute)), ), Row( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: mine ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ if (!mine) ...[ DeskAvatar(label: '${r['fromName'] ?? widget.peerName}', fileId: avatarId, api: widget.api, size: 36), const SizedBox(width: 8), ], Flexible( child: voice ? GestureDetector( onSecondaryTapDown: (d) => _bubbleMenu(r, d.globalPosition), child: VoiceMessageBlock( mine: mine, seconds: sec, playing: _playingId == fileId, transcript: ChatPrefs.voiceText(_voiceKey(r, fileId)), loading: _voiceTextLoading.contains(_voiceKey(r, fileId)), onTap: () => _playVoice(r), bubbleColor: kDeskBubbleMine, iconColor: kDeskInk, bottomMargin: 10, ), ) : GestureDetector( onSecondaryTapDown: (d) => _bubbleMenu(r, d.globalPosition), onTap: business ? () => _openBusiness(meta) : loc ? () => _openLocation(meta, body) : (image || sticker) && fileId.isNotEmpty ? () => _previewImage(fileId) : (fileMsg && fileId.isNotEmpty) ? () => _openFile(fileId, '${meta['fileName'] ?? body}', '${meta['mime'] ?? ''}') : null, child: Container( margin: const EdgeInsets.only(bottom: 10), padding: sticker || image ? const EdgeInsets.all(4) : const EdgeInsets.symmetric(horizontal: 12, vertical: 9), constraints: BoxConstraints(maxWidth: sticker ? 140 : (image ? 320 : (voice ? 200 : (loc ? 240 : 480)))), decoration: BoxDecoration( color: sticker || image ? Colors.transparent : (mine ? kDeskBubbleMine : Colors.white), borderRadius: BorderRadius.circular(6), boxShadow: sticker || image || mine ? null : [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 2, offset: const Offset(0, 1))], ), child: sticker ? AuthImage(fileId: fileId, api: widget.api, width: 120, height: 120, fit: BoxFit.contain) : image ? ClipRRect( borderRadius: BorderRadius.circular(4), child: AuthImage(fileId: fileId, api: widget.api, width: 240, height: 180, fit: BoxFit.cover), ) : business ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${meta['title'] ?? '业务卡片'}', style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text(body, style: const TextStyle(fontSize: 13, color: kDeskMute)), ], ) : loc ? SizedBox( width: 220, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(4, 2, 4, 0), child: Text( meta['title']?.toString().isNotEmpty == true ? '${meta['title']}' : body, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), ), ), if ('${meta['address'] ?? ''}'.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(4, 2, 4, 4), child: Text('${meta['address']}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: kDeskMute)), ), if (locLat != null && locLng != null) ClipRRect( borderRadius: BorderRadius.circular(4), child: TencentMapThumb(api: widget.api, lat: locLat, lng: locLng, width: 212, height: 100), ), ], ), ) : Text(body, style: const TextStyle(fontSize: 14, height: 1.45)), ), ), ), if (mine) const SizedBox(width: 8), if (mine) DeskAvatar(label: widget.session.displayName, fileId: myAvatar, api: widget.api, size: 36), ], ), ], ); } }