dual check v0.1
This commit is contained in:
1 parent
78bad1fa3d
commit
c137299016
36 files changed
+3727
-81
No files matched your search
@@ -0,0 +1,253 @@
|
||||
// DualCheck API 封装
|
||||
// 后端统一响应格式:{code:0, message, data}
|
||||
// WebSocket 消息格式:{type, data, error}
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 后端地址
|
||||
const String _baseUrl = kIsWeb
|
||||
? 'http://localhost:8080'
|
||||
: 'http://127.0.0.1:8080';
|
||||
|
||||
/// WebSocket 地址
|
||||
const String _wsBaseUrl =
|
||||
kIsWeb ? 'ws://localhost:8080' : 'ws://127.0.0.1:8080';
|
||||
|
||||
class DualCheckApi {
|
||||
DualCheckApi()
|
||||
: _dio = Dio(BaseOptions(
|
||||
baseUrl: _baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
)) {
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onResponse: (response, handler) {
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic> && data['code'] != 0) {
|
||||
handler.reject(DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
type: DioExceptionType.badResponse,
|
||||
error: _BizError(data['code'] as int, data['message'] as String),
|
||||
));
|
||||
return;
|
||||
}
|
||||
handler.next(response);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
// ── Create ──
|
||||
|
||||
Future<CreateResp> createSession({required String nickname}) async {
|
||||
final res = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/v1/dual-check/create',
|
||||
data: {'nickname': nickname},
|
||||
);
|
||||
final d = res.data!['data'] as Map<String, dynamic>;
|
||||
return CreateResp(
|
||||
sessionId: d['session_id'] as String,
|
||||
nickname: d['nickname'] as String,
|
||||
ttl: d['ttl_seconds'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Submit ──
|
||||
|
||||
Future<SubmitResp> submit({
|
||||
required String sessionId,
|
||||
required String nickname,
|
||||
required String role, // "initiator" | "participant"
|
||||
required String text,
|
||||
}) async {
|
||||
final res = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/v1/dual-check/submit',
|
||||
data: {
|
||||
'session_id': sessionId,
|
||||
'nickname': nickname,
|
||||
'role': role,
|
||||
'text': text,
|
||||
},
|
||||
);
|
||||
final d = res.data!['data'] as Map<String, dynamic>;
|
||||
return SubmitResp(
|
||||
submitted: d['submitted'] as bool,
|
||||
initiatorReady: d['initiator_ready'] as bool,
|
||||
match: d['match'] as bool?,
|
||||
);
|
||||
}
|
||||
|
||||
// ── CheckSession ──
|
||||
|
||||
Future<CheckResp> checkSession(String sessionId) async {
|
||||
final res = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/v1/dual-check/check/$sessionId',
|
||||
);
|
||||
final d = res.data!['data'] as Map<String, dynamic>;
|
||||
return CheckResp(
|
||||
exists: d['exists'] as bool,
|
||||
initiatorReady: d['initiator_ready'] as bool,
|
||||
participantCount: d['participant_count'] as int,
|
||||
ttlSeconds: d['ttl_seconds'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
// ── GetSession 全量快照 ──
|
||||
|
||||
Future<SessionResp> getSession(String sessionId) async {
|
||||
final res = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/v1/dual-check/session/$sessionId',
|
||||
);
|
||||
return parseSession(res.data!['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
// ── SetResubmit 发起方切换"允许参与者重新提交" ──
|
||||
|
||||
Future<SessionResp> setResubmit(String sessionId, {required bool allow}) async {
|
||||
final res = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/v1/dual-check/session/$sessionId/resubmit',
|
||||
data: {'allow': allow},
|
||||
);
|
||||
return parseSession(res.data!['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
// ── EndSession 发起方主动结束会话 ──
|
||||
|
||||
Future<void> endSession(String sessionId) async {
|
||||
await _dio.post('/api/v1/dual-check/session/$sessionId/end');
|
||||
}
|
||||
|
||||
static SessionResp parseSession(Map<String, dynamic> d) {
|
||||
final parts = (d['participants'] as List)
|
||||
.map((p) => ParticipantResp.fromMap(p as Map<String, dynamic>))
|
||||
.toList();
|
||||
return SessionResp(
|
||||
sessionId: d['session_id'] as String,
|
||||
initiatorNickname: (d['initiator_nickname'] ?? '') as String,
|
||||
initiatorReady: d['initiator_ready'] as bool,
|
||||
allowResubmit: (d['allow_resubmit'] as bool?) ?? false,
|
||||
participants: parts,
|
||||
ttlSeconds: d['ttl_seconds'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
// ── WS URL ──
|
||||
|
||||
String wsUrl({
|
||||
required String sessionId,
|
||||
required String role,
|
||||
required String nickname,
|
||||
}) {
|
||||
return '$_wsBaseUrl/api/v1/dual-check/ws'
|
||||
'?session_id=${Uri.encodeQueryComponent(sessionId)}'
|
||||
'&role=$role'
|
||||
'&nickname=${Uri.encodeQueryComponent(nickname)}';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据模型 ──
|
||||
|
||||
class CreateResp {
|
||||
final String sessionId;
|
||||
final String nickname;
|
||||
final int ttl;
|
||||
CreateResp({required this.sessionId, required this.nickname, required this.ttl});
|
||||
}
|
||||
|
||||
class SubmitResp {
|
||||
final bool submitted;
|
||||
final bool initiatorReady;
|
||||
final bool? match;
|
||||
SubmitResp({required this.submitted, required this.initiatorReady, this.match});
|
||||
}
|
||||
|
||||
class CheckResp {
|
||||
final bool exists;
|
||||
final bool initiatorReady;
|
||||
final int participantCount;
|
||||
final int ttlSeconds;
|
||||
CheckResp({
|
||||
required this.exists,
|
||||
required this.initiatorReady,
|
||||
required this.participantCount,
|
||||
required this.ttlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
class ParticipantResp {
|
||||
final String nickname;
|
||||
final bool joined;
|
||||
final bool submitted;
|
||||
final bool? match;
|
||||
ParticipantResp({
|
||||
required this.nickname,
|
||||
required this.joined,
|
||||
required this.submitted,
|
||||
this.match,
|
||||
});
|
||||
factory ParticipantResp.fromMap(Map<String, dynamic> m) {
|
||||
return ParticipantResp(
|
||||
nickname: m['nickname'] as String,
|
||||
joined: m['joined'] as bool? ?? true,
|
||||
submitted: m['submitted'] as bool,
|
||||
match: m['match'] as bool?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SessionResp {
|
||||
final String sessionId;
|
||||
final String initiatorNickname;
|
||||
final bool initiatorReady;
|
||||
final bool allowResubmit;
|
||||
final List<ParticipantResp> participants;
|
||||
final int ttlSeconds;
|
||||
SessionResp({
|
||||
required this.sessionId,
|
||||
required this.initiatorNickname,
|
||||
required this.initiatorReady,
|
||||
required this.allowResubmit,
|
||||
required this.participants,
|
||||
required this.ttlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
// ── WS 消息类型 ──
|
||||
|
||||
class WSMessage {
|
||||
final String type;
|
||||
final Map<String, dynamic> data;
|
||||
final String? error;
|
||||
|
||||
WSMessage({required this.type, required this.data, this.error});
|
||||
|
||||
factory WSMessage.fromJson(Map<String, dynamic> json) {
|
||||
return WSMessage(
|
||||
type: json['type'] as String,
|
||||
data: (json['data'] as Map<String, dynamic>?) ?? const {},
|
||||
error: json['error'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 业务错误处理 ──
|
||||
|
||||
class _BizError implements Exception {
|
||||
_BizError(this.code, this.message);
|
||||
final int code;
|
||||
final String message;
|
||||
@override
|
||||
String toString() => '[biz:$code] $message';
|
||||
}
|
||||
|
||||
bool isBizError(DioException e) => e.error is _BizError;
|
||||
int? bizErrorCode(DioException e) {
|
||||
final err = e.error;
|
||||
return err is _BizError ? err.code : null;
|
||||
}
|
||||
String bizErrorMessage(DioException e) {
|
||||
final err = e.error;
|
||||
return err is _BizError ? err.message : (e.message ?? '请求失败');
|
||||
}
|
||||
@@ -1,4 +1,18 @@
|
||||
import 'package:flutter/cupertino.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});
|
||||
@@ -8,8 +22,963 @@ class DualCheck extends StatefulWidget {
|
||||
}
|
||||
|
||||
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) {
|
||||
return const Placeholder();
|
||||
// 会话中: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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// 全局昵称设置
|
||||
// 持久化到 SharedPreferences / localStorage,所有工具共享同一个昵称
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _keyNickname = 'toolbox.nickname';
|
||||
|
||||
class NicknameService {
|
||||
NicknameService._();
|
||||
static final NicknameService instance = NicknameService._();
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
Future<void> _ensureInit() async {
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
/// 获取当前昵称,未设置时返回默认昵称
|
||||
Future<String> get nickname async {
|
||||
await _ensureInit();
|
||||
final n = _prefs!.getString(_keyNickname);
|
||||
if (n != null && n.trim().isNotEmpty) return n.trim();
|
||||
return '用户';
|
||||
}
|
||||
|
||||
/// 设置昵称(1-32 字符,过短返回 false)
|
||||
Future<bool> setNickname(String value) async {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty || trimmed.length > 32) return false;
|
||||
await _ensureInit();
|
||||
await _prefs!.setString(_keyNickname, trimmed);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user