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;
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||
+216
-27
@@ -6,7 +6,7 @@ packages:
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
@@ -14,7 +14,7 @@ packages:
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
@@ -22,7 +22,7 @@ packages:
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
@@ -30,7 +30,7 @@ packages:
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
@@ -38,25 +38,65 @@ packages:
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.11.0"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -67,7 +107,7 @@ packages:
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
@@ -75,12 +115,25 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
@@ -88,7 +141,7 @@ packages:
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
@@ -96,7 +149,7 @@ packages:
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
@@ -104,7 +157,7 @@ packages:
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
@@ -112,7 +165,7 @@ packages:
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
@@ -120,7 +173,7 @@ packages:
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
@@ -128,17 +181,121 @@ packages:
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.28"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.5.7"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -149,7 +306,7 @@ packages:
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
@@ -157,7 +314,7 @@ packages:
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
@@ -165,7 +322,7 @@ packages:
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
@@ -173,7 +330,7 @@ packages:
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
@@ -181,7 +338,7 @@ packages:
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
@@ -189,25 +346,57 @@ packages:
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
version: "15.3.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.5.1"
|
||||
web_socket_channel:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.5"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
sdks:
|
||||
dart: ">=3.12.2 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
flutter: ">=3.44.0"
|
||||
@@ -35,6 +35,11 @@ dependencies:
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
# HTTP 客户端 + WebSocket + 本地持久化
|
||||
dio: ^5.8.0
|
||||
web_socket_channel: ^2.4.5
|
||||
shared_preferences: ^2.5.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
Reference in new issue
Block a user