// DualCheck 主页面(多参与者版) // 一个发起方(initiator)提交标准答案,多个参与者(participant)各自提交文本 // 通过 WebSocket 实时同步参与者列表 + 提交状态 + match 结果 import 'dart:async'; import 'dart:convert'; 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 { final _api = DualCheckApi(); final _nickService = NicknameService.instance; final _textCtrl = TextEditingController(); // ── 会话状态 ── String? _sessionId; late String _nickname = '用户'; late _RoleSelect _myRole = _RoleSelect.initiator; SessionResp? _session; // 当前会话全量状态(发起方视图) // ── WS ── WebSocketChannel? _ws; StreamSubscription? _wsSub; // ── UI 状态 ── bool _isLoading = false; String? _errorMsg; @override void initState() { super.initState(); _loadNickname(); } Future _loadNickname() async { final n = await _nickService.nickname; if (!mounted) return; setState(() => _nickname = n); } @override void dispose() { _wsSub?.cancel(); _ws?.sink.close(); _textCtrl.dispose(); super.dispose(); } // ── 发起方:创建会话 ── Future _createSession() async { setState(() => _isLoading = true); try { final resp = await _api.createSession(nickname: _nickname); setState(() { _sessionId = resp.sessionId; _myRole = _RoleSelect.initiator; }); // WS 连接(发起方不需要 join,直接连接) _connectWs(); } on DioException catch (e) { setState(() => _errorMsg = isBizError(e) ? bizErrorMessage(e) : '创建失败'); } catch (e) { setState(() => _errorMsg = '创建失败:$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, 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 { final sid = ctrl.text.trim(); if (sid.isEmpty) { set(() => err = '请输入会话码'); return; } 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 连接 ── void _connectWs() { final role = _myRole == _RoleSelect.initiator ? 'initiator' : 'participant'; final url = _api.wsUrl( sessionId: _sessionId!, role: role, nickname: _nickname, ); try { _ws = WebSocketChannel.connect(Uri.parse(url)); _wsSub = _ws!.stream.listen(_onWsMessage, onError: (e) { debugPrint('[DC] ws error: $e'); }, onDone: () { debugPrint('[DC] ws closed'); }); } catch (e) { debugPrint('[DC] ws connect fail: $e'); } } // ── WS 消息处理(事件驱动 UI) ── void _onWsMessage(dynamic raw) { try { final msg = WSMessage.fromJson( jsonDecode(raw as String) as Map); switch (msg.type) { case 'full_state': final s = DualCheckApi.parseSession(msg.data); setState(() => _session = s); case 'participant_joined': final nick = msg.data['nickname'] as String; setState(() { _session ??= SessionResp( sessionId: _sessionId!, initiatorNickname: '', initiatorReady: false, allowResubmit: 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, participants: [], ttlSeconds: 0); _session = _session!.copyWith(allowResubmit: allow); if (allow) _errorMsg = null; // 开关打开时清掉上次"不能重提交"的报错 }); case 'session_ended': // 只有参与者收到才弹窗——发起方自己就是结束者,不需要再提示自己 if (_myRole == _RoleSelect.participant) _showSessionEnded(); } } catch (e) { debugPrint('[DC] ws parse error: $e'); } } // ── 提交文本 ── Future _submit() async { if (_sessionId == null) return; final text = _textCtrl.text.trim(); 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, ); _textCtrl.clear(); // 参与者提交后立刻在本地更新会话状态 if (_myRole == _RoleSelect.participant) { setState(() { _session ??= SessionResp( sessionId: _sessionId!, initiatorNickname: '', initiatorReady: false, allowResubmit: false, participants: [], ttlSeconds: 0); _session = _session!.copyWith( initiatorReady: resp.initiatorReady, ); }); // 如果 initiator 已提交,可以直接显示 match if (resp.initiatorReady && resp.match != null) { _showMyResult(resp.match!); } else { _showParticipantWaiting(); } } else { // 发起方提交后等 WS full_state 来刷新 if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: 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); } } // ── 重置 ── void _reset() { _wsSub?.cancel(); _ws?.sink.close(); _textCtrl.clear(); setState(() { _ws = null; _wsSub = null; _sessionId = null; _session = null; _errorMsg = 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() { // 防重复弹窗(多次 session_ended / 重复 Build) if (!mounted || _sessionId == null) return; final sid = _sessionId!; _reset(); if (!mounted) return; 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('好的'), ), ], ), ); } // ── 发起方:切换允许重提交开关 ── 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( SnackBar(content: Text('操作失败:${bizErrorMessage(e)}')), ); } } // ── 设置昵称对话框 ── void _showNicknameDialog() { final ctrl = TextEditingController(text: _nickname); showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('设置昵称'), content: TextField( controller: ctrl, maxLength: 32, decoration: const InputDecoration( hintText: '1-32 字符', border: OutlineInputBorder(), counterText: '', ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('取消'), ), FilledButton( onPressed: () async { final ok = await _nickService.setNickname(ctrl.text); if (ok && ctx.mounted) Navigator.pop(ctx); setState(() { if (ok) _nickname = ctrl.text.trim(); }); if (ctx.mounted && !ok) { ScaffoldMessenger.of(ctx).showSnackBar( const SnackBar(content: Text('昵称无效')), ); } }, 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)), content: Text('你的昵称:$_nickname'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('好的')), ], ), ); } void _showParticipantWaiting() { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: 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: [ 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), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ const Icon(Icons.person, color: Colors.grey), const SizedBox(width: 8), const Text('当前昵称:'), Text(_nickname, style: const TextStyle(fontWeight: FontWeight.bold)), const Spacer(), TextButton( onPressed: _showNicknameDialog, child: const Text('修改'), ), ], ), ), 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: [ _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( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.green.shade50, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Icon(Icons.check_circle, color: Colors.green.shade700), const SizedBox(width: 8), Expanded( child: Text('标准答案已提交($initNick)', style: TextStyle(color: Colors.green.shade700)), ), ], ), ), ], 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) Expanded( child: 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 Expanded( child: ListView.separated( itemCount: participants.length, separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (_, i) => _buildParticipantCard(participants[i]), ), ), // 发起方控制区 SwitchListTile.adaptive( contentPadding: const EdgeInsets.symmetric(horizontal: 4), title: const Text('允许参与者重新提交'), subtitle: const Text('开启后参与者可以覆盖之前的提交结果'), value: _session?.allowResubmit ?? false, onChanged: _toggleResubmit, ), 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), // 自己的输入框 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: _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(const SnackBar(content: Text('已复制'))); } 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, List? participants, int? ttlSeconds, }) { return SessionResp( sessionId: sessionId ?? this.sessionId, initiatorNickname: initiatorNickname ?? this.initiatorNickname, initiatorReady: initiatorReady ?? this.initiatorReady, allowResubmit: allowResubmit ?? this.allowResubmit, participants: participants ?? this.participants, ttlSeconds: ttlSeconds ?? this.ttlSeconds, ); } }