// 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 createState() => _DualCheckState(); } class _DualCheckState extends State 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? _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 _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 _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 _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 _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.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.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; setState(() { if (_session == null) return; final list = List.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.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.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.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 _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 _confirmAndEndSession() async { final confirmed = await showDialog( 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 _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 _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 _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 _handleWillPop() async { if (_sessionId == null) return; final confirmed = await showDialog( 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( 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 _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? 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'; }