dual check v0.1

This commit is contained in:
cancerai committed 2026-08-29 23:34:01 +08:00
1 parent 78bad1fa3d
commit c137299016
36 files changed
+3727 -81

No files matched your search

+253
View File
@@ -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 ?? '请求失败');
}
+971 -2
View File
@@ -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;
}
}
+42 -12
View File
@@ -1,4 +1,5 @@
import 'package:client/const/font.dart';
import 'package:client/modules/dual_check/dual_check.dart';
import 'package:client/modules/home/tool_card.dart';
import 'package:client/modules/to_qrcode/to_qrcode.dart';
import 'package:flutter/material.dart';
@@ -11,7 +12,30 @@ class Home extends StatefulWidget {
}
class _HomeState extends State<Home> {
final toolsList = [ToQrcode()];
// 所有工具注册 —— 新增工具只需在这里加一项
// 数据驱动:页面 widget + 元信息(图标/名称/描述)分开
final List<_ToolEntry> _tools = [
// _ToolEntry(
// page: const ToQrcode(),
// meta: const ToolMeta(
// name: '二维码生成器',
// description: '上传文字、图片即可立即生成二维码',
// icon: Icons.qr_code,
// iconColor: Colors.purple,
// bgColor: Color(0xFFF3E5F5),
// ),
// ),
_ToolEntry(
page: const DualCheck(),
meta: const ToolMeta(
name: 'DualCheck 双验',
description: '两人各输入一段文字,实时比对是否一致',
icon: Icons.verified_user,
iconColor: Colors.teal,
bgColor: Color(0xFFE0F2F1),
),
),
];
@override
Widget build(BuildContext context) {
@@ -27,10 +51,10 @@ class _HomeState extends State<Home> {
),
),
centerTitle: true,
actions: [
CircleAvatar(child: Text("登陆", style: TextStyle(fontSize: 12))),
SizedBox(width: 16),
],
// actions: [
// CircleAvatar(child: Text("登陆", style: TextStyle(fontSize: 12))),
// SizedBox(width: 16),
// ],
),
body: GridView.builder(
padding: EdgeInsets.symmetric(horizontal: 16),
@@ -40,23 +64,29 @@ class _HomeState extends State<Home> {
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: toolsList.length,
itemCount: _tools.length,
itemBuilder: (BuildContext context, int index) {
return GestureDetector(
onTap: () {
enterCard(index);
},
child: ToolCard(),
onTap: () => _enterCard(index),
child: ToolCard(meta: _tools[index].meta),
);
},
),
);
}
void enterCard(int index) {
void _enterCard(int index) {
Navigator.push(
context,
MaterialPageRoute(builder: ((context) => toolsList[index])),
MaterialPageRoute(builder: (_) => _tools[index].page),
);
}
}
/// 内部工具注册条目
class _ToolEntry {
final Widget page;
final ToolMeta meta;
_ToolEntry({required this.page, required this.meta});
}
+50 -40
View File
@@ -2,60 +2,70 @@ import 'package:flutter/material.dart';
import '../../const/font.dart';
class ToolCard extends StatefulWidget {
const ToolCard({super.key});
/// 工具元信息 —— 数据驱动首页卡片渲染
class ToolMeta {
final String name;
final String description;
final IconData icon;
final Color iconColor;
final Color bgColor;
@override
State<ToolCard> createState() => _ToolCardState();
const ToolMeta({
required this.name,
required this.description,
required this.icon,
this.iconColor = Colors.blue,
this.bgColor = const Color(0xFFF5F5F5),
});
}
class _ToolCardState extends State<ToolCard> {
class ToolCard extends StatelessWidget {
const ToolCard({super.key, required this.meta});
final ToolMeta meta;
@override
Widget build(BuildContext context) {
return Card(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(),
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: Image.asset(
"assets/qr_code_logo.png",
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
// 图标占位区
Container(
width: 80,
height: double.infinity,
decoration: BoxDecoration(
color: meta.bgColor,
borderRadius: BorderRadius.circular(8),
),
child: Icon(meta.icon, color: meta.iconColor, size: 36),
),
SizedBox(width: 8),
SizedBox(width: 12),
Expanded(
child: ClipRect(
clipBehavior: Clip.hardEdge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"二维码生成器",
style: TextStyle(
fontSize: Font.FONT_SIZE_SUBTITLE,
fontWeight: FontWeight.bold,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
meta.name,
style: TextStyle(
fontSize: Font.FONT_SIZE_SUBTITLE,
fontWeight: FontWeight.bold,
),
SizedBox(height: 8),
Divider(),
SizedBox(height: 8),
Container(
child: Text(
"上传文字,图片即可立即生成二维码",
maxLines: 2,
style: TextStyle(
fontSize: Font.FONT_SIZE_CONTENT,
overflow: TextOverflow.ellipsis,
),
),
),
SizedBox(height: 6),
Divider(height: 1),
SizedBox(height: 6),
Text(
meta.description,
maxLines: 2,
style: TextStyle(
fontSize: Font.FONT_SIZE_CONTENT,
overflow: TextOverflow.ellipsis,
color: Colors.grey.shade600,
),
],
),
),
],
),
),
],