985 lines
31 KiB
Dart
985 lines
31 KiB
Dart
// 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,
|
||
);
|
||
}
|
||
}
|