新增网络测试功能,预留付费用户线路自定义与线路自选入口 #8

Merged
gongyun merged 4 commits from dev into main 2026-05-31 10:23:10 +08:00
3 changed files with 1103 additions and 163 deletions
Showing only changes of commit 8dfc22691a - Show all commits
@@ -0,0 +1,758 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import '../../widgets/desktop_constrained.dart';
enum _NetworkTestTab { network, service, custom }
class NetworkTestPage extends StatefulWidget {
const NetworkTestPage({super.key});
@override
State<NetworkTestPage> createState() => _NetworkTestPageState();
}
class _NetworkTestPageState extends State<NetworkTestPage> {
static final _panUri = Uri.parse('https://pan.gongyun.org');
static final _infoUri = Uri.parse('https://www.gongyun.org/appnetinfo.html');
_NetworkTestTab _tab = _NetworkTestTab.network;
bool _networkLoading = false;
bool _serviceLoading = false;
_NetworkDiagnostics? _networkDiagnostics;
_ServiceDiagnostics? _serviceDiagnostics;
String? _networkError;
String? _serviceError;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('网络测试'),
actions: [
IconButton(
tooltip: '说明',
icon: const Icon(Icons.info_outline),
onPressed: () =>
launchUrl(_infoUri, mode: LaunchMode.externalApplication),
),
],
),
body: DesktopConstrained(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: SizedBox(
width: double.infinity,
child: SegmentedButton<_NetworkTestTab>(
segments: const [
ButtonSegment(
value: _NetworkTestTab.network,
icon: Icon(Icons.public),
label: Text('网络'),
),
ButtonSegment(
value: _NetworkTestTab.service,
icon: Icon(Icons.dns_outlined),
label: Text('服务'),
),
ButtonSegment(
value: _NetworkTestTab.custom,
icon: Icon(Icons.route_outlined),
label: Text('自定义'),
),
],
selected: {_tab},
onSelectionChanged: (selection) {
final selected = selection.first;
if (selected == _NetworkTestTab.custom) {
_showCustomUnavailableDialog();
return;
}
setState(() => _tab = selected);
if (selected == _NetworkTestTab.network &&
_networkDiagnostics == null &&
!_networkLoading) {
_runNetworkTest();
}
if (selected == _NetworkTestTab.service &&
_serviceDiagnostics == null &&
!_serviceLoading) {
_runServiceTest();
}
},
),
),
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: switch (_tab) {
_NetworkTestTab.network => _NetworkPanel(
key: const ValueKey('network'),
loading: _networkLoading,
error: _networkError,
diagnostics: _networkDiagnostics,
onRefresh: _runNetworkTest,
),
_NetworkTestTab.service => _ServicePanel(
key: const ValueKey('service'),
loading: _serviceLoading,
error: _serviceError,
diagnostics: _serviceDiagnostics,
onRefresh: _runServiceTest,
),
_NetworkTestTab.custom => const SizedBox.shrink(),
},
),
),
],
),
),
);
}
Future<void> _showCustomUnavailableDialog() async {
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('自定义线路'),
content: const Text('此功能暂不可用'),
actions: [
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text(''),
),
],
),
);
}
Future<void> _runNetworkTest() async {
setState(() {
_networkLoading = true;
_networkError = null;
});
try {
final ipFuture = _fetchIpInfo();
final panProbe = await _probeUri(_panUri);
final stability = await _testStability(_panUri);
final ipInfo = await ipFuture;
if (!mounted) return;
setState(() {
_networkDiagnostics = _NetworkDiagnostics(
panReachable: panProbe.reachable,
latencyMs: panProbe.latencyMs,
ipInfo: ipInfo,
stable: stability.stable,
sampleCount: stability.sampleCount,
successCount: stability.successCount,
);
});
} catch (e) {
if (mounted) setState(() => _networkError = e.toString());
} finally {
if (mounted) setState(() => _networkLoading = false);
}
}
Future<void> _runServiceTest() async {
setState(() {
_serviceLoading = true;
_serviceError = null;
});
try {
final monitorHeartbeats = await _fetchMonitorHeartbeats('gongyun');
_ServiceItem monitor(String label, int monitorId) {
return _monitorItem(
label: label,
monitorId: monitorId,
heartbeats: monitorHeartbeats,
);
}
final telegramBot = await _probeItem(
label: 'Telegram bot',
uri: Uri.parse('https://bot.mygongyun.com'),
healthyStatusCodes: const {200, 401},
);
final business = <_ServiceItem>[
monitor('官网', 3),
monitor('网盘', 2),
monitor('短链', 37),
monitor('SSO', 39),
telegramBot,
];
final dc = <_ServiceItem>[
monitor('DC3-CN-SZ', 13),
monitor('DC3-CN-MIAMI', 11),
monitor('DC3-CN-NY', 12),
monitor('DC3-CN-LV', 10),
];
final backend = <_ServiceItem>[monitor('数据库', 32), monitor('键值对数据库', 36)];
final storage = <_ServiceItem>[
monitor('DC-S-NA-1', 5),
monitor('DC-S-NA-2', 6),
monitor('DC-S-EU', 7),
monitor('DC-S-EP', 8),
];
final acceleration = <_ServiceItem>[
monitor('香港', 18),
monitor('香港', 19),
monitor('新加坡', 22),
monitor('洛杉矶', 24),
monitor('蒙特利尔', 21),
];
if (!mounted) return;
setState(() {
_serviceDiagnostics = _ServiceDiagnostics(
fetchedAt: DateTime.now(),
business: business,
dc: dc,
backend: backend,
storage: storage,
acceleration: acceleration,
);
});
} catch (e) {
if (mounted) setState(() => _serviceError = e.toString());
} finally {
if (mounted) setState(() => _serviceLoading = false);
}
}
Future<_IpInfo> _fetchIpInfo() async {
try {
final response = await http
.get(Uri.parse('https://ipwho.is/'))
.timeout(const Duration(seconds: 8));
if (response.statusCode >= 200 && response.statusCode < 300) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (data['success'] != false) {
final connection = data['connection'];
final connectionMap = connection is Map<String, dynamic>
? connection
: <String, dynamic>{};
return _IpInfo(
ip: data['ip']?.toString() ?? '未知',
asn: connectionMap['asn']?.toString() ?? '未知',
operatorName: connectionMap['isp']?.toString().isNotEmpty == true
? connectionMap['isp'].toString()
: (connectionMap['org']?.toString() ?? '未知'),
);
}
}
} catch (_) {
// Fall back to the next public endpoint.
}
try {
final response = await http
.get(Uri.parse('https://ipapi.co/json/'))
.timeout(const Duration(seconds: 8));
if (response.statusCode >= 200 && response.statusCode < 300) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return _IpInfo(
ip: data['ip']?.toString() ?? '未知',
asn: data['asn']?.toString() ?? '未知',
operatorName: data['org']?.toString() ?? '未知',
);
}
} catch (_) {
// Handled by the default value below.
}
return const _IpInfo(ip: '未知', asn: '未知', operatorName: '未知');
}
_ServiceItem _monitorItem({
required String label,
required int monitorId,
required Map<int, _MonitorHeartbeat> heartbeats,
}) {
final heartbeat = heartbeats[monitorId];
return _ServiceItem(
label: label,
online: heartbeat?.online ?? false,
latencyMs: heartbeat?.pingMs,
);
}
Future<Map<int, _MonitorHeartbeat>> _fetchMonitorHeartbeats(
String pageId,
) async {
try {
final response = await http
.get(
Uri.parse('https://status.gongyun.org/api/monitor?pageId=$pageId'),
headers: const {'Cache-Control': 'no-cache'},
)
.timeout(const Duration(seconds: 10));
if (response.statusCode < 200 || response.statusCode >= 300) {
return const {};
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) return const {};
final data = decoded['data'];
if (data is! Map<String, dynamic>) return const {};
final heartbeatList = data['heartbeatList'];
if (heartbeatList is! Map<String, dynamic>) return const {};
final result = <int, _MonitorHeartbeat>{};
for (final entry in heartbeatList.entries) {
final monitorId = int.tryParse(entry.key);
final records = entry.value;
if (monitorId == null || records is! List || records.isEmpty) continue;
final latest = records.last;
if (latest is! Map<String, dynamic>) continue;
final status = _asInt(latest['status']);
final ping = _asInt(latest['ping']);
result[monitorId] = _MonitorHeartbeat(
online: status == 1,
pingMs: ping,
);
}
return result;
} catch (_) {
return const {};
}
}
int? _asInt(Object? value) {
if (value is int) return value;
if (value is num) return value.round();
return int.tryParse(value?.toString() ?? '');
}
Future<_ServiceItem> _probeItem({
required String label,
required Uri uri,
Set<int> healthyStatusCodes = const {},
}) async {
final probe = await _probeUri(uri, healthyStatusCodes: healthyStatusCodes);
return _ServiceItem(
label: label,
online: probe.reachable,
latencyMs: probe.latencyMs,
);
}
Future<_ProbeResult> _probeUri(
Uri uri, {
Set<int> healthyStatusCodes = const {},
}) async {
final stopwatch = Stopwatch()..start();
try {
final response = await http
.get(uri, headers: const {'Cache-Control': 'no-cache'})
.timeout(const Duration(seconds: 8));
stopwatch.stop();
final isHealthy = healthyStatusCodes.isEmpty
? response.statusCode >= 200 && response.statusCode < 500
: healthyStatusCodes.contains(response.statusCode);
return _ProbeResult(
reachable: isHealthy,
latencyMs: stopwatch.elapsedMilliseconds,
);
} catch (_) {
stopwatch.stop();
return const _ProbeResult(reachable: false);
}
}
Future<_StabilityResult> _testStability(Uri uri) async {
final latencies = <int>[];
var successCount = 0;
const sampleCount = 3;
for (var i = 0; i < sampleCount; i++) {
final probe = await _probeUri(uri);
if (probe.reachable) {
successCount += 1;
if (probe.latencyMs != null) latencies.add(probe.latencyMs!);
}
if (i < sampleCount - 1) {
await Future<void>.delayed(const Duration(milliseconds: 350));
}
}
final jitter = latencies.isEmpty
? 999999
: latencies.reduce(max) - latencies.reduce(min);
return _StabilityResult(
stable: successCount == sampleCount && jitter < 1500,
sampleCount: sampleCount,
successCount: successCount,
);
}
}
class _NetworkPanel extends StatelessWidget {
final bool loading;
final String? error;
final _NetworkDiagnostics? diagnostics;
final VoidCallback onRefresh;
const _NetworkPanel({
super.key,
required this.loading,
required this.error,
required this.diagnostics,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final data = diagnostics;
final errorColor = Theme.of(context).colorScheme.error;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
children: [
_SectionCard(
title: '网络',
children: [
if (loading) const LinearProgressIndicator(),
if (error != null) _ErrorText(error!),
_InfoRow(
label: '公云存储官网访问测试',
value: data == null ? '检测中' : (data.panReachable ? '正常' : '离线'),
valueColor: _statusColor(context, data?.panReachable),
),
_InfoRow(label: '当前网络 IP 地址', value: data?.ipInfo.ip ?? '检测中'),
_InfoRow(label: 'IP 归属 AS', value: data?.ipInfo.asn ?? '检测中'),
_InfoRow(label: '运营商', value: data?.ipInfo.operatorName ?? '检测中'),
_InfoRow(
label: '连接时延',
value: data?.latencyMs == null ? '检测中' : '${data!.latencyMs} ms',
),
_InfoRow(
label: '长期连接稳定性',
value: data == null
? '检测中'
: '${data.stable ? '稳定' : '不稳定'} '
'(${data.successCount}/${data.sampleCount})',
valueColor: _statusColor(context, data?.stable),
),
],
),
_SectionCard(
title: '说明',
children: const [
_DescriptionLine('公云存储作为国际化云存储产品,已接入Cloudflare CDN作为全球加速引擎。'),
_DescriptionLine(
'由于部分国家/地区有法律法规限制与审查,可能会出现包括但不限于网络波动、网络丢包以及网络连接不稳定现象',
),
_DescriptionLine(
'如诺出现网络连接问题您可将本页面内容向公云存储团队进行报告,团队会根据相关诊断线索进行排查与优化。',
),
],
),
_SectionCard(
title: '警告',
titleColor: errorColor,
children: [
Text(
'本页面内容涉及你本人敏感信息请勿提供给非公云存储团队人员,以避免你本人相关信息泄露!',
style: TextStyle(color: errorColor, fontWeight: FontWeight.w700),
),
],
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: loading ? null : onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('重新检测'),
),
],
);
}
}
class _ServicePanel extends StatelessWidget {
final bool loading;
final String? error;
final _ServiceDiagnostics? diagnostics;
final VoidCallback onRefresh;
const _ServicePanel({
super.key,
required this.loading,
required this.error,
required this.diagnostics,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final data = diagnostics;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
children: [
_SectionCard(
title: '业务状态',
children: [
if (loading) const LinearProgressIndicator(),
if (error != null) _ErrorText(error!),
..._itemsOrLoading(data?.business),
],
),
_SectionCard(title: '离线下载与计算节点', children: _itemsOrLoading(data?.dc)),
_SectionCard(title: '后端', children: _itemsOrLoading(data?.backend)),
_SectionCard(title: '存储节点', children: _itemsOrLoading(data?.storage)),
_SectionCard(
title: '加速线路',
children: _itemsOrLoading(data?.acceleration, showLatency: true),
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: loading ? null : onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('重新检测'),
),
],
);
}
List<Widget> _itemsOrLoading(
List<_ServiceItem>? items, {
bool showLatency = false,
}) {
if (items == null) {
return const [_InfoRow(label: '状态', value: '检测中')];
}
return items
.map(
(item) => _InfoRow(
label: item.label,
value: showLatency
? item.latencyLabel
: (item.online ? '正常' : '离线'),
valueColor: item.online ? Colors.green : Colors.red,
),
)
.toList();
}
}
class _SectionCard extends StatelessWidget {
final String title;
final List<Widget> children;
final Color? titleColor;
const _SectionCard({
required this.title,
required this.children,
this.titleColor,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: titleColor,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
...children,
],
),
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
final Color? valueColor;
const _InfoRow({required this.label, required this.value, this.valueColor});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: Text(label, style: theme.textTheme.bodyMedium)),
const SizedBox(width: 16),
Flexible(
child: Text(
value,
textAlign: TextAlign.right,
style: theme.textTheme.bodyMedium?.copyWith(
color: valueColor,
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}
class _DescriptionLine extends StatelessWidget {
final String text;
const _DescriptionLine(this.text);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Text(text),
);
}
}
class _ErrorText extends StatelessWidget {
final String error;
const _ErrorText(this.error);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'检测失败:$error',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
);
}
}
Color? _statusColor(BuildContext context, bool? value) {
if (value == null) return null;
return value ? Colors.green : Theme.of(context).colorScheme.error;
}
class _NetworkDiagnostics {
final bool panReachable;
final int? latencyMs;
final _IpInfo ipInfo;
final bool stable;
final int sampleCount;
final int successCount;
const _NetworkDiagnostics({
required this.panReachable,
required this.latencyMs,
required this.ipInfo,
required this.stable,
required this.sampleCount,
required this.successCount,
});
}
class _IpInfo {
final String ip;
final String asn;
final String operatorName;
const _IpInfo({
required this.ip,
required this.asn,
required this.operatorName,
});
}
class _ServiceDiagnostics {
final DateTime fetchedAt;
final List<_ServiceItem> business;
final List<_ServiceItem> dc;
final List<_ServiceItem> backend;
final List<_ServiceItem> storage;
final List<_ServiceItem> acceleration;
const _ServiceDiagnostics({
required this.fetchedAt,
required this.business,
required this.dc,
required this.backend,
required this.storage,
required this.acceleration,
});
}
class _ServiceItem {
final String label;
final bool online;
final int? latencyMs;
const _ServiceItem({
required this.label,
required this.online,
this.latencyMs,
});
String get latencyLabel {
if (!online) return '离线';
if (latencyMs == null) return '正常 / -- ms';
return '正常 / $latencyMs ms';
}
}
class _MonitorHeartbeat {
final bool online;
final int? pingMs;
const _MonitorHeartbeat({required this.online, this.pingMs});
}
class _ProbeResult {
final bool reachable;
final int? latencyMs;
const _ProbeResult({required this.reachable, this.latencyMs});
}
class _StabilityResult {
final bool stable;
final int sampleCount;
final int successCount;
const _StabilityResult({
required this.stable,
required this.sampleCount,
required this.successCount,
});
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -16,6 +17,7 @@ import 'file_preferences_page.dart';
import 'app_settings_page.dart';
import 'credit_history_page.dart';
import 'quick_access_settings_page.dart';
import 'network_test_page.dart';
import '../../../router/app_router.dart';
/// 设置主页
@@ -99,7 +101,8 @@ class _SettingsPageState extends State<SettingsPage> {
icon: Icons.security_outlined,
title: '安全设置',
subtitle: _securitySubtitle(settings),
onTap: () => _navigateTo(context, const SecuritySettingsPage()),
onTap: () =>
_navigateTo(context, const SecuritySettingsPage()),
),
],
),
@@ -110,19 +113,22 @@ class _SettingsPageState extends State<SettingsPage> {
icon: Icons.sync_outlined,
title: '文件同步',
subtitle: '本地与云端文件自动同步',
onTap: () => Navigator.of(context).pushNamed(RouteNames.syncSettings),
onTap: () =>
Navigator.of(context).pushNamed(RouteNames.syncSettings),
),
_SettingsTile(
icon: Icons.apps_outlined,
title: '快捷入口',
subtitle: '自定义概览页快捷目录',
onTap: () => _navigateTo(context, const QuickAccessSettingsPage()),
onTap: () =>
_navigateTo(context, const QuickAccessSettingsPage()),
),
_SettingsTile(
icon: Icons.folder_outlined,
title: '文件偏好',
subtitle: '版本保留、视图同步、分享可见性',
onTap: () => _navigateTo(context, const FilePreferencesPage()),
onTap: () =>
_navigateTo(context, const FilePreferencesPage()),
),
_SettingsTile(
icon: Icons.tune,
@@ -130,6 +136,12 @@ class _SettingsPageState extends State<SettingsPage> {
subtitle: '缓存、主题、语言',
onTap: () => _navigateTo(context, const AppSettingsPage()),
),
_SettingsTile(
icon: Icons.network_check,
title: '网络测试',
subtitle: '服务连通性测试与自定义线路(会员)',
onTap: () => _openNetworkTest(context),
),
],
),
// 财务区域(有数据时才显示)
@@ -171,7 +183,9 @@ class _SettingsPageState extends State<SettingsPage> {
trailing: const Icon(Icons.open_in_new, size: 16),
onTap: () {
launchUrl(
Uri.parse('https://github.com/LimoYuan/cloudreve4_flutter'),
Uri.parse(
'https://github.com/LimoYuan/cloudreve4_flutter',
),
mode: LaunchMode.externalApplication,
);
},
@@ -188,7 +202,10 @@ class _SettingsPageState extends State<SettingsPage> {
}
/// 财务区域(存储包、积分、会员)
List<Widget> _buildProSections(BuildContext context, UserSettingModel settings) {
List<Widget> _buildProSections(
BuildContext context,
UserSettingModel settings,
) {
final sections = <Widget>[];
final hasStoragePacks = settings.storagePacks.isNotEmpty;
final hasCredit = settings.credit > 0;
@@ -197,7 +214,8 @@ class _SettingsPageState extends State<SettingsPage> {
if (hasStoragePacks || hasCredit || hasMembership) {
final children = <Widget>[];
if (hasMembership) {
children.add(ListTile(
children.add(
ListTile(
leading: const Icon(Icons.workspace_premium_outlined),
title: const Text('会员'),
subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'),
@@ -205,32 +223,45 @@ class _SettingsPageState extends State<SettingsPage> {
onPressed: () => _cancelMembership(context),
child: const Text('取消会员', style: TextStyle(color: Colors.red)),
),
));
),
);
}
if (hasStoragePacks) {
children.add(ListTile(
children.add(
ListTile(
leading: const Icon(Icons.inventory_2_outlined),
title: Text('存储包 (${settings.storagePacks.length})'),
subtitle: Text(_storagePackSummary(settings.storagePacks)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showStoragePacks(context, settings.storagePacks),
));
),
);
}
if (hasCredit) {
children.add(ListTile(
children.add(
ListTile(
leading: const Icon(Icons.account_balance_wallet_outlined),
title: const Text('积分'),
subtitle: Text('${settings.credit} 积分'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _navigateTo(context, CreditHistoryPage(currentCredit: settings.credit)),
));
onTap: () => _navigateTo(
context,
CreditHistoryPage(currentCredit: settings.credit),
),
),
);
}
sections.add(_buildSection(title: '财务', children: children));
}
return sections;
}
Widget _buildProfileCard(BuildContext context, UserModel? user, UserSettingModel? settings, UserCapacityModel? capacity) {
Widget _buildProfileCard(
BuildContext context,
UserModel? user,
UserSettingModel? settings,
UserCapacityModel? capacity,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
@@ -253,17 +284,24 @@ class _SettingsPageState extends State<SettingsPage> {
children: [
Text(
user?.nickname ?? '未登录',
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
user?.email ?? '',
style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (user?.group != null) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
@@ -316,9 +354,12 @@ class _SettingsPageState extends State<SettingsPage> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('存储空间', style: theme.textTheme.bodySmall),
Text('$usedText / $totalText', style: theme.textTheme.bodySmall?.copyWith(
Text(
'$usedText / $totalText',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
)),
),
),
],
),
const SizedBox(height: 6),
@@ -334,7 +375,10 @@ class _SettingsPageState extends State<SettingsPage> {
);
}
Widget _buildSection({required String title, required List<Widget> children}) {
Widget _buildSection({
required String title,
required List<Widget> children,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
@@ -366,11 +410,18 @@ class _SettingsPageState extends State<SettingsPage> {
child: OutlinedButton.icon(
onPressed: () => _confirmLogout(context, auth),
icon: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
label: Text('退出登录', style: TextStyle(color: Theme.of(context).colorScheme.error)),
label: Text(
'退出登录',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
side: BorderSide(color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
side: BorderSide(
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
@@ -407,7 +458,8 @@ class _SettingsPageState extends State<SettingsPage> {
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text('存储包', style: Theme.of(ctx).textTheme.titleMedium),
),
...packs.map((pack) => Card(
...packs.map(
(pack) => Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
@@ -416,8 +468,14 @@ class _SettingsPageState extends State<SettingsPage> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(pack.name, style: Theme.of(ctx).textTheme.titleSmall),
Text(_formatBytes(pack.size), style: Theme.of(ctx).textTheme.labelLarge),
Text(
pack.name,
style: Theme.of(ctx).textTheme.titleSmall,
),
Text(
_formatBytes(pack.size),
style: Theme.of(ctx).textTheme.labelLarge,
),
],
),
const SizedBox(height: 4),
@@ -428,12 +486,19 @@ class _SettingsPageState extends State<SettingsPage> {
),
if (pack.isExpired) ...[
const SizedBox(height: 4),
Text('已过期', style: TextStyle(color: Theme.of(ctx).colorScheme.error, fontSize: 12)),
Text(
'已过期',
style: TextStyle(
color: Theme.of(ctx).colorScheme.error,
fontSize: 12,
),
),
],
],
),
),
)),
),
),
const SizedBox(height: 16),
],
),
@@ -449,10 +514,15 @@ class _SettingsPageState extends State<SettingsPage> {
title: const Text('取消会员'),
content: const Text('确定要取消当前会员吗?取消后将失去会员权益。'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
child: const Text('确认取消'),
),
],
@@ -479,6 +549,42 @@ class _SettingsPageState extends State<SettingsPage> {
}
}
Future<void> _openNetworkTest(BuildContext context) async {
final agreed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => AlertDialog(
title: const Text('会话信息授权'),
content: _NetworkTestConsentText(
onOpenPrivacy: () => launchUrl(
Uri.parse('https://www.gongyun.org/policy/privacy.html'),
mode: LaunchMode.externalApplication,
),
onOpenTerms: () => launchUrl(
Uri.parse('https://www.gongyun.org/policy/terms.html'),
mode: LaunchMode.externalApplication,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('不同意'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('同意'),
),
],
),
);
if (agreed == true && context.mounted) {
await Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const NetworkTestPage()));
}
}
Future<void> _confirmLogout(BuildContext context, AuthProvider auth) async {
final confirmed = await showDialog<bool>(
context: context,
@@ -486,10 +592,15 @@ class _SettingsPageState extends State<SettingsPage> {
title: const Text('退出登录'),
content: const Text('确定要退出当前账号吗?'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('退出'),
),
],
@@ -506,7 +617,9 @@ class _SettingsPageState extends State<SettingsPage> {
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
@@ -515,6 +628,75 @@ class _SettingsPageState extends State<SettingsPage> {
}
}
class _NetworkTestConsentText extends StatefulWidget {
final VoidCallback onOpenPrivacy;
final VoidCallback onOpenTerms;
const _NetworkTestConsentText({
required this.onOpenPrivacy,
required this.onOpenTerms,
});
@override
State<_NetworkTestConsentText> createState() =>
_NetworkTestConsentTextState();
}
class _NetworkTestConsentTextState extends State<_NetworkTestConsentText> {
late final TapGestureRecognizer _privacyRecognizer;
late final TapGestureRecognizer _termsRecognizer;
@override
void initState() {
super.initState();
_privacyRecognizer = TapGestureRecognizer()..onTap = widget.onOpenPrivacy;
_termsRecognizer = TapGestureRecognizer()..onTap = widget.onOpenTerms;
}
@override
void didUpdateWidget(covariant _NetworkTestConsentText oldWidget) {
super.didUpdateWidget(oldWidget);
_privacyRecognizer.onTap = widget.onOpenPrivacy;
_termsRecognizer.onTap = widget.onOpenTerms;
}
@override
void dispose() {
_privacyRecognizer.dispose();
_termsRecognizer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final linkStyle = TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
);
return RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium,
children: [
const TextSpan(text: '您需同意'),
TextSpan(
text: '隐私政策',
style: linkStyle,
recognizer: _privacyRecognizer,
),
const TextSpan(text: ''),
TextSpan(
text: '用户协议',
style: linkStyle,
recognizer: _termsRecognizer,
),
const TextSpan(text: '方可进入网络测试页。'),
],
),
);
}
}
class _SettingsTile extends StatelessWidget {
final IconData icon;
final String title;
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.3.4+4
version: 1.3.5+5
environment:
sdk: ^3.11.4