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 createState() => _ChatPageState(); } class _ChatPageState extends State { final _input = TextEditingController(); final _scroll = ScrollController(); final _focus = FocusNode(); List> _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? _voiceStart; bool _firstLoad = true; Timer? _receiptPoll; Timer? _playTimer; Timer? _imDebounce; String? _playingId; final Set _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 _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 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 _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]; _applyPrefs(); }); await ChatLocalStore.save(_convId, merged); if (_convId.isNotEmpty) await ImSeqStore.applyItems(_convId, merged); final shouldJump = jump || _firstLoad; _firstLoad = false; if (shouldJump) { await Future.delayed(const Duration(milliseconds: 50)); _jumpBottom(); } } catch (_) {} } void _jumpBottom() { if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent); } Future _post(Map body) async { await _optimisticPost(body); } Future _optimisticPost(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(), '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 _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 _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 _ensureConv() async { if (_convId.isEmpty) await _load(); } Future> _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 _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 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 _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 _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 _addCustomSticker() async { try { final picked = await DeviceBridge.pickMedia(); if (picked == null || (picked['path'] ?? '').isEmpty) return; setState(() => _sending = true); await _ensureConv(); Map 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 _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 _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(), }); if (mounted) setState(() => _plus = false); } Future _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 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 _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 _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.from(data), peerName: widget.peerName, ); } } catch (e) { if (mounted) showWxToast(context, '$e', error: true); } if (mounted) setState(() => _plus = false); } Future _openBusinessCard(Map 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.from(raw) : {}; 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 _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 _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 _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) ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text('$e'))); } } Future _onBubbleLongPress(Map 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 _recallMessage(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) showWxToast(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) { 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 row, String fileId) { final id = '${row['id'] ?? ''}'; return id.isNotEmpty ? id : fileId; } void _previewImage(String fileId) { final fileIds = []; 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 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 _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 _forwardMessage(Map r) async { final picked = await Navigator.of(context) .push>>(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 _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) showWxToast(context, '$e', error: true); } } Future _openDetail() async { if (_convId.isEmpty) await _load(); if (!mounted) return; final r = await Navigator.of(context).push(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 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 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 _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('็”จๅœฐๅ›พๆ‰“ๅผ€'), ), ), ], ), ); } }