76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
234 lines
8.2 KiB
Dart
234 lines
8.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../api/oa_client.dart';
|
|
import '../../im/chat_prefs.dart';
|
|
import '../../session/session.dart';
|
|
import '../desk_theme.dart';
|
|
import '../widgets/desk_widgets.dart';
|
|
import 'desk_pick_people_page.dart';
|
|
|
|
class DeskChatDetailPage extends StatefulWidget {
|
|
const DeskChatDetailPage({
|
|
super.key,
|
|
required this.session,
|
|
required this.api,
|
|
required this.conversationId,
|
|
required this.peerId,
|
|
required this.peerName,
|
|
required this.isGroup,
|
|
this.peerAvatarFileId,
|
|
this.onCall,
|
|
});
|
|
|
|
final SessionStore session;
|
|
final OaClient api;
|
|
final String conversationId;
|
|
final String peerId;
|
|
final String peerName;
|
|
final bool isGroup;
|
|
final String? peerAvatarFileId;
|
|
final void Function(String kind)? onCall;
|
|
|
|
@override
|
|
State<DeskChatDetailPage> createState() => _DeskChatDetailPageState();
|
|
}
|
|
|
|
class _DeskChatDetailPageState extends State<DeskChatDetailPage> {
|
|
List<Map<String, dynamic>> _members = [];
|
|
bool _mute = false;
|
|
bool _pin = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_mute = ChatPrefs.muted(widget.conversationId);
|
|
_pin = ChatPrefs.pinned(widget.conversationId);
|
|
_loadMembers();
|
|
}
|
|
|
|
Future<void> _loadMembers() async {
|
|
if (widget.isGroup && widget.conversationId.isNotEmpty) {
|
|
try {
|
|
final data = await widget.api.get('/im/groups/${widget.conversationId}/members');
|
|
if (!mounted) return;
|
|
setState(() => _members = asMaps(data));
|
|
return;
|
|
} catch (_) {}
|
|
}
|
|
setState(() {
|
|
_members = [
|
|
{'id': widget.peerId, 'name': widget.peerName, 'avatarFileId': widget.peerAvatarFileId},
|
|
{'id': widget.session.userId, 'name': widget.session.displayName, 'avatarFileId': widget.session.user['avatarFileId']},
|
|
];
|
|
});
|
|
}
|
|
|
|
Future<void> _addMembers() async {
|
|
final exclude = {widget.session.userId, ..._members.map((e) => '${e['id'] ?? e['userId'] ?? ''}')};
|
|
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
|
MaterialPageRoute(builder: (_) => DeskPickPeoplePage(api: widget.api, title: '选择同事', exclude: exclude)),
|
|
);
|
|
if (picked == null || picked.isEmpty) return;
|
|
try {
|
|
if (widget.isGroup && widget.conversationId.isNotEmpty) {
|
|
await widget.api.post('/im/groups/${widget.conversationId}/members', {
|
|
'memberIds': picked.map((e) => '${e['id']}').toList(),
|
|
});
|
|
await _loadMembers();
|
|
} else {
|
|
await widget.api.post('/im/groups', {
|
|
'name': '${widget.peerName}的群聊',
|
|
'memberIds': [widget.peerId, ...picked.map((e) => '${e['id']}')],
|
|
});
|
|
if (mounted) {
|
|
deskToast(context, '已创建群聊');
|
|
Navigator.pop(context);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) deskToast(context, '$e', error: true);
|
|
}
|
|
}
|
|
|
|
Future<void> _clear() async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('删除聊天记录'),
|
|
content: const Text('将从本机清空当前会话的聊天记录,对方不受影响。'),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true) return;
|
|
await ChatPrefs.clearHistory(widget.conversationId);
|
|
if (mounted) {
|
|
deskToast(context, '已删除本机聊天记录');
|
|
Navigator.pop(context, 'cleared');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ColoredBox(
|
|
color: kDeskBg,
|
|
child: Column(
|
|
children: [
|
|
DeskPaneHeader(
|
|
title: '聊天详情',
|
|
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
|
|
),
|
|
Expanded(
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 480),
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(20),
|
|
children: [
|
|
Material(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Wrap(
|
|
spacing: 16,
|
|
runSpacing: 12,
|
|
children: [
|
|
for (final m in _members)
|
|
SizedBox(
|
|
width: 56,
|
|
child: Column(
|
|
children: [
|
|
DeskAvatar(
|
|
label: '${m['name'] ?? m['displayName'] ?? ''}',
|
|
fileId: '${m['avatarFileId'] ?? ''}',
|
|
api: widget.api,
|
|
size: 44,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text('${m['name'] ?? m['displayName'] ?? ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
|
|
],
|
|
),
|
|
),
|
|
InkWell(
|
|
onTap: _addMembers,
|
|
child: Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), border: Border.all(color: kDeskLine)),
|
|
child: const Icon(Icons.add, color: kDeskMute),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_section([
|
|
_switchRow('消息免打扰', _mute, (v) async {
|
|
await ChatPrefs.setMuted(widget.conversationId, v);
|
|
setState(() => _mute = v);
|
|
}),
|
|
_switchRow('置顶聊天', _pin, (v) async {
|
|
await ChatPrefs.setPinned(widget.conversationId, v);
|
|
setState(() => _pin = v);
|
|
}),
|
|
]),
|
|
const SizedBox(height: 12),
|
|
_section([
|
|
_actionRow('语音通话', () => widget.onCall?.call('voice')),
|
|
_actionRow('视频通话', () => widget.onCall?.call(widget.isGroup ? 'meeting' : 'video')),
|
|
_actionRow('删除聊天记录', _clear, danger: true),
|
|
]),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _section(List<Widget> children) {
|
|
return Material(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(8),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(children: children),
|
|
);
|
|
}
|
|
|
|
Widget _switchRow(String title, bool value, ValueChanged<bool> onChanged) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
child: Row(
|
|
children: [
|
|
Expanded(child: Text(title, style: const TextStyle(fontSize: 14))),
|
|
Switch(value: value, activeTrackColor: kDeskGreen, onChanged: onChanged),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _actionRow(String title, VoidCallback onTap, {bool danger = false}) {
|
|
return Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
child: Row(
|
|
children: [
|
|
Expanded(child: Text(title, style: TextStyle(fontSize: 14, color: danger ? const Color(0xFFFA5151) : kDeskInk))),
|
|
if (!danger) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|