Files
Public/apps/native/lib/pages/chat_page.dart
T
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

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('用地图打开'),
),
),
],
),
);
}
}