Files
toolbox/client/lib/modules/dual_check/dual_check.dart
T
2026-08-30 21:25:22 +08:00

1467 lines
53 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DualCheck 主页面(多参与者版)
// 一个发起方(initiator)提交标准答案,多个参与者(participant)各自提交文本
// 通过 WebSocket 实时同步参与者列表 + 提交状态 + match 结果
import 'dart:async';
import 'dart:convert';
import 'dart:html' if (dart.library.io) 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'api.dart';
import 'nickname_service.dart';
enum _RoleSelect { initiator, participant }
class DualCheck extends StatefulWidget {
const DualCheck({super.key});
@override
State<DualCheck> createState() => _DualCheckState();
}
class _DualCheckState extends State<DualCheck> with WidgetsBindingObserver {
final _api = DualCheckApi.instance;
final _nickService = NicknameService.instance;
final _textCtrl = TextEditingController();
// ── 会话状态 ──
String? _sessionId;
late String _nickname = '用户'; // 本地显示昵称(由 NicknameService 驱动)
String _serverNick = ''; // 服务端已知的昵称(与 server 同步)
late _RoleSelect _myRole = _RoleSelect.initiator;
SessionResp? _session; // 当前会话全量状态(发起方视图)
// ── WS ──
WebSocketChannel? _ws;
StreamSubscription? _wsSub;
bool _wsStreamDone = false; // WS 底层 stream 是否已关闭
// ── 浏览器/APP 生命周期 ──
bool _pageHidden = false; // 页面是否被隐藏过(paused)——用于识别浏览器手势返回等场景
// ── rename 失败回滚 ──
String? _renamePendingOldNick; // 发送 rename 前暂存当前昵称,若后端报错则回滚
String? _renamePendingNewNick; // 发送 rename 前暂存目标昵称,用于去重
// ── UI 状态 ──
bool _isLoading = false;
String? _errorMsg;
String? _mySubmittedText; // 自己提交过的文本(发起方提交后展示用)
bool _initTextExpanded = false; // 发起方已提交文本是否展开
bool _sessionEndedShown = false; // 防重复弹 session_ended 弹窗
bool _mySubmitted = false; // 参与者:自己是否已提交
bool? _myMatch; // 参与者:自己的 match 结果(null=发起方还没提交)
StreamSubscription<String>? _nickSub;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_setupBrowserLifecycle();
_loadNickname();
// 开启跨标签页同步(仅 Web 生效)
_nickService.enableCrossTabSync();
// 监听昵称变化(本地修改 + 其他标签页修改都会触发)
_nickSub = _nickService.onChanged.listen((n) {
if (!mounted) return;
final oldNick = _nickname;
setState(() => _nickname = n);
// 如果正在会话中且是参与者,通知后端改名
if (_sessionId != null && _myRole == _RoleSelect.participant && oldNick != n) {
debugPrint('[DC] onChanged: old=$oldNick new=$n sessionId=$_sessionId ws=$_ws');
_sendRename(n);
} else {
debugPrint('[DC] onChanged skip: sessionId=$_sessionId role=$_myRole old=$oldNick new=$n');
}
});
}
Future<void> _loadNickname() async {
final n = await _nickService.nickname;
if (!mounted) return;
setState(() => _nickname = n);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_teardownBrowserLifecycle();
_nickSub?.cancel();
_wsSub?.cancel();
_ws?.sink.close();
_textCtrl.dispose();
super.dispose();
}
// ── dart:html 浏览器事件监听(处理 Safari bfcache 手势返回恢复) ──
// visibilitychange:切 tab 触发(切走 hidden,切回 visible)
// pageshow:页面显示时触发(包括 bfcache 恢复!Safari 手势拉回来必触发)
// pagehide:页面隐藏时触发(包括 bfcache 冻结!Safari 手势后退必触发)
void _setupBrowserLifecycle() {
try {
// ignore: undefined_function —— dart:html 仅在 Web 编译时可用
document.onVisibilityChange.listen((_) {
final hidden = document.hidden ?? false;
debugPrint('[DC] html visibilitychange hidden=$hidden');
if (hidden) {
_pageHidden = true;
} else {
_checkAndExit();
}
});
// ignore: undefined_function
window.onPageShow.listen((e) {
// persisted=true 表示从 bfcache 恢复(Safari 手势拉回来的场景)
final persisted = (e as dynamic).persisted as bool? ?? false;
debugPrint('[DC] html pageshow persisted=$persisted pageHidden=$_pageHidden wsDone=$_wsStreamDone');
if (persisted) {
_pageHidden = true; // bfcache 恢复,标记为隐藏过
}
_checkAndExit();
});
// ignore: undefined_function
window.onPageHide.listen((e) {
final persisted = (e as dynamic).persisted as bool? ?? false;
debugPrint('[DC] html pagehide persisted=$persisted');
if (persisted) {
_pageHidden = true;
}
});
debugPrint('[DC] browser lifecycle listeners installed');
} catch (_) {
// 非 Web 平台(iOS/Android native)忽略
debugPrint('[DC] dart:html not available, skip browser lifecycle setup');
}
}
void _teardownBrowserLifecycle() {
try {
// ignore: undefined_function
document.onVisibilityChange.drain();
// ignore: undefined_function
window.onPageShow.drain();
// ignore: undefined_function
window.onPageHide.drain();
} catch (_) {}
}
// 统一的退出检查:只要"页面被浏览器隐藏过" + "WS 已断" → 强制退出
void _checkAndExit() {
if (_pageHidden && _wsStreamDone && _sessionId != null && mounted) {
debugPrint('[DC] _checkAndExit → force exit (pageHidden+wsDone)');
_reset();
Navigator.of(context).pop();
} else if (_pageHidden && _wsStreamDone) {
debugPrint('[DC] _checkAndExit: conditions met but not mounted or no session');
} else {
debugPrint('[DC] _checkAndExit: no exit. pageHidden=$_pageHidden wsDone=$_wsStreamDone session=$_sessionId');
}
}
// ── WidgetsBindingObserver 生命周期:识别浏览器手势返回后又恢复的场景 ──
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
_pageHidden = true;
debugPrint('[DC] lifecycle: page hidden (paused/inactive)');
} else if (state == AppLifecycleState.resumed) {
debugPrint('[DC] lifecycle: page resumed, pageHidden=$_pageHidden wsDone=$_wsStreamDone session=$_sessionId');
if (_pageHidden) {
_pageHidden = false;
// 关键:页面被浏览器隐藏过(手势返回/切标签),恢复后 WS 已断 → 强制退出
if (_wsStreamDone && _sessionId != null) {
debugPrint('[DC] page was hidden + WS closed → force exit to home');
_reset();
if (mounted) {
Navigator.of(context).pop();
}
}
}
}
}
// ── 发起方:创建会话 ──
Future<void> _createSession() async {
setState(() => _isLoading = true);
try {
final bid = await _nickService.browserId;
final resp = await _api.createSession(nickname: _nickname, browserId: bid);
setState(() {
_sessionId = resp.sessionId;
_myRole = _RoleSelect.initiator;
});
// WS 连接(发起方不需要 join,直接连接)
_connectWs();
} on DioException catch (e) {
setState(() => _errorMsg = _friendlyError(e, '创建失败'));
} catch (e) {
setState(() => _errorMsg = _friendlyError(e, '创建失败'));
} finally {
setState(() => _isLoading = false);
}
}
// ── 参与方:输入会话码加入(对话框内校验) ──
void _showJoinDialog() {
final ctrl = TextEditingController();
bool isChecking = false;
String? err;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
title: const Text('输入会话码加入'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: ctrl,
autofocus: true,
enabled: !isChecking,
decoration: const InputDecoration(
hintText: '粘贴或输入会话码',
border: OutlineInputBorder(),
),
),
if (err != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(err!,
style: const TextStyle(color: Colors.red, fontSize: 13)),
),
],
),
actions: [
TextButton(
onPressed: isChecking ? null : () => Navigator.pop(ctx),
child: const Text('取消'),
),
FilledButton(
onPressed: isChecking
? null
: () async {
String sid = ctrl.text.trim();
if (sid.isEmpty) {
set(() => err = '请输入会话码');
return;
}
// 如果粘贴的是 URL,尝试提取 session_id
final extracted = _extractSessionId(sid);
if (extracted != null) {
sid = extracted;
ctrl.text = sid;
}
set(() {
isChecking = true;
err = null;
});
try {
await _api.checkSession(sid);
if (!ctx.mounted) return;
Navigator.pop(ctx);
setState(() {
_sessionId = sid;
_myRole = _RoleSelect.participant;
});
_connectWs();
} on DioException catch (e) {
set(() {
isChecking = false;
err = isBizError(e)
? bizErrorMessage(e)
: '无法连接服务器';
});
} catch (e) {
set(() {
isChecking = false;
err = '校验失败:$e';
});
}
},
child: isChecking
? const SizedBox(width: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('加入'),
),
],
),
),
);
}
// ── WS 连接 ──
Future<void> _connectWs() async {
final role = _myRole == _RoleSelect.initiator ? 'initiator' : 'participant';
// 连接时用当前本地昵称;如果参与方被自动改名,server 会在 full_state 或 nickname_auto_changed 里通知
_serverNick = _nickname;
final bid = await _nickService.browserId;
final url = _api.wsUrl(
sessionId: _sessionId!,
role: role,
nickname: _nickname,
browserId: bid,
);
try {
_ws = WebSocketChannel.connect(Uri.parse(url));
_wsStreamDone = false;
_wsSub = _ws!.stream.listen(_onWsMessage, onError: (e) {
debugPrint('[DC] ws error: $e');
}, onDone: () {
debugPrint('[DC] ws stream done (closed)');
_wsStreamDone = true;
// 兜底:如果页面之前已被隐藏过(Safari 手势后退 bfcache 场景),
// WS onDone 可能在 resumed 之后才触发(时序问题),这里立即退出
if (_pageHidden && _sessionId != null && mounted) {
debugPrint('[DC] ws done after page hidden → force exit');
_reset();
Navigator.of(context).pop();
}
});
} catch (e) {
debugPrint('[DC] ws connect fail: $e');
}
}
// ── WS 消息处理(事件驱动 UI) ──
Future<void> _onWsMessage(dynamic raw) async {
try {
final msg = WSMessage.fromJson(raw as String);
switch (msg.type) {
case 'full_state':
final s = DualCheckApi.parseSession(msg.data);
// 在参与者列表中找到自己,提取 match 结果
bool? myMatch;
for (final p in s.participants) {
if (p.nickname == _nickname && p.match != null) {
myMatch = p.match;
break;
}
}
setState(() {
_session = s;
if (myMatch != null) _myMatch = myMatch;
});
// 如果之前已提交但发起方后来才提交 → 弹结果
if (_mySubmitted && myMatch != null) {
_showMyResult(myMatch);
}
case 'participant_joined':
final nick = msg.data['nickname'] as String;
setState(() {
_session ??= SessionResp(
sessionId: _sessionId!,
initiatorNickname: '',
initiatorReady: false,
allowResubmit: false,
ignoreWs: false,
participants: [],
ttlSeconds: 0);
final list = List<ParticipantResp>.from(_session!.participants);
if (!list.any((p) => p.nickname == nick)) {
list.add(ParticipantResp(
nickname: nick, joined: true, submitted: false));
}
_session = _session!.copyWith(participants: list);
});
case 'participant_left':
final nick = msg.data['nickname'] as String;
setState(() {
if (_session == null) return;
final list = List<ParticipantResp>.from(_session!.participants);
list.removeWhere((p) => p.nickname == nick);
_session = _session!.copyWith(participants: list);
});
case 'participant_submitted':
final nick = msg.data['nickname'] as String;
final submitData = msg.data['submit'] as Map<String, dynamic>;
setState(() {
if (_session == null) return;
final list = List<ParticipantResp>.from(_session!.participants);
final idx = list.indexWhere((p) => p.nickname == nick);
if (idx >= 0) {
list[idx] = list[idx].copyWith(
submitted: true,
match: submitData['match'] as bool?,
);
}
_session = _session!.copyWith(participants: list);
});
case 'initiator_ready':
setState(() {
if (_session != null) {
_session = _session!.copyWith(initiatorReady: true);
}
});
case 'resubmit_changed':
final allow = msg.data['allow'] as bool;
setState(() {
_session ??= SessionResp(
sessionId: _sessionId!,
initiatorNickname: '',
initiatorReady: false,
allowResubmit: false,
ignoreWs: false,
participants: [],
ttlSeconds: 0);
_session = _session!.copyWith(allowResubmit: allow);
if (allow) _errorMsg = null;
});
case 'ignore_ws_changed':
final ignore = msg.data['ignore'] as bool;
setState(() {
_session ??= SessionResp(
sessionId: _sessionId!,
initiatorNickname: '',
initiatorReady: false,
allowResubmit: false,
ignoreWs: false,
participants: [],
ttlSeconds: 0);
_session = _session!.copyWith(ignoreWs: ignore);
});
case 'session_ended':
// 只有参与者收到才弹窗——发起方自己就是结束者,不需要再提示自己
if (_myRole == _RoleSelect.participant) _showSessionEnded();
case 'participant_nickname_changed':
final oldNick = msg.data['old_nickname'] as String;
final newNick = msg.data['new_nickname'] as String;
setState(() {
if (_session == null) return;
final list = List<ParticipantResp>.from(_session!.participants);
final idx = list.indexWhere((p) => p.nickname == oldNick);
if (idx >= 0) {
list[idx] = list[idx].copyWith(nickname: newNick);
_session = _session!.copyWith(participants: list);
}
});
// 如果改名的是自己,更新 _serverNick 并清 pending
if (_myRole == _RoleSelect.participant &&
(_renamePendingNewNick == newNick || _serverNick == oldNick)) {
_serverNick = newNick;
_renamePendingOldNick = null;
_renamePendingNewNick = null;
}
case 'nickname_auto_changed':
// 服务端因昵称冲突自动改名,只发给改名的那个客户端
final newNick = msg.data['nickname'] as String;
final oldNick = _nickname;
debugPrint('[DC] nickname auto changed: $oldNick → $newNick');
setState(() {
_nickname = newNick;
_serverNick = newNick; // 服务端已经用这个名字了
_nickService.setNickname(newNick); // 持久化
// 如果 _session 已初始化,更新自己在 participants 里的记录
if (_session != null) {
final list = List<ParticipantResp>.from(_session!.participants);
final idx = list.indexWhere((p) => p.nickname == oldNick);
if (idx >= 0) {
list[idx] = list[idx].copyWith(nickname: newNick);
_session = _session!.copyWith(participants: list);
}
}
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
_snack('🔔 昵称冲突,已自动改为 $newNick'),
);
}
case 'error':
final errMsg = msg.error;
if (errMsg.isNotEmpty) {
debugPrint('[DC] ws error from server: $errMsg');
// 如果是 rename 请求期间收到的错误,回滚本地昵称
bool needRollback = _renamePendingOldNick != null;
final rollbackTo = _renamePendingOldNick;
final pendingNew = _renamePendingNewNick;
_renamePendingOldNick = null;
_renamePendingNewNick = null;
if (needRollback && rollbackTo != null) {
debugPrint('[DC] rename failed, rolling back to: $rollbackTo');
setState(() {
_nickname = rollbackTo;
_serverNick = rollbackTo; // 恢复 serverNick
_nickService.setNickname(rollbackTo); // 不 await,避免阻塞 SnackBar
// 如果 _session 的 participants 里有 pending 的新昵称,恢复回来
if (_session != null && pendingNew != null) {
final list = List<ParticipantResp>.from(_session!.participants);
final idx = list.indexWhere((p) => p.nickname == pendingNew);
if (idx >= 0) {
list[idx] = list[idx].copyWith(nickname: rollbackTo);
_session = _session!.copyWith(participants: list);
}
}
});
}
if (_sessionId != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
_snack(errMsg, icon: Icons.error_outline, color: Colors.red),
);
}
}
}
} catch (e) {
debugPrint('[DC] ws parse error: $e');
}
}
// ── 提交文本 ──
Future<void> _submit() async {
if (_sessionId == null) return;
final text = _textCtrl.text;
if (text.isEmpty) {
setState(() => _errorMsg = '请输入内容后再提交');
return;
}
setState(() {
_isLoading = true;
_errorMsg = null; // 清除上次残留的报错
});
try {
final role = _myRole == _RoleSelect.initiator ? 'initiator' : 'participant';
final resp = await _api.submit(
sessionId: _sessionId!,
nickname: _nickname,
role: role,
text: text,
);
// 只清发起方的输入框(发起方提交后有只读预览卡片展示文本,参与者要保留原输入方便重提)
if (_myRole == _RoleSelect.initiator) _textCtrl.clear();
// 参与者提交后立刻在本地更新会话状态
if (_myRole == _RoleSelect.participant) {
setState(() {
_session ??= SessionResp(
sessionId: _sessionId!,
initiatorNickname: '',
initiatorReady: false,
allowResubmit: false,
ignoreWs: false,
participants: [],
ttlSeconds: 0);
_session = _session!.copyWith(
initiatorReady: resp.initiatorReady,
);
_mySubmitted = true;
_myMatch = resp.match; // null 表示发起方还没提交
});
// 如果 initiator 已提交,可以直接显示 match 弹窗
if (resp.initiatorReady && resp.match != null) {
_showMyResult(resp.match!);
}
} else {
// 发起方提交后等 WS full_state 来刷新;同时保存自己的文本用于只读展示
setState(() => _mySubmittedText = text);
}
} on DioException catch (e) {
final code = bizErrorCode(e);
String msg;
if (code == 1003) {
msg = '已提交过,等待发起方开启"允许重新提交"后再试';
} else {
msg = isBizError(e) ? bizErrorMessage(e) : '提交失败';
}
setState(() => _errorMsg = msg);
} catch (e) {
setState(() => _errorMsg = '提交失败:$e');
} finally {
setState(() => _isLoading = false);
}
}
// 从粘贴的 URL/文本中提取会话码
// 支持的格式:
// http://host/?session_id=M_xxx
// http://host/api/v1/dual-check/session/M_xxx
// /api/v1/dual-check/session/M_xxx
// session_id=M_xxx
// M_xxx → 原样返回 null(不是 URL)
String? _extractSessionId(String input) {
// 1. 先试 query param
try {
final uri = Uri.tryParse(input);
if (uri != null) {
if (uri.queryParameters['session_id'] case final sid?) return sid;
// 2. 再试 path 里的 session/XXX 或 check/XXX 段
final segs = uri.pathSegments;
for (int i = 0; i < segs.length - 1; i++) {
if (segs[i] == 'session' || segs[i] == 'check') return segs[i + 1];
}
}
} catch (_) {}
// 3. 纯文本匹配 session_id=XXX
final m = RegExp(r'session[_\-]?id=([^\s&]+)', caseSensitive: false).firstMatch(input);
if (m != null) return m.group(1);
return null;
}
// 通过 WS 发送 rename 消息(仅参与者用)
void _sendRename(String newNick) {
if (_ws == null) {
debugPrint('[DC] _sendRename SKIP: ws is null');
return;
}
// 重要:必须和 _serverNick 比较,不能和 _nickname 比。
// 因为 _nickService.setNickname() 会同步触发 onChanged 把 _nickname 改成 newNick,
// 这时再和 _nickname 比永远相等,WS 消息就发不出去了。
if (newNick == _serverNick) {
debugPrint('[DC] _sendRename SKIP: same as server-known nick ($_serverNick)');
return;
}
// 暂存旧昵称,用于后端报错时回滚
_renamePendingOldNick = _serverNick; // 用 serverNick 而不是 _nickname
_renamePendingNewNick = newNick;
try {
final msg = jsonEncode({
'type': 'rename',
'data': {'new_nickname': newNick},
});
debugPrint('[DC] _sendRename → $msg');
_ws!.sink.add(msg);
} catch (e) {
debugPrint('[DC] _sendRename FAIL: $e');
_renamePendingOldNick = null;
_renamePendingNewNick = null;
}
}
// ── 重置 ──
void _reset() {
_wsSub?.cancel();
_ws?.sink.close();
_textCtrl.clear();
_renamePendingOldNick = null;
_renamePendingNewNick = null;
_serverNick = '';
setState(() {
_ws = null;
_wsSub = null;
_sessionId = null;
_session = null;
_errorMsg = null;
_mySubmittedText = null;
_initTextExpanded = false;
_sessionEndedShown = false;
_mySubmitted = false;
_myMatch = null;
});
}
// ── 发起方:主动结束会话(广播给所有人后退出) ──
Future<void> _confirmAndEndSession() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
icon: const Icon(Icons.warning_amber_rounded, size: 40, color: Colors.orange),
title: const Text('确定结束会话?'),
content: const Text('结束后所有参与者都会收到通知并自动退出,无法恢复。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: const Text('确定结束'),
),
],
),
);
if (confirmed == true && mounted) {
await _endSession();
}
}
Future<void> _endSession() async {
final sid = _sessionId;
if (sid == null) return;
try {
await _api.endSession(sid);
} catch (e) {
// 即使网络失败,后端也可能已成功,直接进入 reset
debugPrint('[DC] endSession api error (ignoring): $e');
}
if (!mounted) return;
_reset();
}
// ── 收到 session_ended WS 事件:弹窗 + 自动退出 ──
void _showSessionEnded() async {
// 防重复:收到多个 session_ended / 重复触发都只弹一次
if (!mounted || _sessionId == null || _sessionEndedShown) return;
_sessionEndedShown = true;
final sid = _sessionId!;
await showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
icon: const Icon(Icons.info_outline, size: 50, color: Colors.blue),
title: const Text('会话已结束'),
content: Text('会话 $sid 已由发起方主动结束,即将返回首页。'),
actions: [
FilledButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('好的'),
),
],
),
);
if (!mounted) return;
_reset();
}
// ── 发起方:切换允许重提交开关 ──
Future<void> _toggleResubmit(bool value) async {
final sid = _sessionId;
if (sid == null) return;
setState(() {
_session = _session?.copyWith(allowResubmit: value);
});
try {
final sess = await _api.setResubmit(sid, allow: value);
if (!mounted) return;
setState(() => _session = sess);
} on DioException catch (e) {
// 失败回滚
if (!mounted) return;
setState(() {
_session = _session?.copyWith(allowResubmit: !value);
});
ScaffoldMessenger.of(context).showSnackBar(
_snack('操作失败:${bizErrorMessage(e)}', icon: Icons.error_outline, color: Colors.red),
);
}
}
Future<void> _toggleIgnoreWs(bool value) async {
final sid = _sessionId;
if (sid == null) return;
setState(() {
_session = _session?.copyWith(ignoreWs: value);
});
try {
final sess = await _api.setIgnoreWs(sid, ignore: value);
if (!mounted) return;
setState(() => _session = sess);
} on DioException catch (e) {
if (!mounted) return;
setState(() {
_session = _session?.copyWith(ignoreWs: !value);
});
ScaffoldMessenger.of(context).showSnackBar(
_snack('操作失败:${bizErrorMessage(e)}', icon: Icons.error_outline, color: Colors.red),
);
}
}
// ── 设置昵称对话框 ──
void _showNicknameDialog() {
final ctrl = TextEditingController(text: _nickname);
final capturedOldNick = _nickname; // 捕获旧昵称(setNickname 是异步的,onChanged 也会 set,这里做个备份)
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('设置昵称'),
content: TextField(
controller: ctrl,
autofocus: true,
maxLength: 32,
decoration: const InputDecoration(
hintText: '1-32 字符',
border: OutlineInputBorder(),
counterText: '',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
FilledButton(
onPressed: () async {
final newNick = ctrl.text.trim();
final ok = await _nickService.setNickname(newNick);
if (ok && ctx.mounted) Navigator.pop(ctx);
setState(() {
if (ok) _nickname = newNick;
});
if (ctx.mounted && !ok) {
ScaffoldMessenger.of(ctx).showSnackBar(
_snack('昵称无效', icon: Icons.error_outline, color: Colors.red),
);
}
// 双保险:保存成功后直接通知后端(不管 onChanged 有没有触发)
if (ok && capturedOldNick != newNick &&
_sessionId != null && _myRole == _RoleSelect.participant) {
debugPrint('[DC] dialog save: old=$capturedOldNick new=$newNick → _sendRename');
_sendRename(newNick);
}
},
child: const Text('保存'),
),
],
),
);
}
// ── 参与者结果弹窗 ──
void _showMyResult(bool match) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
icon: Icon(match ? Icons.check_circle : Icons.cancel,
size: 60, color: match ? Colors.green : Colors.red),
title: Text(match ? '🎉 完全一致' : '❌ 不相同',
style: TextStyle(color: match ? Colors.green : Colors.red)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('好的')),
],
),
);
}
// ── UI ──
@override
Widget build(BuildContext context) {
// 会话中:PopScope 拦截所有返回方式(AppBar back、系统返回键、Android 物理返回)
final Widget body;
if (_sessionId == null) {
body = _buildRoleSelect();
} else {
body = PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return; // PopScope 允许了 pop
await _handleWillPop();
},
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: _buildSession(),
),
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('DualCheck 双验'),
centerTitle: true,
actions: [
// 发起者进入会话后,不允许修改发起者昵称,隐藏按钮
if (_sessionId == null || _myRole != _RoleSelect.initiator)
IconButton(
tooltip: '设置昵称',
icon: const Icon(Icons.person),
onPressed: _showNicknameDialog,
),
],
),
body: body,
);
}
// ── PopScope:拦截返回 ──
Future<void> _handleWillPop() async {
if (_sessionId == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
icon: Icon(
_myRole == _RoleSelect.initiator
? Icons.warning_amber_rounded
: Icons.exit_to_app,
size: 40,
color: _myRole == _RoleSelect.initiator ? Colors.orange : Colors.blue,
),
title: Text(_myRole == _RoleSelect.initiator ? '确定退出会话?' : '确定离开会话?'),
content: Text(_myRole == _RoleSelect.initiator
? '退出将结束会话,所有参与者都会收到通知并自动下线,无法恢复。'
: '离开后将断开连接,不再接收会话消息。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: _myRole == _RoleSelect.initiator ? Colors.red : null,
),
child: Text(_myRole == _RoleSelect.initiator ? '结束并退出' : '离开'),
),
],
),
);
if (confirmed == true && mounted) {
if (_myRole == _RoleSelect.initiator) {
await _endSession(); // 广播 session_ended + 删 Redis → 参与者自动下线
} else {
_reset();
}
}
}
// ── 首页:选择角色 ──
Widget _buildRoleSelect() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
const Text('双验',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('多人各输入一段文字,看谁和发起方一致',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey)),
const SizedBox(height: 24),
const Spacer(),
ElevatedButton.icon(
onPressed: _isLoading ? null : _createSession,
icon: const Icon(Icons.add_circle_outline),
label: Text(_isLoading ? '创建中...' : '我是发起方'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16)),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _isLoading ? null : _showJoinDialog,
icon: const Icon(Icons.login),
label: const Text('我是参与方'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16)),
),
if (_errorMsg != null) _buildError(_errorMsg!),
const SizedBox(height: 24),
],
);
}
// ── 会话页(发起方和参与方共用) ──
Widget _buildSession() {
if (_myRole == _RoleSelect.initiator) {
return _buildInitiatorView();
} else {
return _buildParticipantView();
}
}
// 发起方视图
Widget _buildInitiatorView() {
final initReady = _session?.initiatorReady ?? false;
final participants = _session?.participants ?? [];
final initNick = _session?.initiatorNickname ?? _nickname;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── 上半部分:可滚 ──
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSessionBar(),
const SizedBox(height: 16),
if (!initReady) ...[
TextField(
controller: _textCtrl,
maxLines: 4,
maxLength: 10000,
decoration: const InputDecoration(
hintText: '请输入你的答案(标准答案)...',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _isLoading ? null : _submit,
child: _isLoading
? const CircularProgressIndicator(strokeWidth: 2, color: Colors.white)
: const Text('提交标准答案'),
),
if (_errorMsg != null) _buildError(_errorMsg!),
] else ...[
Container(
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 6),
child: Row(
children: [
Icon(Icons.check_circle, color: Colors.green.shade700, size: 18),
const SizedBox(width: 6),
Expanded(
child: Text('标准答案已提交($initNick)',
style: TextStyle(
color: Colors.green.shade700,
fontWeight: FontWeight.w600)),
),
if (_mySubmittedText != null && _mySubmittedText!.length > 80)
TextButton.icon(
onPressed: () =>
setState(() => _initTextExpanded = !_initTextExpanded),
icon: Icon(
_initTextExpanded
? Icons.expand_less
: Icons.expand_more,
size: 18,
),
label: Text(_initTextExpanded ? '收起' : '展开'),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: Colors.green.shade700),
),
if (_mySubmittedText != null && _mySubmittedText!.isNotEmpty)
IconButton(
tooltip: '复制内容',
icon: Icon(Icons.copy, size: 18, color: Colors.green.shade700),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _copy(_mySubmittedText!),
),
],
),
),
if (_mySubmittedText != null)
Container(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Text(
_mySubmittedText!,
maxLines: _initTextExpanded ? null : 6,
style: TextStyle(
color: Colors.green.shade900, height: 1.5),
),
),
],
),
),
],
const SizedBox(height: 24),
Row(
children: [
const Text('参与者',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const Spacer(),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text('${participants.length} 人',
style: TextStyle(
color: Colors.blue.shade800,
fontSize: 12,
fontWeight: FontWeight.bold)),
),
],
),
const SizedBox(height: 8),
if (participants.isEmpty)
Container(
height: 160,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.group_outlined,
size: 48, color: Colors.grey.shade300),
const SizedBox(height: 8),
const Text('暂无参与者,把会话码分享给他们吧',
style: TextStyle(color: Colors.grey)),
],
),
)
else
SizedBox(
height: 200,
child: ListView.separated(
itemCount: participants.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (_, i) =>
_buildParticipantCard(participants[i]),
),
),
const SizedBox(height: 16),
],
),
),
),
// ── 底部控制区:永远贴底 ──
SwitchListTile.adaptive(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: const Text('允许参与者重新提交'),
value: _session?.allowResubmit ?? false,
onChanged: _toggleResubmit,
),
SwitchListTile.adaptive(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: const Text('比对时忽略首尾空白'),
value: _session?.ignoreWs ?? false,
onChanged: _toggleIgnoreWs,
),
const SizedBox(height: 8),
ElevatedButton(onPressed: _confirmAndEndSession, child: const Text('结束会话')),
],
);
}
Widget _buildParticipantCard(ParticipantResp p) {
IconData icon;
Color color;
String status;
if (!p.submitted) {
icon = Icons.hourglass_empty;
color = Colors.orange;
status = '等待提交';
} else if (p.match == null) {
icon = Icons.check_circle;
color = Colors.blue;
status = '已提交';
} else if (p.match == true) {
icon = Icons.check_circle;
color = Colors.green;
status = '正确 ✓';
} else {
icon = Icons.cancel;
color = Colors.red;
status = '错误 ✗';
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade200),
),
child: Row(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: 10),
Expanded(
child: Text(
p.nickname,
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(status,
style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600)),
),
],
),
);
}
// 参与者视图
Widget _buildParticipantView() {
final initReady = _session?.initiatorReady ?? false;
final initNick = _session?.initiatorNickname ?? '(发起方)';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSessionBar(),
const SizedBox(height: 16),
// 发起方状态
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: initReady ? Colors.green.shade50 : Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
initReady
? Icons.check_circle
: Icons.hourglass_empty,
color: initReady ? Colors.green : Colors.orange),
const SizedBox(width: 8),
Expanded(
child: Text(
initReady
? '发起方 $initNick 已提交标准答案'
: '发起方 $initNick 还没提交,先提交你自己的也可以',
style: TextStyle(
color:
initReady ? Colors.green.shade700 : Colors.orange.shade700),
),
),
],
),
),
const SizedBox(height: 16),
// 自己的状态条(提交后显示)
if (_mySubmitted)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _myMatch == null
? Colors.blue.shade50
: (_myMatch! ? Colors.green.shade50 : Colors.red.shade50),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
_myMatch == null
? Icons.hourglass_empty
: (_myMatch! ? Icons.check_circle : Icons.cancel),
color: _myMatch == null
? Colors.blue
: (_myMatch! ? Colors.green : Colors.red)),
const SizedBox(width: 8),
Expanded(
child: Text(
_myMatch == null
? '已提交,等待发起方提交标准答案后比对...'
: (_myMatch! ? '✅ 你的答案和发起方完全一致' : '❌ 你的答案和发起方不一致'),
style: TextStyle(
color: _myMatch == null
? Colors.blue.shade700
: (_myMatch!
? Colors.green.shade700
: Colors.red.shade700)),
),
),
],
),
),
if (_mySubmitted) const SizedBox(height: 16),
// 自己的输入框
TextField(
controller: _textCtrl,
maxLines: 4,
maxLength: 10000,
decoration: const InputDecoration(
hintText: '请输入你的答案...',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _isLoading ? null : _submit,
child: _isLoading
? const CircularProgressIndicator(strokeWidth: 2, color: Colors.white)
: const Text('提交'),
),
if (_errorMsg != null) _buildError(_errorMsg!),
const Spacer(),
ElevatedButton(
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
icon: const Icon(Icons.exit_to_app, size: 40, color: Colors.blue),
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 && mounted) _reset();
},
child: const Text('离开会话'),
),
],
);
}
// ── 通用组件 ──
Widget _buildSessionBar() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.share, size: 18, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: Text(
_sessionId ?? '',
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
),
),
IconButton(
tooltip: '复制',
onPressed: () => _copy(_sessionId ?? ''),
icon: const Icon(Icons.copy, size: 18),
),
const SizedBox(width: 8),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _myRole == _RoleSelect.initiator
? Colors.blue.shade100
: Colors.orange.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
_myRole == _RoleSelect.initiator ? '发起方' : '参与方',
style: TextStyle(
color: _myRole == _RoleSelect.initiator
? Colors.blue.shade800
: Colors.orange.shade800,
fontSize: 12,
fontWeight: FontWeight.bold),
),
),
],
),
);
}
Future<void> _copy(String text) async {
await Clipboard.setData(ClipboardData(text: text));
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(_snack('已复制', icon: Icons.check_circle, color: Colors.green));
}
/// 统一的 SnackBar:圆角 + 图标 + 主题色
SnackBar _snack(String text, {IconData? icon, Color? color, Duration duration = const Duration(seconds: 2)}) {
final bg = color ?? Colors.grey.shade800;
return SnackBar(
content: Row(
children: [
if (icon != null) ...[
Icon(icon, color: Colors.white, size: 20),
const SizedBox(width: 8),
],
Expanded(child: Text(text, style: const TextStyle(color: Colors.white))),
],
),
backgroundColor: bg,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
duration: duration,
);
}
Widget _buildError(String msg) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(6)),
child: Row(
children: [
const Icon(Icons.error_outline, size: 18, color: Colors.red),
const SizedBox(width: 8),
Expanded(child: Text(msg, style: const TextStyle(color: Colors.red))),
],
),
),
);
}
}
// ── ParticipantResp.copyWith 辅助扩展 ──
extension _ParticipantCopy on ParticipantResp {
ParticipantResp copyWith({
String? nickname,
bool? joined,
bool? submitted,
bool? match,
}) {
return ParticipantResp(
nickname: nickname ?? this.nickname,
joined: joined ?? this.joined,
submitted: submitted ?? this.submitted,
match: match ?? this.match,
);
}
}
extension _SessionCopy on SessionResp {
SessionResp copyWith({
String? sessionId,
String? initiatorNickname,
bool? initiatorReady,
bool? allowResubmit,
bool? ignoreWs,
List<ParticipantResp>? participants,
int? ttlSeconds,
}) {
return SessionResp(
sessionId: sessionId ?? this.sessionId,
initiatorNickname: initiatorNickname ?? this.initiatorNickname,
initiatorReady: initiatorReady ?? this.initiatorReady,
allowResubmit: allowResubmit ?? this.allowResubmit,
ignoreWs: ignoreWs ?? this.ignoreWs,
participants: participants ?? this.participants,
ttlSeconds: ttlSeconds ?? this.ttlSeconds,
);
}
}
// ── 全局辅助:把 DioException 转成用户友好的提示 ──
String _friendlyError(Object e, String fallback) {
if (e is DioException) {
switch (e.type) {
case DioExceptionType.connectionError:
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return '无法连接到服务器';
case DioExceptionType.cancel:
return '请求已取消';
case DioExceptionType.badResponse:
return isBizError(e) ? bizErrorMessage(e) : '服务器返回错误';
default:
return isBizError(e) ? bizErrorMessage(e) : fallback;
}
}
return '$fallback:$e';
}