76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
2002 lines
64 KiB
Dart
2002 lines
64 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:image_picker/image_picker.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_prefs.dart';
|
|
import '../im/chat_local_store.dart';
|
|
import '../im/im_seq_store.dart';
|
|
import '../im/sticker_store.dart';
|
|
import '../session/session.dart';
|
|
import '../theme.dart';
|
|
import '../widgets/auth_image.dart';
|
|
import '../widgets/tencent_map_thumb.dart';
|
|
import '../widgets/voice_message.dart';
|
|
import '../widgets/wecom.dart';
|
|
import 'call_page.dart';
|
|
import 'chat_detail_page.dart';
|
|
import 'location_pick_page.dart';
|
|
import 'pick_people_page.dart';
|
|
import 'record_detail_page.dart';
|
|
|
|
const _emojis = [
|
|
'😀',
|
|
'😁',
|
|
'😂',
|
|
'🤣',
|
|
'😃',
|
|
'😄',
|
|
'😅',
|
|
'😆',
|
|
'😉',
|
|
'😊',
|
|
'😋',
|
|
'😎',
|
|
'😍',
|
|
'😘',
|
|
'🥰',
|
|
'😗',
|
|
'😙',
|
|
'😚',
|
|
'🙂',
|
|
'🤗',
|
|
'🤩',
|
|
'🤔',
|
|
'🤨',
|
|
'😐',
|
|
'😑',
|
|
'😶',
|
|
'🙄',
|
|
'😏',
|
|
'😣',
|
|
'😥',
|
|
'😮',
|
|
'🤐',
|
|
'😯',
|
|
'😪',
|
|
'😫',
|
|
'🥱',
|
|
'😴',
|
|
'😌',
|
|
'😛',
|
|
'😜',
|
|
'😝',
|
|
'🤤',
|
|
'😒',
|
|
'😓',
|
|
'😔',
|
|
'😕',
|
|
'🙃',
|
|
'🤑',
|
|
'😲',
|
|
'🙁',
|
|
'😖',
|
|
'😞',
|
|
'😟',
|
|
'😤',
|
|
'😢',
|
|
'😭',
|
|
'😦',
|
|
'😧',
|
|
'😨',
|
|
'😩',
|
|
'🤯',
|
|
'😬',
|
|
'😰',
|
|
'😱',
|
|
'🥵',
|
|
'🥶',
|
|
'😳',
|
|
'🤪',
|
|
'😵',
|
|
'😡',
|
|
'😠',
|
|
'🤬',
|
|
'😷',
|
|
'🤒',
|
|
'🤕',
|
|
'🤢',
|
|
'🤮',
|
|
'🤧',
|
|
'😇',
|
|
'🥳',
|
|
'🥺',
|
|
'🤠',
|
|
'🤡',
|
|
'🤥',
|
|
'🤫',
|
|
'🤭',
|
|
'🧐',
|
|
'🤓',
|
|
'😈',
|
|
'👿',
|
|
'👹',
|
|
'👺',
|
|
'💀',
|
|
'👻',
|
|
'👽',
|
|
'🤖',
|
|
'💩',
|
|
'😺',
|
|
'😸',
|
|
'😹',
|
|
'😻',
|
|
'😼',
|
|
'😽',
|
|
'🙀',
|
|
'😿',
|
|
'😾',
|
|
'🙈',
|
|
'🙉',
|
|
'🙊',
|
|
'💋',
|
|
'💌',
|
|
'💘',
|
|
'💝',
|
|
'💖',
|
|
'💗',
|
|
'💓',
|
|
'💞',
|
|
'💕',
|
|
'💟',
|
|
'❣️',
|
|
'💔',
|
|
'❤️',
|
|
'🧡',
|
|
'💛',
|
|
'💚',
|
|
'💙',
|
|
'💜',
|
|
'🖤',
|
|
'👍',
|
|
'👎',
|
|
'👌',
|
|
'✌️',
|
|
'🤞',
|
|
'🤟',
|
|
'🤘',
|
|
'🤙',
|
|
'👈',
|
|
'👉',
|
|
'👆',
|
|
'👇',
|
|
'☝️',
|
|
'✋',
|
|
'🤚',
|
|
'🖐',
|
|
'🖖',
|
|
'👋',
|
|
'🤝',
|
|
'🙏',
|
|
'💪',
|
|
'🦾',
|
|
'👏',
|
|
'🙌',
|
|
'👐',
|
|
'🤲',
|
|
'✊',
|
|
'👊',
|
|
'🤛',
|
|
'🤜',
|
|
'🫶',
|
|
'👀',
|
|
'🔥',
|
|
'⭐',
|
|
'🌟',
|
|
'✨',
|
|
'🎉',
|
|
'🎊',
|
|
'🎈',
|
|
'🎁',
|
|
'🏆',
|
|
'🥇',
|
|
'🎯',
|
|
'📌',
|
|
'📍',
|
|
'💡',
|
|
'💯',
|
|
'✅',
|
|
'❌',
|
|
'❓',
|
|
'❗',
|
|
'💤',
|
|
'💢',
|
|
'💥',
|
|
'💦',
|
|
'💨',
|
|
'🌸',
|
|
'🌹',
|
|
'🍀',
|
|
'🌙',
|
|
'☀️',
|
|
'🌈',
|
|
'☕',
|
|
'🍺',
|
|
];
|
|
|
|
bool _isGifFile(String path, {String? name, String? mime}) {
|
|
final n = (name ?? path).toLowerCase();
|
|
if (n.endsWith('.gif')) return true;
|
|
if ((mime ?? '').toLowerCase().contains('gif')) return true;
|
|
try {
|
|
final raf = File(path).openSync();
|
|
final b = raf.readSync(6);
|
|
raf.closeSync();
|
|
return b.length >= 3 && b[0] == 0x47 && b[1] == 0x49 && b[2] == 0x46;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
class ChatPage extends StatefulWidget {
|
|
const ChatPage({
|
|
super.key,
|
|
required this.session,
|
|
required this.api,
|
|
this.peerId = '',
|
|
this.conversationId = '',
|
|
required this.peerName,
|
|
this.isGroup = false,
|
|
this.peerAvatarFileId,
|
|
this.embedded = false,
|
|
});
|
|
final SessionStore session;
|
|
final OaClient api;
|
|
final String peerId;
|
|
final String conversationId;
|
|
final String peerName;
|
|
final bool isGroup;
|
|
final String? peerAvatarFileId;
|
|
final bool embedded;
|
|
|
|
@override
|
|
State<ChatPage> createState() => _ChatPageState();
|
|
}
|
|
|
|
class _ChatPageState extends State<ChatPage> {
|
|
final _input = TextEditingController();
|
|
final _scroll = ScrollController();
|
|
final _focus = FocusNode();
|
|
List<Map<String, dynamic>> _items = [];
|
|
String _convId = '';
|
|
bool _sending = false;
|
|
bool _plus = false;
|
|
bool _emoji = false;
|
|
bool _voice = false;
|
|
bool _recording = false;
|
|
bool _cancelVoice = false;
|
|
bool _pressed = false;
|
|
int _emojiTab = 0;
|
|
DateTime? _voiceDown;
|
|
Offset? _downGlobal;
|
|
Future<void>? _voiceStart;
|
|
bool _firstLoad = true;
|
|
Timer? _receiptPoll;
|
|
Timer? _playTimer;
|
|
Timer? _imDebounce;
|
|
String? _playingId;
|
|
final Set<String> _voiceTextLoading = {};
|
|
Color _bg = const Color(0xFFF5F5F5);
|
|
int _loadSeq = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_convId = widget.conversationId;
|
|
widget.session.im.addListener(_onIm);
|
|
DeviceBridge.watchScreenshot(_onScreenshot);
|
|
ChatPrefs.ensure().then((_) {
|
|
StickerStore.ensure();
|
|
if (mounted) setState(() => _applyPrefs());
|
|
});
|
|
_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);
|
|
widget.session.im.bump();
|
|
_input.dispose();
|
|
_scroll.dispose();
|
|
_focus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _applyPrefs() {
|
|
final c = ChatPrefs.bgColor(_convId);
|
|
_bg = c == null ? const Color(0xFFF5F5F5) : Color(c);
|
|
}
|
|
|
|
void _hideBoard() {
|
|
_focus.unfocus();
|
|
if (_plus || _emoji)
|
|
setState(() {
|
|
_plus = false;
|
|
_emoji = false;
|
|
});
|
|
}
|
|
|
|
void _onIm() {
|
|
final inbox = widget.session.im.inbox;
|
|
if (inbox.isNotEmpty) {
|
|
final last = inbox.first;
|
|
final conv = '${last.raw['conversationId'] ?? ''}';
|
|
final same = conv.isEmpty || conv == _convId;
|
|
if (same && last.kind == 'receipt') {
|
|
final me = widget.session.userId;
|
|
setState(() {
|
|
_items = [
|
|
for (final e in _items)
|
|
'${e['fromUserId']}' == me
|
|
? {
|
|
...e,
|
|
'readCount': (((e['readCount'] as num?)?.toInt() ?? 0) < 1
|
|
? 1
|
|
: e['readCount'])
|
|
}
|
|
: e
|
|
];
|
|
});
|
|
} else if (same && last.kind == 'readSync') {
|
|
// 多端已读同步由会话列表角标处理,此处仅刷新消息
|
|
}
|
|
}
|
|
_ackRead();
|
|
_imDebounce?.cancel();
|
|
_imDebounce = Timer(const Duration(milliseconds: 400), () {
|
|
if (mounted) _load();
|
|
});
|
|
}
|
|
|
|
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 (_) {}
|
|
}
|
|
|
|
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> _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];
|
|
_applyPrefs();
|
|
});
|
|
await ChatLocalStore.save(_convId, merged);
|
|
if (_convId.isNotEmpty) await ImSeqStore.applyItems(_convId, merged);
|
|
final shouldJump = jump || _firstLoad;
|
|
_firstLoad = false;
|
|
if (shouldJump) {
|
|
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 {
|
|
await _optimisticPost(body);
|
|
}
|
|
|
|
Future<void> _optimisticPost(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(),
|
|
'readCount': 0,
|
|
'_pending': true,
|
|
};
|
|
if (mounted) {
|
|
setState(() => _items = [..._items, local]);
|
|
unawaited(ChatLocalStore.save(_convId, _items));
|
|
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,
|
|
});
|
|
await DeviceBridge.playMessageSent();
|
|
widget.session.im.bump();
|
|
await _load(jump: true);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() =>
|
|
_items = _items.where((e) => e['id'] != local['id']).toList());
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> _send() async {
|
|
final text = _input.text.trim();
|
|
if (text.isEmpty || _sending) return;
|
|
_input.clear();
|
|
try {
|
|
await _optimisticPost({'body': text});
|
|
} catch (e) {
|
|
if (mounted) {
|
|
_input.text = text;
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('$e')));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _onScreenshot() async {
|
|
if (!mounted) return;
|
|
try {
|
|
await _ensureConv();
|
|
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<void> _ensureConv() async {
|
|
if (_convId.isEmpty) await _load();
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _upload(String path, String name, String bizType,
|
|
{String? mime}) async {
|
|
await _ensureConv();
|
|
final bizId = _convId.isNotEmpty ? _convId : widget.session.userId;
|
|
return widget.api.uploadFile(
|
|
filePath: path,
|
|
filename: name,
|
|
bizType: bizType,
|
|
bizId: bizId,
|
|
mime: mime);
|
|
}
|
|
|
|
Future<void> _sendMediaFile({
|
|
required String path,
|
|
required String name,
|
|
String? mime,
|
|
required bool sticker,
|
|
}) async {
|
|
setState(() => _sending = true);
|
|
try {
|
|
final gif = sticker || _isGifFile(path, name: name, mime: mime);
|
|
Map<String, dynamic> file;
|
|
if (gif) {
|
|
try {
|
|
file = await _upload(path, name, 'IM_STICKER', mime: mime);
|
|
} catch (_) {
|
|
file = await _upload(path, name, 'IM_IMAGE', mime: mime);
|
|
}
|
|
} else {
|
|
file = await _upload(path, name, 'IM_IMAGE', mime: mime);
|
|
}
|
|
final id = '${file['id'] ?? ''}';
|
|
if (gif && id.isNotEmpty) await StickerStore.add(id);
|
|
await _post({
|
|
'body': gif ? '[表情]' : '[图片]',
|
|
'contentType': gif ? 'sticker' : 'image',
|
|
'meta': {
|
|
'fileId': id,
|
|
'fileName': file['fileName'] ?? name,
|
|
'mime': mime ?? (gif ? 'image/gif' : '')
|
|
},
|
|
});
|
|
} catch (e) {
|
|
if (mounted) showWxToast(context, '$e', error: true);
|
|
} finally {
|
|
if (mounted)
|
|
setState(() {
|
|
_sending = false;
|
|
_plus = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _pickAlbum() async {
|
|
try {
|
|
final picked = await DeviceBridge.pickMedia();
|
|
if (picked == null || (picked['path'] ?? '').isEmpty) return;
|
|
await _sendMediaFile(
|
|
path: picked['path']!,
|
|
name: picked['name'] ?? 'image.jpg',
|
|
mime: picked['mime']?.isNotEmpty == true ? picked['mime'] : null,
|
|
sticker: false,
|
|
);
|
|
} catch (e) {
|
|
if (mounted) showWxToast(context, '$e', error: true);
|
|
}
|
|
}
|
|
|
|
Future<void> _pickCamera() async {
|
|
try {
|
|
final x = await ImagePicker().pickImage(
|
|
source: ImageSource.camera, imageQuality: 85, maxWidth: 1920);
|
|
if (x == null) return;
|
|
await _sendMediaFile(
|
|
path: x.path,
|
|
name: x.name.isEmpty ? 'image.jpg' : x.name,
|
|
sticker: false);
|
|
} catch (e) {
|
|
if (mounted)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('$e')));
|
|
}
|
|
}
|
|
|
|
Future<void> _addCustomSticker() async {
|
|
try {
|
|
final picked = await DeviceBridge.pickMedia();
|
|
if (picked == null || (picked['path'] ?? '').isEmpty) return;
|
|
setState(() => _sending = true);
|
|
await _ensureConv();
|
|
Map<String, dynamic> file;
|
|
try {
|
|
file = await _upload(
|
|
picked['path']!, picked['name'] ?? 'sticker.gif', 'IM_STICKER',
|
|
mime: picked['mime']);
|
|
} catch (_) {
|
|
file = await _upload(
|
|
picked['path']!, picked['name'] ?? 'sticker.gif', 'IM_IMAGE',
|
|
mime: picked['mime']);
|
|
}
|
|
final id = '${file['id'] ?? ''}';
|
|
await StickerStore.add(id);
|
|
if (mounted) setState(() {});
|
|
} catch (e) {
|
|
if (mounted)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('$e')));
|
|
} finally {
|
|
if (mounted) setState(() => _sending = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _sendSticker(String fileId) async {
|
|
try {
|
|
await _post({
|
|
'body': '[表情]',
|
|
'contentType': 'sticker',
|
|
'meta': {'fileId': fileId},
|
|
});
|
|
} catch (e) {
|
|
if (mounted)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('$e')));
|
|
}
|
|
}
|
|
|
|
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(),
|
|
});
|
|
if (mounted) setState(() => _plus = false);
|
|
}
|
|
|
|
Future<void> _sendFile() async {
|
|
try {
|
|
final picked = await DeviceBridge.pickFile();
|
|
if (picked == null || (picked['path'] ?? '').isEmpty) return;
|
|
final name = picked['name'] ?? '文件';
|
|
final mime = picked['mime'];
|
|
if (_looksLikeImage(name, mime)) {
|
|
await _sendMediaFile(
|
|
path: picked['path']!, name: name, mime: mime, sticker: false);
|
|
return;
|
|
}
|
|
setState(() => _sending = true);
|
|
Map<String, dynamic> file;
|
|
try {
|
|
file = await _upload(picked['path']!, name, 'IM_FILE', mime: mime);
|
|
} catch (_) {
|
|
file = await _upload(picked['path']!, name, 'IM_IMAGE', mime: mime);
|
|
}
|
|
await _post({
|
|
'body': '[文件] $name',
|
|
'contentType': 'file',
|
|
'meta': {
|
|
'fileId': file['id'],
|
|
'fileName': file['fileName'] ?? name,
|
|
'size': file['size'],
|
|
'mime': picked['mime'] ?? file['mimeType'] ?? '',
|
|
},
|
|
});
|
|
} catch (e) {
|
|
if (mounted)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('$e')));
|
|
} finally {
|
|
if (mounted)
|
|
setState(() {
|
|
_sending = false;
|
|
_plus = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
String _sizeText(dynamic n) {
|
|
final b = (n as num?)?.toInt() ?? 0;
|
|
if (b < 1024) return '$b B';
|
|
if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB';
|
|
return '${(b / 1024 / 1024).toStringAsFixed(1)} MB';
|
|
}
|
|
|
|
Future<void> _openChatFile(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 safe = name.replaceAll(RegExp(r'[/\\]'), '_');
|
|
final f = File('${dir.path}/$safe');
|
|
await f.writeAsBytes(bytes, flush: true);
|
|
await DeviceBridge.openFile(f.path, mime: mime);
|
|
} catch (e) {
|
|
if (mounted)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('$e')));
|
|
}
|
|
}
|
|
|
|
bool _looksLikeImage(String name, String? mime) {
|
|
final m = (mime ?? '').toLowerCase();
|
|
if (m.startsWith('image/')) return true;
|
|
return RegExp(r'\.(jpe?g|png|gif|webp|bmp|heic|heif)(?:$|[?#\s])',
|
|
caseSensitive: false)
|
|
.hasMatch(name);
|
|
}
|
|
|
|
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) return;
|
|
if (data is Map) {
|
|
openCallPage(
|
|
context,
|
|
session: widget.session,
|
|
api: widget.api,
|
|
call: Map<String, dynamic>.from(data),
|
|
peerName: widget.peerName,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) showWxToast(context, '$e', error: true);
|
|
}
|
|
if (mounted) setState(() => _plus = false);
|
|
}
|
|
|
|
Future<void> _openBusinessCard(Map<String, dynamic> meta) async {
|
|
final path = '${meta['fetchPath'] ?? ''}';
|
|
final title = '${meta['title'] ?? '业务详情'}';
|
|
if (!path.startsWith('/') || path.contains('..')) {
|
|
showWxToast(context, '业务卡片地址无效', error: true);
|
|
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: (_) => RecordDetailPage(
|
|
api: widget.api,
|
|
seed: row,
|
|
title: title,
|
|
),
|
|
));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final message = '$e';
|
|
if (RegExp(r'无权|权限|禁止|403').hasMatch(message)) {
|
|
showWxToast(context, '您无权查看本信息', error: true);
|
|
} else {
|
|
showWxToast(context, message, error: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('$e')));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _endVoice() async {
|
|
if (!_pressed && !_recording) return;
|
|
final cancel = _cancelVoice;
|
|
final start = _voiceDown;
|
|
_pressed = false;
|
|
_voiceDown = null;
|
|
_downGlobal = 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)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(const SnackBar(content: Text('说话时间太短')));
|
|
return;
|
|
}
|
|
try {
|
|
final file = await DeviceBridge.stopVoice();
|
|
final path = '${file?['path'] ?? ''}';
|
|
if (path.isEmpty) {
|
|
if (mounted)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(const SnackBar(content: Text('说话时间太短')));
|
|
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)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('$e')));
|
|
} finally {
|
|
if (mounted) setState(() => _sending = false);
|
|
}
|
|
}
|
|
|
|
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)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('$e')));
|
|
}
|
|
}
|
|
|
|
Future<void> _onBubbleLongPress(Map<String, dynamic> r) async {
|
|
final ctype = '${r['contentType'] ?? 'text'}';
|
|
final meta = _metaOf(r);
|
|
final fileId = '${meta['fileId'] ?? ''}';
|
|
final mine = '${r['fromUserId']}' == widget.session.userId;
|
|
final acts = <(String, String)>[];
|
|
if (ctype == 'text' &&
|
|
r['recalledAt'] == null &&
|
|
'${r['body'] ?? ''}'.isNotEmpty) acts.add(('复制', 'copy'));
|
|
if ((ctype == 'image' || ctype == 'sticker') && fileId.isNotEmpty) {
|
|
acts.add(('查看', 'view'));
|
|
acts.add(('保存到相册', 'save'));
|
|
}
|
|
if (ctype == 'location') acts.add(('查看位置', 'loc'));
|
|
if (ctype == 'voice' && fileId.isNotEmpty) acts.add(('语音转文字', 'voiceText'));
|
|
if (ctype == 'sticker' && fileId.isNotEmpty) {
|
|
acts.add((StickerStore.has(fileId) ? '已在表情' : '添加到表情', 'sticker'));
|
|
}
|
|
acts.add(('转发', 'forward'));
|
|
if (mine && ctype == 'text') {
|
|
final t = DateTime.tryParse('${r['createdAt'] ?? ''}');
|
|
if (r['recalledAt'] != null) {
|
|
acts.add(('重新编辑', 'edit'));
|
|
} else if (t != null && DateTime.now().difference(t).inMinutes < 2) {
|
|
acts.add(('编辑', 'edit'));
|
|
acts.add(('撤回', 'recall'));
|
|
}
|
|
}
|
|
if (acts.isEmpty) return;
|
|
final a = await showWxSheet(context, acts);
|
|
if (a == null || !mounted) return;
|
|
if (a == 'copy') {
|
|
await Clipboard.setData(ClipboardData(text: '${r['body'] ?? ''}'));
|
|
if (mounted) showWxToast(context, '已复制');
|
|
} else if (a == 'view' && fileId.isNotEmpty) {
|
|
_previewImage(fileId);
|
|
} else if (a == 'save' && fileId.isNotEmpty) {
|
|
await _saveChatImage(fileId);
|
|
} else if (a == 'loc') {
|
|
_openLocation(meta, '${r['body'] ?? ''}');
|
|
} else if (a == 'voiceText') {
|
|
await _voiceToText(r, fileId);
|
|
} else if (a == 'sticker' && fileId.isNotEmpty) {
|
|
await StickerStore.add(fileId);
|
|
if (mounted) {
|
|
setState(() {});
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(const SnackBar(content: Text('已添加到表情')));
|
|
}
|
|
} else if (a == 'forward') {
|
|
await _forwardMessage(r);
|
|
} else if (a == 'edit') {
|
|
await _editMessage(r);
|
|
} else if (a == 'recall') {
|
|
await _recallMessage(r);
|
|
}
|
|
}
|
|
|
|
Future<void> _recallMessage(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) showWxToast(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) {
|
|
showWxToast(context, '未识别到文字', error: true);
|
|
return;
|
|
}
|
|
await ChatPrefs.setVoiceText(key, text.trim());
|
|
if (mounted) setState(() {});
|
|
} catch (e) {
|
|
if (mounted) showWxToast(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;
|
|
}
|
|
|
|
void _previewImage(String fileId) {
|
|
final fileIds = <String>[];
|
|
for (final row in _items) {
|
|
final ctype = '${row['contentType'] ?? ''}';
|
|
final meta = _metaOf(row);
|
|
final id = '${meta['fileId'] ?? ''}';
|
|
final name = '${meta['fileName'] ?? row['body'] ?? ''}';
|
|
if (id.isNotEmpty &&
|
|
(ctype == 'image' ||
|
|
ctype == 'sticker' ||
|
|
_looksLikeImage(name, '${meta['mime'] ?? ''}'))) {
|
|
if (!fileIds.contains(id)) fileIds.add(id);
|
|
}
|
|
}
|
|
if (!fileIds.contains(fileId)) fileIds.add(fileId);
|
|
Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => _ImagePreviewPage(
|
|
api: widget.api,
|
|
fileIds: fileIds,
|
|
initialIndex: fileIds.indexOf(fileId),
|
|
),
|
|
));
|
|
}
|
|
|
|
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> _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)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(const SnackBar(content: Text('已保存到相册')));
|
|
} catch (e) {
|
|
if (mounted)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('$e')));
|
|
}
|
|
}
|
|
|
|
Future<void> _forwardMessage(Map<String, dynamic> r) async {
|
|
final picked = await Navigator.of(context)
|
|
.push<List<Map<String, dynamic>>>(MaterialPageRoute(
|
|
builder: (_) => PickPeoplePage(
|
|
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)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('已转发给 ${picked.length} 人')));
|
|
} catch (e) {
|
|
if (mounted)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('$e')));
|
|
}
|
|
}
|
|
|
|
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) showWxToast(context, '$e', error: true);
|
|
}
|
|
}
|
|
|
|
Future<void> _openDetail() async {
|
|
if (_convId.isEmpty) await _load();
|
|
if (!mounted) return;
|
|
final r = await Navigator.of(context).push<String>(MaterialPageRoute(
|
|
builder: (_) => ChatDetailPage(
|
|
session: widget.session,
|
|
api: widget.api,
|
|
conversationId: _convId,
|
|
peerId: widget.peerId,
|
|
peerName: widget.peerName,
|
|
isGroup: widget.isGroup,
|
|
peerAvatarFileId: widget.peerAvatarFileId,
|
|
messages: _items,
|
|
onCall: _call,
|
|
),
|
|
));
|
|
if (!mounted) return;
|
|
setState(_applyPrefs);
|
|
if (r == 'cleared') await _load(jump: true);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final me = widget.session.userId;
|
|
final myAvatar = '${widget.session.user['avatarFileId'] ?? ''}';
|
|
return Scaffold(
|
|
backgroundColor: _bg,
|
|
appBar: AppBar(
|
|
title: Text(widget.peerName),
|
|
automaticallyImplyLeading: !widget.embedded,
|
|
leading: widget.embedded ? null : const BackButton(),
|
|
actions: [
|
|
IconButton(
|
|
onPressed: _openDetail, icon: const Icon(Icons.more_horiz)),
|
|
],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
Column(
|
|
children: [
|
|
Expanded(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onTap: _hideBoard,
|
|
child: ListView.builder(
|
|
controller: _scroll,
|
|
padding: const EdgeInsets.fromLTRB(10, 12, 10, 12),
|
|
itemCount: _items.length,
|
|
itemBuilder: (_, i) {
|
|
final r = _items[i];
|
|
return KeyedSubtree(
|
|
key: ValueKey('${r['id']}_${r['fingerprint']}'),
|
|
child: _bubble(r, i, me, myAvatar),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
_composer(),
|
|
],
|
|
),
|
|
if (_recording)
|
|
Positioned.fill(
|
|
child: IgnorePointer(
|
|
child: ColoredBox(
|
|
color: Colors.black.withValues(alpha: 0.35),
|
|
child: Column(
|
|
children: [
|
|
const Spacer(),
|
|
Icon(_cancelVoice ? Icons.undo : Icons.mic,
|
|
color: _cancelVoice ? kDanger : Colors.white,
|
|
size: 52),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_cancelVoice ? '松开手指,取消发送' : '松开发送,上滑取消',
|
|
style: TextStyle(
|
|
color: _cancelVoice ? kDanger : Colors.white,
|
|
fontSize: 14),
|
|
),
|
|
const SizedBox(height: 120),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _bubble(Map<String, dynamic> r, int i, String me, String myAvatar) {
|
|
final mine = '${r['fromUserId']}' == me;
|
|
final system = widget.peerId == '0' || '${r['fromUserId']}' == '0';
|
|
final kind = '${r['kind'] ?? 'chat'}';
|
|
final ctype = '${r['contentType'] ?? 'text'}';
|
|
var body = '${r['body'] ?? ''}';
|
|
final showTime = i == 0 ||
|
|
shortTime(r['createdAt']) != shortTime(_items[i - 1]['createdAt']);
|
|
if (kind == 'screenshot' || ctype == 'system' && kind == 'screenshot') {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Center(
|
|
child: Text(body,
|
|
style:
|
|
const TextStyle(fontSize: 12, color: Color(0xFFB2B2B2)))),
|
|
);
|
|
}
|
|
if (r['recalledAt'] != null) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20),
|
|
child: Center(
|
|
child: Wrap(
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
Text(mine ? '你撤回了一条消息' : '对方撤回了一条消息',
|
|
style:
|
|
const TextStyle(fontSize: 12, color: Color(0xFFB2B2B2))),
|
|
if (mine)
|
|
TextButton(
|
|
onPressed: () => _editMessage(r),
|
|
style: TextButton.styleFrom(
|
|
minimumSize: Size.zero,
|
|
padding: const EdgeInsets.only(left: 5),
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
child: const Text('重新编辑', style: TextStyle(fontSize: 12)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
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' ||
|
|
(ctype == 'file' &&
|
|
_looksLikeImage('${meta['fileName'] ?? body} ${body}',
|
|
'${meta['mime'] ?? ''}')));
|
|
final voice = ctype == 'voice';
|
|
final loc = ctype == 'location';
|
|
final fileMsg = ctype == 'file' && !image;
|
|
final business = ctype == 'business';
|
|
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: 12, color: Color(0xFFB2B2B2))),
|
|
),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment:
|
|
mine ? MainAxisAlignment.end : MainAxisAlignment.start,
|
|
children: [
|
|
if (!mine) ...[
|
|
SquareAvatar(
|
|
label: system ? '?' : '${r['fromName'] ?? widget.peerName}',
|
|
size: 36,
|
|
color: system ? const Color(0xFF07C160) : null,
|
|
icon: system ? Icons.notifications : null,
|
|
fileId: avatarId.isEmpty ? null : avatarId,
|
|
api: widget.api,
|
|
),
|
|
const SizedBox(width: 8),
|
|
],
|
|
Flexible(
|
|
child: voice
|
|
? VoiceMessageBlock(
|
|
mine: mine,
|
|
seconds: sec,
|
|
playing: _playingId == fileId,
|
|
transcript: ChatPrefs.voiceText(_voiceKey(r, fileId)),
|
|
loading: _voiceTextLoading.contains(_voiceKey(r, fileId)),
|
|
onTap: () => _playVoice(r),
|
|
onLongPress: () => _onBubbleLongPress(r),
|
|
readLabel: mine
|
|
? (((r['readCount'] as num?)?.toInt() ?? 0) > 0
|
|
? '已读'
|
|
: '未读')
|
|
: null,
|
|
)
|
|
: GestureDetector(
|
|
onTap: business
|
|
? () => _openBusinessCard(meta)
|
|
: ((image || sticker) && fileId.isNotEmpty)
|
|
? () => _previewImage(fileId)
|
|
: (loc ? () => _openLocation(meta, body) : null),
|
|
onLongPress: () => _onBubbleLongPress(r),
|
|
child: Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
padding: sticker
|
|
? EdgeInsets.zero
|
|
: (image || loc)
|
|
? const EdgeInsets.all(4)
|
|
: const EdgeInsets.symmetric(
|
|
horizontal: 12, vertical: 9),
|
|
constraints: const BoxConstraints(maxWidth: 280),
|
|
decoration: BoxDecoration(
|
|
color: sticker
|
|
? Colors.transparent
|
|
: (loc
|
|
? Colors.white
|
|
: (mine ? kBubbleMine : Colors.white)),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: mine
|
|
? CrossAxisAlignment.end
|
|
: CrossAxisAlignment.start,
|
|
children: [
|
|
if (business)
|
|
SizedBox(
|
|
width: 232,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(children: [
|
|
Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: kBind.withOpacity(.12),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(Icons.description_outlined,
|
|
color: kBind, size: 22),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text('${meta['title'] ?? body}',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: kInk,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600)),
|
|
),
|
|
]),
|
|
if ('${meta['summary'] ?? ''}'.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Text('${meta['summary']}',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: kMute,
|
|
fontSize: 12,
|
|
height: 1.35)),
|
|
],
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: 8),
|
|
child: Divider(height: 1, color: kLine),
|
|
),
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: 7),
|
|
child: Row(children: [
|
|
Text('工作台信息',
|
|
style: TextStyle(
|
|
color: kMute, fontSize: 11)),
|
|
Spacer(),
|
|
Text('查看详情',
|
|
style: TextStyle(
|
|
color: kBind, fontSize: 11)),
|
|
Icon(Icons.chevron_right,
|
|
color: kBind, size: 15),
|
|
]),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else if (sticker)
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(
|
|
maxWidth: 140, maxHeight: 140),
|
|
child: AuthImage(
|
|
api: widget.api,
|
|
fileId: fileId,
|
|
fit: BoxFit.contain),
|
|
)
|
|
else if (image)
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: AuthImage(
|
|
api: widget.api,
|
|
fileId: fileId,
|
|
width: 180,
|
|
height: 180),
|
|
)
|
|
else if (loc)
|
|
SizedBox(
|
|
width: 220,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
|
|
child: Text(
|
|
meta['title']?.toString().isNotEmpty == true
|
|
? '${meta['title']}'
|
|
: body,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
color: kInk)),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 2, 8, 6),
|
|
child: Text('${meta['address'] ?? body}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 12, color: kMute)),
|
|
),
|
|
ClipRRect(
|
|
borderRadius: const BorderRadius.only(
|
|
bottomLeft: Radius.circular(6),
|
|
bottomRight: Radius.circular(6)),
|
|
child: locLat != null && locLng != null
|
|
? TencentMapThumb(
|
|
api: widget.api,
|
|
lat: locLat,
|
|
lng: locLng,
|
|
width: 220,
|
|
height: 110)
|
|
: const SizedBox(
|
|
height: 80,
|
|
child: Center(
|
|
child: Icon(Icons.place,
|
|
color: kWeGreen))),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else if (fileMsg)
|
|
InkWell(
|
|
onTap: fileId.isEmpty
|
|
? null
|
|
: () => _openChatFile(
|
|
fileId,
|
|
'${meta['fileName'] ?? body}',
|
|
'${meta['mime'] ?? ''}'),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.insert_drive_file_outlined,
|
|
color: kBind, size: 28),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('${meta['fileName'] ?? body}',
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 15, color: kInk)),
|
|
if (meta['size'] != null)
|
|
Text(_sizeText(meta['size']),
|
|
style: const TextStyle(
|
|
fontSize: 11, color: kMute)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else if (ctype == 'call')
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
'${meta['callKind']}' == 'audio'
|
|
? Icons.call
|
|
: Icons.videocam,
|
|
color: kBind,
|
|
size: 22),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(body,
|
|
style: const TextStyle(
|
|
fontSize: 15, color: kInk))),
|
|
],
|
|
)
|
|
else if (r['recalledAt'] != null)
|
|
Text(body,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
color: kMute,
|
|
fontStyle: FontStyle.italic))
|
|
else
|
|
Text(body,
|
|
style: const TextStyle(
|
|
fontSize: 16, color: kInk, height: 1.35)),
|
|
if (mine)
|
|
Text(
|
|
((r['readCount'] as num?)?.toInt() ?? 0) > 0
|
|
? '已读'
|
|
: '未读',
|
|
style: const TextStyle(fontSize: 10, color: kMute),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (mine) ...[
|
|
const SizedBox(width: 8),
|
|
SquareAvatar(
|
|
label: widget.session.displayName,
|
|
size: 36,
|
|
fileId: myAvatar.isEmpty ? null : myAvatar,
|
|
api: widget.api,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _composer() {
|
|
return ColoredBox(
|
|
color: const Color(0xFFF7F7F7),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(6, 6, 6, 6),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: () => setState(() {
|
|
_voice = !_voice;
|
|
_plus = false;
|
|
_emoji = false;
|
|
if (!_voice) {
|
|
_focus.requestFocus();
|
|
} else {
|
|
_focus.unfocus();
|
|
}
|
|
}),
|
|
icon: Icon(_voice
|
|
? Icons.keyboard_alt_outlined
|
|
: Icons.keyboard_voice_outlined),
|
|
),
|
|
Expanded(
|
|
child: _voice
|
|
? Listener(
|
|
behavior: HitTestBehavior.opaque,
|
|
onPointerDown: (e) {
|
|
_pressed = true;
|
|
_downGlobal = e.position;
|
|
_beginVoice();
|
|
},
|
|
onPointerMove: (e) {
|
|
if (!_recording || _downGlobal == null) return;
|
|
final cancel =
|
|
(_downGlobal!.dy - e.position.dy) > 70;
|
|
if (cancel != _cancelVoice)
|
|
setState(() => _cancelVoice = cancel);
|
|
},
|
|
onPointerUp: (_) => _endVoice(),
|
|
onPointerCancel: (_) {
|
|
_cancelVoice = true;
|
|
_endVoice();
|
|
},
|
|
child: Container(
|
|
height: 40,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: _recording
|
|
? const Color(0xFFE5E5E5)
|
|
: Colors.white,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
_recording
|
|
? (_cancelVoice ? '松开取消' : '松开发送')
|
|
: '按住 说话',
|
|
style: const TextStyle(
|
|
color: kMute, fontSize: 16)),
|
|
),
|
|
)
|
|
: TextField(
|
|
controller: _input,
|
|
focusNode: _focus,
|
|
minLines: 1,
|
|
maxLines: 4,
|
|
decoration: const InputDecoration(hintText: ''),
|
|
onTap: () => setState(() {
|
|
_plus = false;
|
|
_emoji = false;
|
|
}),
|
|
onChanged: (_) => setState(() {}),
|
|
onSubmitted: (_) => _send(),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () {
|
|
_focus.unfocus();
|
|
setState(() {
|
|
_emoji = !_emoji;
|
|
_plus = false;
|
|
});
|
|
},
|
|
icon: Icon(_emoji
|
|
? Icons.keyboard_alt_outlined
|
|
: Icons.emoji_emotions_outlined),
|
|
),
|
|
_input.text.trim().isNotEmpty
|
|
? Padding(
|
|
padding: const EdgeInsets.only(right: 6),
|
|
child: FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: kWeGreen,
|
|
minimumSize: const Size(56, 32)),
|
|
onPressed: _sending ? null : _send,
|
|
child: const Text('发送'),
|
|
),
|
|
)
|
|
: IconButton(
|
|
onPressed: () {
|
|
_focus.unfocus();
|
|
setState(() {
|
|
_plus = !_plus;
|
|
_emoji = false;
|
|
});
|
|
},
|
|
icon: const Icon(Icons.add_circle_outline),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_emoji) _emojiPanel(),
|
|
if (_plus) _plusPanel(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _emojiPanel() {
|
|
return SizedBox(
|
|
height: 280,
|
|
width: double.infinity,
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: _emojiTab == 0
|
|
? GridView.count(
|
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
|
|
crossAxisCount: 8,
|
|
children: [
|
|
for (final e in _emojis)
|
|
InkWell(
|
|
onTap: () {
|
|
_input.text += e;
|
|
_input.selection = TextSelection.collapsed(
|
|
offset: _input.text.length);
|
|
setState(() {});
|
|
},
|
|
child: Center(
|
|
child: Text(e,
|
|
style: const TextStyle(fontSize: 24))),
|
|
),
|
|
],
|
|
)
|
|
: GridView.count(
|
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
|
crossAxisCount: 4,
|
|
children: [
|
|
InkWell(
|
|
onTap: _addCustomSticker,
|
|
child: Container(
|
|
margin: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: const Color(0xFFDDDDDD)),
|
|
),
|
|
child: const Icon(Icons.add, color: kMute, size: 32),
|
|
),
|
|
),
|
|
for (final id in StickerStore.ids)
|
|
GestureDetector(
|
|
onTap: () => _sendSticker(id),
|
|
onLongPress: () async {
|
|
final a =
|
|
await showWxSheet(context, [('删除表情', 'del')]);
|
|
if (a == 'del') {
|
|
await StickerStore.remove(id);
|
|
if (mounted) setState(() {});
|
|
}
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: AuthImage(
|
|
api: widget.api,
|
|
fileId: id,
|
|
width: 72,
|
|
height: 72,
|
|
fit: BoxFit.contain),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
ColoredBox(
|
|
color: const Color(0xFFEFEFEF),
|
|
child: 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: 16, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: on ? const Color(0xFFF7F7F7) : Colors.transparent,
|
|
border: on
|
|
? const Border(top: BorderSide(color: Color(0xFFDDDDDD)))
|
|
: null,
|
|
),
|
|
child: Text(label,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: on ? kInk : kMute,
|
|
fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _plusPanel() {
|
|
final items = <(IconData, String, Color, VoidCallback)>[
|
|
(Icons.photo_outlined, '相册', const Color(0xFF07C160), _pickAlbum),
|
|
(Icons.photo_camera_outlined, '拍摄', const Color(0xFF10AEFF), _pickCamera),
|
|
(
|
|
Icons.insert_drive_file_outlined,
|
|
'文件',
|
|
const Color(0xFF576B95),
|
|
_sendFile
|
|
),
|
|
(Icons.place_outlined, '位置', const Color(0xFFFA9D3B), _location),
|
|
(Icons.call_outlined, '语音通话', kBind, () => _call('audio')),
|
|
(
|
|
Icons.videocam_outlined,
|
|
'视频通话',
|
|
const Color(0xFF6267F2),
|
|
() => _call(widget.isGroup ? 'meeting' : 'video')
|
|
),
|
|
];
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
|
color: const Color(0xFFF7F7F7),
|
|
child: Wrap(
|
|
spacing: 22,
|
|
runSpacing: 16,
|
|
children: [
|
|
for (final it in items)
|
|
InkWell(
|
|
onTap: it.$4,
|
|
child: SizedBox(
|
|
width: 64,
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
width: 56,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12)),
|
|
child: Icon(it.$1, color: it.$3),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(it.$2,
|
|
style: const TextStyle(fontSize: 12, color: kMute)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ImagePreviewPage extends StatefulWidget {
|
|
const _ImagePreviewPage(
|
|
{required this.api, required this.fileIds, required this.initialIndex});
|
|
final OaClient api;
|
|
final List<String> fileIds;
|
|
final int initialIndex;
|
|
|
|
@override
|
|
State<_ImagePreviewPage> createState() => _ImagePreviewPageState();
|
|
}
|
|
|
|
class _ImagePreviewPageState extends State<_ImagePreviewPage> {
|
|
late final PageController _pages;
|
|
late int _index;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_index = widget.initialIndex.clamp(0, widget.fileIds.length - 1).toInt();
|
|
_pages = PageController(initialPage: _index);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pages.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.black,
|
|
foregroundColor: Colors.white,
|
|
title: Text(widget.fileIds.length > 1
|
|
? '查看图片 ${_index + 1}/${widget.fileIds.length}'
|
|
: '查看图片'),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.save_alt),
|
|
onPressed: () async {
|
|
try {
|
|
final bytes =
|
|
await widget.api.fileBytes(widget.fileIds[_index]);
|
|
if (bytes == null) throw Exception('图片无法下载');
|
|
final dir = await getTemporaryDirectory();
|
|
final f = File('${dir.path}/img_${widget.fileIds[_index]}.jpg');
|
|
await f.writeAsBytes(bytes, flush: true);
|
|
await DeviceBridge.saveImage(f.path);
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(const SnackBar(content: Text('已保存到相册')));
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted)
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('$e')));
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: PageView.builder(
|
|
controller: _pages,
|
|
itemCount: widget.fileIds.length,
|
|
onPageChanged: (value) => setState(() => _index = value),
|
|
itemBuilder: (_, index) => LayoutBuilder(
|
|
builder: (_, constraints) => InteractiveViewer(
|
|
minScale: 0.8,
|
|
maxScale: 5,
|
|
child: SizedBox(
|
|
width: constraints.maxWidth,
|
|
height: constraints.maxHeight,
|
|
child: AuthImage(
|
|
api: widget.api,
|
|
fileId: widget.fileIds[index],
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LocationViewPage extends StatelessWidget {
|
|
const _LocationViewPage({
|
|
required this.api,
|
|
required this.title,
|
|
required this.address,
|
|
this.lat,
|
|
this.lng,
|
|
});
|
|
final OaClient api;
|
|
final String title;
|
|
final String address;
|
|
final double? lat;
|
|
final double? lng;
|
|
|
|
Future<void> _openMaps(BuildContext context) async {
|
|
if (lat == null || lng == null) return;
|
|
final la = lat!;
|
|
final ln = lng!;
|
|
final q = Uri.encodeComponent(title.isNotEmpty ? title : '$la,$ln');
|
|
final urls = [
|
|
'amapuri://route/plan/?dlat=$la&dlon=$ln&dname=$q&dev=0',
|
|
'qqmap://map/geocoder?coord=$la,$ln',
|
|
'geo:$la,$ln?q=$la,$ln($q)',
|
|
'https://uri.amap.com/marker?position=$ln,$la&name=$q',
|
|
];
|
|
for (final u in urls) {
|
|
try {
|
|
await DeviceBridge.openUrl(u);
|
|
return;
|
|
} catch (_) {}
|
|
}
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(const SnackBar(content: Text('无法打开地图应用')));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.white,
|
|
appBar: AppBar(title: const Text('位置')),
|
|
body: ListView(
|
|
children: [
|
|
if (lat != null && lng != null)
|
|
TencentMapThumb(
|
|
api: api,
|
|
lat: lat!,
|
|
lng: lng!,
|
|
width: MediaQuery.sizeOf(context).width,
|
|
height: 220),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Text(title.isEmpty ? '位置' : title,
|
|
style:
|
|
const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
|
),
|
|
if (address.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
child: Text(address,
|
|
style: const TextStyle(fontSize: 14, color: kMute)),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: FilledButton.icon(
|
|
onPressed: lat == null ? null : () => _openMaps(context),
|
|
icon: const Icon(Icons.map_outlined),
|
|
label: const Text('用地图打开'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|