1626 lines
50 KiB
Dart
1626 lines
50 KiB
Dart
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:provider/provider.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
import 'package:dio/dio.dart';
|
||
|
||
import '../../model/user_model.dart';
|
||
import '../../providers/user_provider.dart';
|
||
import 'login_page.dart';
|
||
|
||
class UserPage extends StatefulWidget {
|
||
const UserPage({super.key});
|
||
|
||
@override
|
||
State<UserPage> createState() => _UserPageState();
|
||
}
|
||
|
||
class _UserPageState extends State<UserPage> {
|
||
final _nameController = TextEditingController();
|
||
final _emailController = TextEditingController();
|
||
final _sexController = TextEditingController();
|
||
final _bioController = TextEditingController();
|
||
final _dfUserController = TextEditingController();
|
||
final _dfPwdController = TextEditingController();
|
||
final _lxKeyController = TextEditingController();
|
||
static const _pinkColor = Color(0xFFFFC0D6);
|
||
|
||
// 开关状态
|
||
bool _isDisagreeRecommend = false;
|
||
bool _isDisagreeFriend = false;
|
||
Map<String, dynamic>? _radarData;
|
||
|
||
// 获取雷达图方法
|
||
Future<void> _loadRadarData() async {
|
||
final provider = Provider.of<UserProvider>(context, listen: false);
|
||
try {
|
||
final data = await provider.fetchRadarData("684a6ee7f62aed83538ded34");
|
||
setState(() {
|
||
_radarData = data;
|
||
});
|
||
if(mounted){
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text("雷达图数据加载成功 ✅")),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if(mounted){
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text("加载失败:$e")),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
@override
|
||
void initState() {
|
||
_loadRadarData;
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
final provider = Provider.of<UserProvider>(context, listen: false);
|
||
provider.fetchSexTags();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void didChangeDependencies() {
|
||
super.didChangeDependencies();
|
||
final user = Provider.of<UserProvider>(context).user;
|
||
_nameController.text = user?.name ?? '';
|
||
_emailController.text = user?.email ?? '';
|
||
_sexController.text = user?.sex ?? '';
|
||
_bioController.text = user?.bio ?? '';
|
||
_dfUserController.text = user?.dfUsername ?? '';
|
||
_dfPwdController.text = user?.dfPassword ?? '';
|
||
_lxKeyController.text = user?.lxKey ?? '';
|
||
|
||
// 初始化开关
|
||
_isDisagreeRecommend = user?.isDisagreeRecommend ?? false;
|
||
_isDisagreeFriend = user?.isDisagreeFriend ?? false;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_nameController.dispose();
|
||
_emailController.dispose();
|
||
_sexController.dispose();
|
||
_bioController.dispose();
|
||
_dfUserController.dispose();
|
||
_dfPwdController.dispose();
|
||
_lxKeyController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
// 2. 添加打开链接的方法
|
||
Future<void> _launchURL(String urlString) async {
|
||
final Uri url = Uri.parse(urlString);
|
||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('无法打开链接: $urlString')),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _verifyShuiyu() async {
|
||
final username = _dfUserController.text.trim();
|
||
final password = _dfPwdController.text.trim();
|
||
|
||
// 1. 判空校验
|
||
if (username.isEmpty || password.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text("请输入水鱼用户名和密码")),
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 2. 显示加载提示
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text("正在验证水鱼账号..."),
|
||
duration: Duration(seconds: 1),
|
||
),
|
||
);
|
||
}
|
||
|
||
try {
|
||
// 3. 创建Dio实例,发送POST请求
|
||
final dio = Dio();
|
||
final response = await dio.post(
|
||
'https://maimai.diving-fish.com/api/maimaidxprober/login',
|
||
data: {
|
||
"username": username,
|
||
"password": password,
|
||
},
|
||
// 可选:设置请求超时
|
||
options: Options(
|
||
sendTimeout: const Duration(seconds: 10),
|
||
receiveTimeout: const Duration(seconds: 10),
|
||
),
|
||
);
|
||
|
||
// 4. 解析返回结果
|
||
final Map<String, dynamic> result = response.data;
|
||
|
||
if (mounted) {
|
||
// 登录成功
|
||
if (result['message'] == "登录成功") {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text("水鱼账号验证成功!"),
|
||
backgroundColor: Colors.green,
|
||
duration: Duration(seconds: 1),
|
||
),
|
||
);
|
||
// TODO: 验证成功后的逻辑(保存信息、跳转页面等)
|
||
} else {
|
||
// 账号密码错误
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(result['message'] ?? "验证失败"),
|
||
duration: Duration(seconds: 1),
|
||
backgroundColor: Colors.red,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
} on DioException catch (e) {
|
||
// 5. Dio网络错误处理(无网络、超时、服务器异常)
|
||
if (mounted) {
|
||
String errorMsg = "网络请求失败";
|
||
if (e.type == DioExceptionType.connectionTimeout) {
|
||
errorMsg = "连接超时,请检查网络";
|
||
} else if (e.type == DioExceptionType.receiveTimeout) {
|
||
errorMsg = "服务器响应超时";
|
||
} else if (e.type == DioExceptionType.connectionError) {
|
||
errorMsg = "网络连接异常,请检查网络";
|
||
}
|
||
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(errorMsg),
|
||
backgroundColor: Colors.red,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
// 其他未知错误
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text("未知错误,请重试"),
|
||
backgroundColor: Colors.red,
|
||
duration: Duration(seconds: 1),
|
||
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _verifyLuoxue() async {
|
||
final key = _lxKeyController.text.trim();
|
||
|
||
if (key.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text("请输入落雪 lxKey")),
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
final dio = Dio();
|
||
|
||
// 发起请求
|
||
final response = await dio.get(
|
||
'https://maimai.lxns.net/api/v0/user/maimai/player',
|
||
options: Options(
|
||
headers: {
|
||
'X-User-Token': key,
|
||
},
|
||
),
|
||
);
|
||
|
||
// ✅ 关键:解析外层响应 + 内层 data
|
||
final result = LuoxueResponse.fromJson(response.data);
|
||
|
||
if (result.success && mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text("✅ 验证成功!玩家:${result.data.name}\nDX Rating:${result.data.rating}"),
|
||
),
|
||
);
|
||
|
||
// 在这里可以使用完整的玩家数据
|
||
// result.data.name
|
||
// result.data.rating
|
||
// result.data.friendCode
|
||
// result.data.trophy.name
|
||
}
|
||
} on DioException catch (e) {
|
||
String msg = "验证失败";
|
||
if (e.response?.statusCode == 401) {
|
||
msg = "❌ lxKey 无效或已过期";
|
||
} else if (e.type == DioExceptionType.connectionError) {
|
||
msg = "📶 网络连接失败";
|
||
}
|
||
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(msg)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
void _showSexSelectDialog() {
|
||
final provider = Provider.of<UserProvider>(context, listen: false);
|
||
final tags = provider.sexTags;
|
||
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||
),
|
||
builder: (context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Text(
|
||
"选择性别标签",
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(height: 16),
|
||
if (tags.isEmpty)
|
||
const Padding(
|
||
padding: EdgeInsets.all(20),
|
||
child: Text("暂无性别标签"),
|
||
),
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: tags.map((tag) {
|
||
return ActionChip(
|
||
label: Text(tag),
|
||
onPressed: () {
|
||
_sexController.text = tag;
|
||
Navigator.pop(context);
|
||
},
|
||
);
|
||
}).toList(),
|
||
),
|
||
const SizedBox(height: 30),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final userProvider = Provider.of<UserProvider>(context);
|
||
final UserModel? user = userProvider.user;
|
||
|
||
if (user == null) {
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text("用户中心")),
|
||
body: Center(
|
||
|
||
child: Transform.translate(
|
||
// Offset(水平, 垂直)
|
||
// 垂直向上:负值
|
||
offset: const Offset(0, -40), // 往上移 10 像素
|
||
child: _buildLoginCard(
|
||
onTap: () {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => const LoginPage()),
|
||
);
|
||
},
|
||
),
|
||
)
|
||
),
|
||
floatingActionButton: _buildFloatingBackButton(),
|
||
);
|
||
}
|
||
|
||
return Scaffold(
|
||
body: SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
|
||
child: Center(
|
||
child: Container(
|
||
constraints: const BoxConstraints(maxWidth: 900),
|
||
child: Column(
|
||
children: [
|
||
_buildProfileHeader(context, userProvider, user),
|
||
const SizedBox(height: 24),
|
||
|
||
if (user.userCards != null && user.userCards!.isNotEmpty)
|
||
_buildUserCardSlider(userProvider, user),
|
||
const SizedBox(height: 24),
|
||
|
||
_buildSectionCard([
|
||
_buildItem("用户ID", user.id.toString()),
|
||
_buildEditableItem("用户名", _nameController),
|
||
_buildEditableItem("邮箱", _emailController),
|
||
_buildEditableSexItem(),
|
||
_buildEditableItem("个性签名", _bioController),
|
||
_buildItem("积分", user.points.toString()),
|
||
_buildItem("B50方案", user.useB50Type.toString()),
|
||
_buildCopyableItem(context, "API Key", user.apiKey),
|
||
_buildCopyableItem(context, "绑定 API Key", user.apiBindKey),
|
||
_buildItem("UserID", "****${user.userId.toString().substring(4)}"),
|
||
_buildItem("上次登录", _formatLastLoginTime(user.lastLoginTime)),
|
||
]),
|
||
const SizedBox(height: 20),
|
||
|
||
_buildScoreCheckerCard(user),
|
||
const SizedBox(height: 20),
|
||
|
||
_buildSectionCard([
|
||
_buildSwitchItem("禁止被推荐", _isDisagreeRecommend, (v) {
|
||
setState(() => _isDisagreeRecommend = v);
|
||
}),
|
||
_buildSwitchItem("禁止加好友", _isDisagreeFriend, (v) {
|
||
setState(() => _isDisagreeFriend = v);
|
||
}),
|
||
]),
|
||
const SizedBox(height: 20),
|
||
|
||
_buildSegaCard(user),
|
||
const SizedBox(height: 20),
|
||
_buildRadarChartSection(),
|
||
const SizedBox(height: 20),
|
||
|
||
|
||
|
||
_buildSaveButton(userProvider),
|
||
const SizedBox(height: 12),
|
||
_buildLogoutButton(context, userProvider),
|
||
const SizedBox(height: 100),
|
||
|
||
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
floatingActionButton: _buildFloatingBackButton(),
|
||
);
|
||
}
|
||
|
||
// 5. 修改 _buildScoreCheckerCard以添加按钮
|
||
Widget _buildScoreCheckerCard(UserModel user) {
|
||
return _webCard(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text(
|
||
"第三方查分器绑定",
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// --- 水鱼部分 ---
|
||
const Text("水鱼查分器", style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
|
||
const SizedBox(height: 8),
|
||
TextField(
|
||
controller: _dfUserController,
|
||
decoration: const InputDecoration(
|
||
labelText: "用户名",
|
||
isDense: true,
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
TextField(
|
||
controller: _dfPwdController,
|
||
obscureText: true,
|
||
decoration: const InputDecoration(
|
||
labelText: "密码",
|
||
isDense: true,
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
// 水鱼操作按钮行
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
TextButton.icon(
|
||
icon: const Icon(Icons.check_circle_outline, size: 18),
|
||
label: const Text("验证水鱼账号"),
|
||
onPressed: _verifyShuiyu,
|
||
),
|
||
const SizedBox(width: 8),
|
||
TextButton.icon(
|
||
icon: const Icon(Icons.open_in_new, size: 18),
|
||
label: const Text("跳转水鱼官网"),
|
||
onPressed: () => _launchURL("https://www.diving-fish.com/"), // 替换为实际水鱼官网地址
|
||
),
|
||
],
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
// --- 落雪部分 ---
|
||
const Text("落雪查分器", style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
|
||
const SizedBox(height: 8),
|
||
TextField(
|
||
controller: _lxKeyController,
|
||
decoration: const InputDecoration(
|
||
labelText: "lxKey",
|
||
isDense: true,
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
// 落雪操作按钮行
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
TextButton.icon(
|
||
icon: const Icon(Icons.check_circle_outline, size: 18),
|
||
label: const Text("验证落雪账号"),
|
||
onPressed: _verifyLuoxue,
|
||
),
|
||
const SizedBox(width: 8),
|
||
TextButton.icon(
|
||
icon: const Icon(Icons.open_in_new, size: 18),
|
||
label: const Text("跳转落雪官网"),
|
||
onPressed: () => _launchURL("https://maimai.lxns.net/"), // 替换为实际落雪官网地址
|
||
),
|
||
],
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
const Text("Mai2Links 账号", style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
|
||
const SizedBox(height: 8),
|
||
if (user.mai2links != null && user.mai2links!.isNotEmpty)
|
||
...user.mai2links!.keys.map((name) {
|
||
final isUser = name.toLowerCase().contains("user");
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: isUser ? Colors.purpleAccent : Colors.green,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
isUser ? "用户账号" : "机台账号",
|
||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text("mai2Link用户 · $name"),
|
||
],
|
||
),
|
||
);
|
||
})
|
||
else
|
||
const Text("无mai2links绑定"),
|
||
const SizedBox(height: 20),
|
||
|
||
const Text("国服多账号", style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
|
||
const SizedBox(height: 6),
|
||
if(user.userName2userId!=null && user.userName2userId!.isNotEmpty)
|
||
...user.userName2userId!.keys.map((name) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: Colors.pinkAccent,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
"舞萌账号" ,
|
||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text("国服用户 - $name"),
|
||
],
|
||
),
|
||
);
|
||
})
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
String _formatLastLoginTime(int? timestamp) {
|
||
if (timestamp == null || timestamp == 0) return "未登录";
|
||
final date = DateTime.fromMillisecondsSinceEpoch(timestamp)
|
||
.toUtc()
|
||
.add(const Duration(hours: 8));
|
||
return date.toString().substring(0, 19);
|
||
}
|
||
|
||
Widget _buildEditableSexItem() {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 7),
|
||
child: Row(
|
||
children: [
|
||
const SizedBox(width: 120, child: Text("性别")),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _sexController,
|
||
decoration: InputDecoration(
|
||
isDense: true,
|
||
border: const UnderlineInputBorder(),
|
||
suffixIcon: IconButton(
|
||
icon: const Icon(Icons.arrow_drop_down, size: 20),
|
||
onPressed: _showSexSelectDialog,
|
||
),
|
||
),
|
||
enabled: true,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildEditableItem(String label, TextEditingController controller) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 7),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
SizedBox(width: 120, child: Text(label)),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: controller,
|
||
decoration: const InputDecoration(
|
||
isDense: true,
|
||
border: UnderlineInputBorder(),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _refreshUserInfo() async {
|
||
final provider = Provider.of<UserProvider>(context, listen: false);
|
||
await provider.initUser();
|
||
final user = provider.user;
|
||
if (user != null) {
|
||
_nameController.text = user.name ?? '';
|
||
_emailController.text = user.email ?? '';
|
||
_sexController.text = user.sex ?? '';
|
||
_bioController.text = user.bio ?? '';
|
||
_dfUserController.text = user.dfUsername ?? '';
|
||
_dfPwdController.text = user.dfPassword ?? '';
|
||
_lxKeyController.text = user.lxKey ?? '';
|
||
|
||
setState(() {
|
||
_isDisagreeRecommend = user.isDisagreeRecommend ?? false;
|
||
_isDisagreeFriend = user.isDisagreeFriend ?? false;
|
||
});
|
||
}
|
||
await provider.fetchSexTags();
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text("用户信息已刷新 ✅")),
|
||
);
|
||
}
|
||
}
|
||
|
||
Widget _buildProfileHeader(
|
||
BuildContext context, UserProvider provider, UserModel user) {
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
return _webCard(
|
||
child: Row(
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Image.network(
|
||
provider.avatarUrl,
|
||
width: 80,
|
||
height: 80,
|
||
fit: BoxFit.cover,
|
||
),
|
||
),
|
||
const SizedBox(width: 20),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
user.name ?? "Unknown",
|
||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
"Rating: ${user.rating ?? 0}",
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
color: isDark ? Colors.grey[400] : Colors.grey[600]),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildFloatingBackButton() {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 100),
|
||
child: FloatingActionButton(
|
||
elevation: 6,
|
||
backgroundColor: Colors.black87,
|
||
onPressed: () async {
|
||
await _refreshUserInfo();
|
||
if (mounted) {
|
||
Navigator.maybePop(context);
|
||
}
|
||
},
|
||
child: const Icon(Icons.refresh, color: Colors.white),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildUserCardSlider(UserProvider provider, UserModel user) {
|
||
return _webCard(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text("用户卡牌",
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
height: 340,
|
||
child: PageView.builder(
|
||
itemCount: user.userCards!.length,
|
||
itemBuilder: (context, index) {
|
||
final card = user.userCards![index];
|
||
return _buildSingleCard(card);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSingleCard(dynamic card) {
|
||
final charaId = card.charaId?.toString().padLeft(6, '0') ?? "000000";
|
||
final iconId = card.iconId?.toString().padLeft(6, '0') ?? "000000";
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(9),
|
||
image: DecorationImage(
|
||
image: NetworkImage(
|
||
"https://cdn.godserver.cn/resource/static/coll/Chara/UI_Chara_$charaId.png",
|
||
),
|
||
fit: BoxFit.cover,
|
||
colorFilter: ColorFilter.mode(
|
||
Colors.black.withOpacity(0.4), BlendMode.darken),
|
||
),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: Image.network(
|
||
"https://cdn.godserver.cn/resource/static/coll/Icon/UI_Icon_$iconId.png",
|
||
width: 50,
|
||
height: 50,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
card.username ?? "",
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600),
|
||
),
|
||
Text("Rating: ${card.rating ?? 0}",
|
||
style: const TextStyle(color: Colors.white70)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSaveButton(UserProvider provider) {
|
||
return SizedBox(
|
||
width: double.infinity,
|
||
height: 50,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.greenAccent.shade400,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
onPressed: () async {
|
||
final user = provider.user;
|
||
if (user == null) return;
|
||
|
||
final newUser = UserModel(
|
||
id: user.id,
|
||
name: _nameController.text.trim(),
|
||
userId: user.userId,
|
||
teamId: user.teamId,
|
||
email: _emailController.text.trim(),
|
||
password: user.password,
|
||
twoFactorKey: user.twoFactorKey,
|
||
apiKey: user.apiKey,
|
||
apiBindKey: user.apiBindKey,
|
||
protectRole: user.protectRole,
|
||
risks: user.risks,
|
||
mcName: user.mcName,
|
||
userName2userId: user.userName2userId,
|
||
|
||
lxKey: _lxKeyController.text.trim(),
|
||
dfUsername: _dfUserController.text.trim(),
|
||
dfPassword: _dfPwdController.text.trim(),
|
||
|
||
nuoId: user.nuoId,
|
||
botId: user.botId,
|
||
spasolBotId: user.spasolBotId,
|
||
githubId: user.githubId,
|
||
rating: user.rating,
|
||
ratingMax: user.ratingMax,
|
||
iconId: user.iconId,
|
||
plateId: user.plateId,
|
||
plateIds: user.plateIds,
|
||
frameId: user.frameId,
|
||
charaSlots: user.charaSlots,
|
||
qiandaoDay: user.qiandaoDay,
|
||
inviter: user.inviter,
|
||
successLogoutTime: user.successLogoutTime,
|
||
lastLoginTime: user.lastLoginTime,
|
||
friendIds: user.friendIds,
|
||
bio: _bioController.text.trim(),
|
||
friendBio: user.friendBio,
|
||
sex: _sexController.text.trim(),
|
||
|
||
// 开关已正确保存
|
||
isDisagreeRecommend: _isDisagreeRecommend,
|
||
isDisagreeFriend: _isDisagreeFriend,
|
||
|
||
points: user.points,
|
||
planPoints: user.planPoints,
|
||
cardIds: user.cardIds,
|
||
userCards: user.userCards,
|
||
tags: user.tags,
|
||
useBeta: user.useBeta,
|
||
useNuo: user.useNuo,
|
||
useServer: user.useServer,
|
||
useB50Type: user.useB50Type,
|
||
userHot: user.userHot,
|
||
chatInGroupNumbers: user.chatInGroupNumbers,
|
||
sc: user.sc,
|
||
id2pcNuo: user.id2pcNuo,
|
||
mai2links: user.mai2links,
|
||
key2KeychipEn: user.key2KeychipEn,
|
||
key2key2KeychipEn: user.key2key2KeychipEn,
|
||
mai2link: user.mai2link,
|
||
userRegion: user.userRegion,
|
||
rinUsernameOrEmail: user.rinUsernameOrEmail,
|
||
rinPassword: user.rinPassword,
|
||
rinChusanUser: user.rinChusanUser,
|
||
segaCards: user.segaCards,
|
||
placeList: user.placeList,
|
||
lastKeyChip: user.lastKeyChip,
|
||
token: user.token,
|
||
timesRegionData: user.timesRegionData,
|
||
yearTotal: user.yearTotal,
|
||
yearTotalComment: user.yearTotalComment,
|
||
userCollCardMap: user.userCollCardMap,
|
||
collName2musicIds: user.collName2musicIds,
|
||
ai: user.ai,
|
||
pkScore: user.pkScore,
|
||
pkScoreStr: user.pkScoreStr,
|
||
pkScoreReality: user.pkScoreReality,
|
||
pkUserId: user.pkUserId,
|
||
limitPkTimestamp: user.limitPkTimestamp,
|
||
hasAcceptPk: user.hasAcceptPk,
|
||
pkPlayNum: user.pkPlayNum,
|
||
pkWin: user.pkWin,
|
||
userData: user.userData,
|
||
banState: user.banState,
|
||
);
|
||
|
||
provider.updateUser(newUser);
|
||
|
||
try {
|
||
await provider.saveUserInfo();
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text("保存成功 ✅")),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text("保存失败:${e.toString()}")),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
child: const Text(
|
||
"保存修改",
|
||
style: TextStyle(color: Colors.black87, fontSize: 16),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSegaCard(UserModel user) {
|
||
return _webCard(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text("SegaID 账号",
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 10),
|
||
if (user.segaCards != null && user.segaCards!.isNotEmpty)
|
||
...user.segaCards!.map((card) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: (card.type ?? "intl") == "jp" ? Colors.purple : Colors.blue,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
card.type == "jp" ? "日服" : "国际服",
|
||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text(card.segaId ?? ""),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
if (user.segaCards == null || user.segaCards!.isEmpty)
|
||
const Text("暂无 SegaID"),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _webCard({required Widget child}) {
|
||
return Builder(
|
||
builder: (context) {
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
return Container(
|
||
width: double.infinity,
|
||
decoration: BoxDecoration(
|
||
color: isDark ? Colors.grey[850] : Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(
|
||
color: isDark ? Colors.grey[700]! : Colors.grey[200]!,
|
||
width: 1),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black12.withOpacity(isDark ? 0.3 : 0.08),
|
||
blurRadius: 12,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
BoxShadow(
|
||
color: Colors.greenAccent.withOpacity(0.2),
|
||
blurRadius: 15,
|
||
spreadRadius: 3,
|
||
offset: const Offset(0, 3),
|
||
),
|
||
],
|
||
),
|
||
padding: const EdgeInsets.all(20),
|
||
child: child,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildSectionCard(List<Widget> children) {
|
||
return _webCard(child: Column(children: children));
|
||
}
|
||
|
||
Widget _buildCopyableItem(
|
||
BuildContext context, String label, String? value) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(2),
|
||
onTap: () {
|
||
if (value == null || value.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text("暂无绑定 API Key")));
|
||
return;
|
||
}
|
||
Clipboard.setData(ClipboardData(text: value));
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text("已复制:$value")));
|
||
},
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(width: 120, child: Text(label)),
|
||
Expanded(
|
||
child: Text(
|
||
value ?? "-",
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: Colors.blueAccent,
|
||
decoration: TextDecoration.underline),
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
const Icon(Icons.copy, size: 14, color: Colors.grey),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildItem(String label, String? value) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 7),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(width: 120, child: Text(label)),
|
||
Expanded(child: Text(value ?? "-")),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSwitchItem(String title, bool value, ValueChanged<bool> onChanged) {
|
||
return SwitchListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
title: Text(title),
|
||
value: value,
|
||
onChanged: onChanged,
|
||
);
|
||
}
|
||
|
||
Widget _buildLogoutButton(BuildContext context, UserProvider provider) {
|
||
return SizedBox(
|
||
width: double.infinity,
|
||
height: 50,
|
||
child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.redAccent,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
onPressed: () async {
|
||
await provider.logout();
|
||
if (context.mounted) {
|
||
Navigator.maybePop(context);
|
||
}
|
||
},
|
||
child: const Text("退出登录",
|
||
style: TextStyle(color: Colors.white, fontSize: 16)),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLoginCard({required VoidCallback onTap}) {
|
||
return Builder(
|
||
builder: (context) {
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
final primaryColor = _pinkColor;
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
constraints: const BoxConstraints(maxWidth: 400),
|
||
padding: const EdgeInsets.all(24),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? Colors.grey[850] : Colors.white,
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(
|
||
color: isDark ? Colors.grey[700]! : Colors.grey[200]!,
|
||
width: 1,
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.pinkAccent.withOpacity(isDark ? 0.4 : 0.1),
|
||
blurRadius: 20,
|
||
spreadRadius: 10,
|
||
offset: const Offset(0, 10),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// 1. 头部图标与标题
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: primaryColor.withOpacity(0.1),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(
|
||
Icons.account_circle_outlined,
|
||
size: 48,
|
||
color: primaryColor,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
"加入Union",
|
||
style: TextStyle(
|
||
fontSize: 22,
|
||
fontWeight: FontWeight.bold,
|
||
color: isDark ? Colors.white : Colors.black87,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
"登录以解锁完整功能",
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: isDark ? Colors.grey[400] : Colors.grey[600],
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
|
||
const SizedBox(height: 24),
|
||
|
||
// 2. 登录好处列表 (Value Proposition)
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? Colors.grey[800] : Colors.grey[50],
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildBenefitItem(
|
||
icon: Icons.sync,
|
||
title: "多端数据同步",
|
||
desc: "水鱼/落雪查分器一键绑定",
|
||
isDark: isDark,
|
||
),
|
||
const Divider(height: 16),
|
||
_buildBenefitItem(
|
||
icon: Icons.palette,
|
||
title: "无需其他查分器即可使用",
|
||
desc: "不依赖水鱼&落雪,同时支持日服与国际服全套服务",
|
||
isDark: isDark,
|
||
),
|
||
const Divider(height: 16),
|
||
_buildBenefitItem(
|
||
icon: Icons.insights,
|
||
title: "深度数据分析",
|
||
desc: "查看 B50,Rating,歌曲详细推分 趋势与详情",
|
||
isDark: isDark,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 24),
|
||
|
||
// 3. 登录按钮
|
||
SizedBox(
|
||
width: double.infinity,
|
||
height: 50,
|
||
child: ElevatedButton(
|
||
onPressed: onTap,
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: primaryColor,
|
||
foregroundColor: Colors.white,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
),
|
||
child: const Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text(
|
||
"立即登录 / 注册",
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||
),
|
||
SizedBox(width: 8),
|
||
Icon(Icons.arrow_forward, size: 18),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
// 辅助方法:构建单个权益项
|
||
Widget _buildBenefitItem({
|
||
required IconData icon,
|
||
required String title,
|
||
required String desc,
|
||
required bool isDark,
|
||
}) {
|
||
return Row(
|
||
children: [
|
||
Icon(icon, size: 20, color: isDark ? Colors.grey[400] : Colors.grey[600]),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: isDark ? Colors.white : Colors.black87,
|
||
),
|
||
),
|
||
Text(
|
||
desc,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: isDark ? Colors.grey[500] : Colors.grey[500],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildRadarChartSection() {
|
||
return _webCard(
|
||
child: Column(
|
||
children: [
|
||
const Text(
|
||
"能力雷达图",
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
if (_radarData == null)
|
||
TextButton(
|
||
onPressed: _loadRadarData,
|
||
child: const Text("点击加载雷达图数据"),
|
||
)
|
||
else
|
||
// 使用我们自定义的雷达图
|
||
SizedBox(
|
||
height: 300,
|
||
width: double.infinity,
|
||
child: CustomRadarChart(
|
||
// 传入你的数据,确保 Map<String, double>
|
||
data: _radarData!.map((key, value) =>
|
||
MapEntry(key.toString(), double.tryParse(value.toString()) ?? 0.0)
|
||
),
|
||
maxValue: 1.3, // 强制最大值为 1.5,不再自动缩放
|
||
lineColor: Colors.grey.shade200, // 极淡的网格线,视觉上接近“无”
|
||
areaColor: Colors.pink.withOpacity(0.15),
|
||
borderColor: Colors.pinkAccent,
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 20),
|
||
|
||
// ————————————————————————————————
|
||
// 下面的列表保持不变
|
||
// ————————————————————————————————
|
||
if (_radarData != null)
|
||
GridView.count(
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
crossAxisCount: 3,
|
||
childAspectRatio: 2.4,
|
||
crossAxisSpacing: 8,
|
||
mainAxisSpacing: 8,
|
||
children: _radarData!.entries.map((e) {
|
||
final key = e.key.toString();
|
||
final value = double.tryParse(e.value.toString()) ?? 0.0;
|
||
final isHigh = value >= 1.0;
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(
|
||
color: isHigh ? Colors.pink.shade200 : Colors.grey.shade300,
|
||
width: 1,
|
||
),
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
key,
|
||
style: const TextStyle(fontSize: 12,
|
||
fontWeight: FontWeight.w500),
|
||
),
|
||
Text(
|
||
value.toStringAsFixed(2),
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
class CustomRadarChart extends StatelessWidget {
|
||
final Map<String, double> data; // 维度名 -> 数值
|
||
final double maxValue; // 最大值,用于计算比例
|
||
final Color lineColor; // 网格线颜色
|
||
final Color areaColor; // 填充区域颜色
|
||
final Color borderColor; // 边框线条颜色
|
||
|
||
const CustomRadarChart({
|
||
super.key,
|
||
required this.data,
|
||
this.maxValue = 1.3,
|
||
this.lineColor = const Color(0xFFE0E0E0), // 极淡的灰色,几乎看不见
|
||
this.areaColor = const Color(0x40FF69B4), // 半透明粉色
|
||
this.borderColor = const Color(0xFFEC407A), // 实线粉色
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
// 取宽高较小者作为绘图区域,保证是正方形
|
||
final size = math.min(constraints.maxWidth, constraints.maxHeight);
|
||
return SizedBox(
|
||
width: size,
|
||
height: size,
|
||
child: CustomPaint(
|
||
painter: _RadarPainter(
|
||
data: data,
|
||
maxValue: maxValue,
|
||
lineColor: lineColor,
|
||
areaColor: areaColor,
|
||
borderColor: borderColor,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _RadarPainter extends CustomPainter {
|
||
final Map<String, double> data;
|
||
final double maxValue;
|
||
final Color lineColor;
|
||
final Color areaColor;
|
||
final Color borderColor;
|
||
|
||
_RadarPainter({
|
||
required this.data,
|
||
required this.maxValue,
|
||
required this.lineColor,
|
||
required this.areaColor,
|
||
required this.borderColor,
|
||
});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final center = Offset(size.width / 2, size.height / 2);
|
||
// 半径留一点 padding,防止文字贴边
|
||
final radius = math.min(size.width, size.height) / 2 * 0.85;
|
||
final count = data.length;
|
||
|
||
if (count == 0) return;
|
||
|
||
final angleStep = (2 * math.pi) / count;
|
||
|
||
// --- 1. 绘制背景网格 (同心多边形) ---
|
||
final gridPaint = Paint()
|
||
..color = lineColor
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 1.0;
|
||
|
||
// 画 5 层网格 (0.2, 0.4, 0.6, 0.8, 1.0 比例)
|
||
for (int i = 1; i <= 5; i++) {
|
||
final levelRadius = radius * (i / 5);
|
||
final path = Path();
|
||
for (int j = 0; j < count; j++) {
|
||
final angle = j * angleStep - math.pi / 2; // -pi/2 让第一个点朝上
|
||
final x = center.dx + levelRadius * math.cos(angle);
|
||
final y = center.dy + levelRadius * math.sin(angle);
|
||
if (j == 0) {
|
||
path.moveTo(x, y);
|
||
} else {
|
||
path.lineTo(x, y);
|
||
}
|
||
}
|
||
path.close();
|
||
canvas.drawPath(path, gridPaint);
|
||
}
|
||
|
||
// --- 2. 绘制辐射轴线 (从中心到顶点) ---
|
||
// 如果你连这个轴线也不想要,可以注释掉这一段
|
||
for (int i = 0; i < count; i++) {
|
||
final angle = i * angleStep - math.pi / 2;
|
||
final x = center.dx + radius * math.cos(angle);
|
||
final y = center.dy + radius * math.sin(angle);
|
||
|
||
canvas.drawLine(
|
||
center,
|
||
Offset(x, y),
|
||
gridPaint, // 使用同样的淡色
|
||
);
|
||
}
|
||
|
||
// --- 3. 绘制数据区域 ---
|
||
final dataPath = Path();
|
||
final dataPoints = <Offset>[];
|
||
|
||
for (int i = 0; i < count; i++) {
|
||
final key = data.keys.elementAt(i);
|
||
final value = data[key] ?? 0.0;
|
||
|
||
// 计算当前值的比例半径
|
||
final ratio = (value / maxValue).clamp(0.0, 1.0);
|
||
final currentRadius = radius * ratio;
|
||
|
||
final angle = i * angleStep - math.pi / 2;
|
||
final x = center.dx + currentRadius * math.cos(angle);
|
||
final y = center.dy + currentRadius * math.sin(angle);
|
||
|
||
dataPoints.add(Offset(x, y));
|
||
|
||
if (i == 0) {
|
||
dataPath.moveTo(x, y);
|
||
} else {
|
||
dataPath.lineTo(x, y);
|
||
}
|
||
}
|
||
dataPath.close();
|
||
|
||
// 填充颜色
|
||
final fillPaint = Paint()
|
||
..color = areaColor
|
||
..style = PaintingStyle.fill;
|
||
canvas.drawPath(dataPath, fillPaint);
|
||
|
||
// 描边颜色
|
||
final borderPaint = Paint()
|
||
..color = borderColor
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 2.0
|
||
..strokeJoin = StrokeJoin.round; // 圆角连接,更美观
|
||
canvas.drawPath(dataPath, borderPaint);
|
||
|
||
// --- 4. 绘制顶点小圆点 (可选,增加精致感) ---
|
||
final dotPaint = Paint()
|
||
..color = Colors.white
|
||
..style = PaintingStyle.fill;
|
||
final dotBorderPaint = Paint()
|
||
..color = borderColor
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 1.5;
|
||
|
||
for (var point in dataPoints) {
|
||
canvas.drawCircle(point, 3.5, dotPaint);
|
||
canvas.drawCircle(point, 3.5, dotBorderPaint);
|
||
}
|
||
|
||
final textPainter = TextPainter(
|
||
textAlign: TextAlign.center,
|
||
textDirection: TextDirection.ltr,
|
||
);
|
||
|
||
for (int i = 0; i < count; i++) {
|
||
final key = data.keys.elementAt(i);
|
||
final angle = i * angleStep - math.pi / 2;
|
||
|
||
// 文字位置再往外扩一点
|
||
final labelRadius = radius + 20;
|
||
final x = center.dx + labelRadius * math.cos(angle);
|
||
final y = center.dy + labelRadius * math.sin(angle);
|
||
|
||
textPainter.text = TextSpan(
|
||
text: key,
|
||
style: const TextStyle(fontSize: 12,color: Colors.pinkAccent),
|
||
);
|
||
textPainter.layout();
|
||
|
||
// 修正文字居中
|
||
textPainter.paint(canvas, Offset(x - textPainter.width / 2, y - textPainter.height / 2));
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _RadarPainter oldDelegate) {
|
||
return oldDelegate.data != data || oldDelegate.maxValue != maxValue;
|
||
}
|
||
}
|
||
/// 最外层响应结构
|
||
class LuoxueResponse {
|
||
final bool success;
|
||
final int code;
|
||
final LuoxuePlayer data;
|
||
|
||
LuoxueResponse({
|
||
required this.success,
|
||
required this.code,
|
||
required this.data,
|
||
});
|
||
|
||
factory LuoxueResponse.fromJson(Map<String, dynamic> json) {
|
||
return LuoxueResponse(
|
||
success: json['success'] ?? false,
|
||
code: json['code'] ?? 0,
|
||
data: LuoxuePlayer.fromJson(json['data'] ?? {}),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 玩家信息
|
||
class LuoxuePlayer {
|
||
final String name;
|
||
final int rating;
|
||
final int friendCode;
|
||
final int courseRank;
|
||
final int classRank;
|
||
final int star;
|
||
final String uploadTime;
|
||
final LuoxueTrophy trophy;
|
||
final LuoxueIcon icon;
|
||
final LuoxueNamePlate namePlate;
|
||
final LuoxueFrame frame;
|
||
|
||
LuoxuePlayer({
|
||
required this.name,
|
||
required this.rating,
|
||
required this.friendCode,
|
||
required this.courseRank,
|
||
required this.classRank,
|
||
required this.star,
|
||
required this.uploadTime,
|
||
required this.trophy,
|
||
required this.icon,
|
||
required this.namePlate,
|
||
required this.frame,
|
||
});
|
||
|
||
factory LuoxuePlayer.fromJson(Map<String, dynamic> json) {
|
||
return LuoxuePlayer(
|
||
name: json['name'] ?? '',
|
||
rating: json['rating'] ?? 0,
|
||
friendCode: json['friend_code'] ?? 0,
|
||
courseRank: json['course_rank'] ?? 0,
|
||
classRank: json['class_rank'] ?? 0,
|
||
star: json['star'] ?? 0,
|
||
uploadTime: json['upload_time'] ?? '',
|
||
trophy: LuoxueTrophy.fromJson(json['trophy'] ?? {}),
|
||
icon: LuoxueIcon.fromJson(json['icon'] ?? {}),
|
||
namePlate: LuoxueNamePlate.fromJson(json['name_plate'] ?? {}),
|
||
frame: LuoxueFrame.fromJson(json['frame'] ?? {}),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 称号
|
||
class LuoxueTrophy {
|
||
final int id;
|
||
final String name;
|
||
final String genre;
|
||
final String color;
|
||
|
||
LuoxueTrophy({
|
||
required this.id,
|
||
required this.name,
|
||
required this.genre,
|
||
required this.color,
|
||
});
|
||
|
||
factory LuoxueTrophy.fromJson(Map<String, dynamic> json) {
|
||
return LuoxueTrophy(
|
||
id: json['id'] ?? 0,
|
||
name: json['name'] ?? '',
|
||
genre: json['genre'] ?? '',
|
||
color: json['color'] ?? '',
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 头像
|
||
class LuoxueIcon {
|
||
final int id;
|
||
final String name;
|
||
final String genre;
|
||
|
||
LuoxueIcon({
|
||
required this.id,
|
||
required this.name,
|
||
required this.genre,
|
||
});
|
||
|
||
factory LuoxueIcon.fromJson(Map<String, dynamic> json) {
|
||
return LuoxueIcon(
|
||
id: json['id'] ?? 0,
|
||
name: json['name'] ?? '',
|
||
genre: json['genre'] ?? '',
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 姓名框
|
||
class LuoxueNamePlate {
|
||
final int id;
|
||
final String name;
|
||
final String genre;
|
||
|
||
LuoxueNamePlate({
|
||
required this.id,
|
||
required this.name,
|
||
required this.genre,
|
||
});
|
||
|
||
factory LuoxueNamePlate.fromJson(Map<String, dynamic> json) {
|
||
return LuoxueNamePlate(
|
||
id: json['id'] ?? 0,
|
||
name: json['name'] ?? '',
|
||
genre: json['genre'] ?? '',
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 背景
|
||
class LuoxueFrame {
|
||
final int id;
|
||
final String name;
|
||
final String genre;
|
||
|
||
LuoxueFrame({
|
||
required this.id,
|
||
required this.name,
|
||
required this.genre,
|
||
});
|
||
|
||
factory LuoxueFrame.fromJson(Map<String, dynamic> json) {
|
||
return LuoxueFrame(
|
||
id: json['id'] ?? 0,
|
||
name: json['name'] ?? '',
|
||
genre: json['genre'] ?? '',
|
||
);
|
||
}
|
||
} |