76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
1012 lines
38 KiB
Dart
1012 lines
38 KiB
Dart
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<DeskChatPage> createState() => _DeskChatPageState();
|
|
}
|
|
|
|
class _DeskChatPageState extends State<DeskChatPage> {
|
|
final _input = TextEditingController();
|
|
final _scroll = ScrollController();
|
|
final _focus = FocusNode();
|
|
List<Map<String, dynamic>> _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<String> _voiceTextLoading = {};
|
|
Timer? _playTimer;
|
|
Timer? _receiptPoll;
|
|
Timer? _imDebounce;
|
|
DateTime? _voiceDown;
|
|
Future<void>? _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<String, String> get _query {
|
|
if (_convId.isNotEmpty) return {'conversationId': _convId};
|
|
return {'peerId': widget.peerId};
|
|
}
|
|
|
|
Map<String, dynamic> _metaOf(Map<String, dynamic> r) {
|
|
final m = r['meta'];
|
|
if (m is Map) return Map<String, dynamic>.from(m);
|
|
if (m is String && m.isNotEmpty) {
|
|
try {
|
|
final d = jsonDecode(m);
|
|
if (d is Map) return Map<String, dynamic>.from(d);
|
|
} catch (_) {}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
Future<void> _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<void> _load({bool jump = false}) async {
|
|
final seq = ++_loadSeq;
|
|
try {
|
|
await ImSeqStore.ensure();
|
|
final query = Map<String, String>.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 = <String, Map<String, dynamic>>{};
|
|
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<void>.delayed(const Duration(milliseconds: 50));
|
|
_jumpBottom();
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
void _jumpBottom() {
|
|
if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent);
|
|
}
|
|
|
|
Future<void> _post(Map<String, dynamic> body) async {
|
|
final fp = '${body['fingerprint'] ?? const Uuid().v4()}';
|
|
final local = <String, dynamic>{
|
|
'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<void> _send() async {
|
|
final text = _input.text.trim();
|
|
if (text.isEmpty || _sending) return;
|
|
_input.clear();
|
|
setState(() => _emoji = false);
|
|
await _post({'body': text});
|
|
}
|
|
|
|
Future<void> _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<Map<String, dynamic>> _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<void> _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<void> _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<void> _sendSticker(String fileId) async {
|
|
await _post({
|
|
'body': '[表情]',
|
|
'contentType': 'sticker',
|
|
'meta': {'fileId': fileId},
|
|
});
|
|
if (mounted) setState(() => _emoji = false);
|
|
}
|
|
|
|
Future<void> _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<void> _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<void> _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<String, dynamic>.from(data), peerName: widget.peerName);
|
|
} catch (e) {
|
|
if (mounted) deskToast(context, '$e', error: true);
|
|
}
|
|
}
|
|
|
|
Future<void> _location() async {
|
|
final picked = await Navigator.of(context).push<GeoPlace>(MaterialPageRoute(
|
|
builder: (_) => LocationPickPage(api: widget.api),
|
|
));
|
|
if (picked == null) return;
|
|
await _post({
|
|
'body': picked.title,
|
|
'contentType': 'location',
|
|
'meta': picked.toMeta(),
|
|
});
|
|
}
|
|
|
|
Future<void> _forwardMessage(Map<String, dynamic> r) async {
|
|
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(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<void> _editMessage(Map<String, dynamic> r) async {
|
|
final id = '${r['id'] ?? ''}';
|
|
if (id.isEmpty || id.startsWith('local-')) return;
|
|
final c = TextEditingController(text: '${r['body'] ?? ''}');
|
|
final ok = await showDialog<bool>(
|
|
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<String, dynamic> 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<void> _recall(Map<String, dynamic> 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<void> _playVoice(Map<String, dynamic> 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<void> _voiceToText(Map<String, dynamic> 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<String, dynamic> row, String fileId) {
|
|
final id = '${row['id'] ?? ''}';
|
|
return id.isNotEmpty ? id : fileId;
|
|
}
|
|
|
|
Future<void> _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<void> _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<void> _bubbleMenu(Map<String, dynamic> 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<String>(
|
|
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<void>(
|
|
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<void> _openBusiness(Map<String, dynamic> 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<String, dynamic>.from(raw) : <String, dynamic>{};
|
|
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<void> _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<String, dynamic> 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),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|