Files
toolbox/client/lib/modules/dual_check/dual_check.dart
T
2026-08-29 23:34:01 +08:00

985 lines
31 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DualCheck 主页面(多参与者版)
// 一个发起方(initiator)提交标准答案,多个参与者(participant)各自提交文本
// 通过 WebSocket 实时同步参与者列表 + 提交状态 + match 结果
import 'dart:async';
import 'dart:convert';
import '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> {
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<void> _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<void> _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<String, dynamic>);
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<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,
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<void> _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<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() {
// 防重复弹窗(多次 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<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(
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<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),
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<void> _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<ParticipantResp>? 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,
);
}
}