1467 lines
53 KiB
Dart
1467 lines
53 KiB
Dart
// 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';
|
||
}
|