diff --git a/lib/presentation/pages/settings/network_test_page.dart b/lib/presentation/pages/settings/network_test_page.dart new file mode 100644 index 0000000..905e31f --- /dev/null +++ b/lib/presentation/pages/settings/network_test_page.dart @@ -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 createState() => _NetworkTestPageState(); +} + +class _NetworkTestPageState extends State { + 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 _showCustomUnavailableDialog() async { + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('自定义线路'), + content: const Text('此功能暂不可用'), + actions: [ + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('好'), + ), + ], + ), + ); + } + + Future _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 _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; + if (data['success'] != false) { + final connection = data['connection']; + final connectionMap = connection is Map + ? connection + : {}; + 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; + 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 heartbeats, + }) { + final heartbeat = heartbeats[monitorId]; + return _ServiceItem( + label: label, + online: heartbeat?.online ?? false, + latencyMs: heartbeat?.pingMs, + ); + } + + Future> _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) return const {}; + final data = decoded['data']; + if (data is! Map) return const {}; + final heartbeatList = data['heartbeatList']; + if (heartbeatList is! Map) return const {}; + + final result = {}; + 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) 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 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 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 = []; + 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.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 _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 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, + }); +} diff --git a/lib/presentation/pages/settings/settings_page.dart b/lib/presentation/pages/settings/settings_page.dart index 8fa22cc..2e370ff 100644 --- a/lib/presentation/pages/settings/settings_page.dart +++ b/lib/presentation/pages/settings/settings_page.dart @@ -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'; /// 设置主页 @@ -83,112 +85,127 @@ class _SettingsPageState extends State { ), body: DesktopConstrained( child: ListView( - children: [ - _buildProfileCard(context, user, settings, capacity), - const SizedBox(height: 8), - _buildSection( - title: '账户与安全', - children: [ - _SettingsTile( - icon: Icons.person_outline, - title: '个人资料', - subtitle: '修改昵称、头像', - onTap: () => _navigateTo(context, const ProfileEditPage()), - ), - _SettingsTile( - icon: Icons.security_outlined, - title: '安全设置', - subtitle: _securitySubtitle(settings), - onTap: () => _navigateTo(context, const SecuritySettingsPage()), - ), - ], - ), - _buildSection( - title: '偏好', - children: [ - _SettingsTile( - icon: Icons.sync_outlined, - title: '文件同步', - subtitle: '本地与云端文件自动同步', - onTap: () => Navigator.of(context).pushNamed(RouteNames.syncSettings), - ), - _SettingsTile( - icon: Icons.apps_outlined, - title: '快捷入口', - subtitle: '自定义概览页快捷目录', - onTap: () => _navigateTo(context, const QuickAccessSettingsPage()), - ), - _SettingsTile( - icon: Icons.folder_outlined, - title: '文件偏好', - subtitle: '版本保留、视图同步、分享可见性', - onTap: () => _navigateTo(context, const FilePreferencesPage()), - ), - _SettingsTile( - icon: Icons.tune, - title: '应用设置', - subtitle: '缓存、主题、语言', - onTap: () => _navigateTo(context, const AppSettingsPage()), - ), - ], - ), - // 财务区域(有数据时才显示) - if (settings != null) ..._buildProSections(context, settings), - _buildSection( - title: '关于', - children: [ - ListTile( - leading: const Icon(Icons.info_outline), - title: const Text('应用名称'), - subtitle: const Text('公云存储'), - ), - ListTile( - leading: const Icon(Icons.tag), - title: const Text('版本号'), - subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion), - ), - ListTile( - leading: const Icon(Icons.copyright), - title: const Text('License'), - subtitle: const Text('AGPL-3.0'), - ), - ListTile( - leading: const Icon(Icons.code), - title: const Text('二次开发'), - subtitle: const Text('gongyun_app'), - trailing: const Icon(Icons.open_in_new, size: 16), - onTap: () { - launchUrl( - Uri.parse('https://git.saont.net/gongyun/app'), - mode: LaunchMode.externalApplication, - ); - }, - ), - ListTile( - leading: const Icon(Icons.code), - title: const Text('基于'), - subtitle: const Text('cloudreve4_flutter'), - trailing: const Icon(Icons.open_in_new, size: 16), - onTap: () { - launchUrl( - Uri.parse('https://github.com/LimoYuan/cloudreve4_flutter'), - mode: LaunchMode.externalApplication, - ); - }, - ), - ], - ), - const SizedBox(height: 24), - _buildLogoutButton(context, auth), - const SizedBox(height: 32), - ], - ), + children: [ + _buildProfileCard(context, user, settings, capacity), + const SizedBox(height: 8), + _buildSection( + title: '账户与安全', + children: [ + _SettingsTile( + icon: Icons.person_outline, + title: '个人资料', + subtitle: '修改昵称、头像', + onTap: () => _navigateTo(context, const ProfileEditPage()), + ), + _SettingsTile( + icon: Icons.security_outlined, + title: '安全设置', + subtitle: _securitySubtitle(settings), + onTap: () => + _navigateTo(context, const SecuritySettingsPage()), + ), + ], + ), + _buildSection( + title: '偏好', + children: [ + _SettingsTile( + icon: Icons.sync_outlined, + title: '文件同步', + subtitle: '本地与云端文件自动同步', + onTap: () => + Navigator.of(context).pushNamed(RouteNames.syncSettings), + ), + _SettingsTile( + icon: Icons.apps_outlined, + title: '快捷入口', + subtitle: '自定义概览页快捷目录', + onTap: () => + _navigateTo(context, const QuickAccessSettingsPage()), + ), + _SettingsTile( + icon: Icons.folder_outlined, + title: '文件偏好', + subtitle: '版本保留、视图同步、分享可见性', + onTap: () => + _navigateTo(context, const FilePreferencesPage()), + ), + _SettingsTile( + icon: Icons.tune, + title: '应用设置', + subtitle: '缓存、主题、语言', + onTap: () => _navigateTo(context, const AppSettingsPage()), + ), + _SettingsTile( + icon: Icons.network_check, + title: '网络测试', + subtitle: '服务连通性测试与自定义线路(会员)', + onTap: () => _openNetworkTest(context), + ), + ], + ), + // 财务区域(有数据时才显示) + if (settings != null) ..._buildProSections(context, settings), + _buildSection( + title: '关于', + children: [ + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('应用名称'), + subtitle: const Text('公云存储'), + ), + ListTile( + leading: const Icon(Icons.tag), + title: const Text('版本号'), + subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion), + ), + ListTile( + leading: const Icon(Icons.copyright), + title: const Text('License'), + subtitle: const Text('AGPL-3.0'), + ), + ListTile( + leading: const Icon(Icons.code), + title: const Text('二次开发'), + subtitle: const Text('gongyun_app'), + trailing: const Icon(Icons.open_in_new, size: 16), + onTap: () { + launchUrl( + Uri.parse('https://git.saont.net/gongyun/app'), + mode: LaunchMode.externalApplication, + ); + }, + ), + ListTile( + leading: const Icon(Icons.code), + title: const Text('基于'), + subtitle: const Text('cloudreve4_flutter'), + trailing: const Icon(Icons.open_in_new, size: 16), + onTap: () { + launchUrl( + Uri.parse( + 'https://github.com/LimoYuan/cloudreve4_flutter', + ), + mode: LaunchMode.externalApplication, + ); + }, + ), + ], + ), + const SizedBox(height: 24), + _buildLogoutButton(context, auth), + const SizedBox(height: 32), + ], + ), ), ); } /// 财务区域(存储包、积分、会员) - List _buildProSections(BuildContext context, UserSettingModel settings) { + List _buildProSections( + BuildContext context, + UserSettingModel settings, + ) { final sections = []; final hasStoragePacks = settings.storagePacks.isNotEmpty; final hasCredit = settings.credit > 0; @@ -197,40 +214,54 @@ class _SettingsPageState extends State { if (hasStoragePacks || hasCredit || hasMembership) { final children = []; if (hasMembership) { - children.add(ListTile( - leading: const Icon(Icons.workspace_premium_outlined), - title: const Text('会员'), - subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'), - trailing: TextButton( - onPressed: () => _cancelMembership(context), - child: const Text('取消会员', style: TextStyle(color: Colors.red)), + children.add( + ListTile( + leading: const Icon(Icons.workspace_premium_outlined), + title: const Text('会员'), + subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'), + trailing: TextButton( + onPressed: () => _cancelMembership(context), + child: const Text('取消会员', style: TextStyle(color: Colors.red)), + ), ), - )); + ); } if (hasStoragePacks) { - 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), - )); + 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( - 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)), - )); + 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), + ), + ), + ); } 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 { 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 { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('存储空间', style: theme.textTheme.bodySmall), - Text('$usedText / $totalText', style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - )), + Text( + '$usedText / $totalText', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), ], ), const SizedBox(height: 6), @@ -334,7 +375,10 @@ class _SettingsPageState extends State { ); } - Widget _buildSection({required String title, required List children}) { + Widget _buildSection({ + required String title, + required List children, + }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( @@ -366,11 +410,18 @@ class _SettingsPageState extends State { 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,33 +458,47 @@ class _SettingsPageState extends State { padding: const EdgeInsets.symmetric(vertical: 16), child: Text('存储包', style: Theme.of(ctx).textTheme.titleMedium), ), - ...packs.map((pack) => Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(pack.name, style: Theme.of(ctx).textTheme.titleSmall), - Text(_formatBytes(pack.size), style: Theme.of(ctx).textTheme.labelLarge), - ], - ), - const SizedBox(height: 4), - Text( - '激活: ${_formatDate(pack.activeSince)}' - '${pack.expireAt != null ? " · 到期: ${_formatDate(pack.expireAt!)}" : " · 永久"}', - style: Theme.of(ctx).textTheme.bodySmall, - ), - if (pack.isExpired) ...[ + ...packs.map( + (pack) => Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + pack.name, + style: Theme.of(ctx).textTheme.titleSmall, + ), + Text( + _formatBytes(pack.size), + style: Theme.of(ctx).textTheme.labelLarge, + ), + ], + ), const SizedBox(height: 4), - Text('已过期', style: TextStyle(color: Theme.of(ctx).colorScheme.error, fontSize: 12)), + Text( + '激活: ${_formatDate(pack.activeSince)}' + '${pack.expireAt != null ? " · 到期: ${_formatDate(pack.expireAt!)}" : " · 永久"}', + style: Theme.of(ctx).textTheme.bodySmall, + ), + if (pack.isExpired) ...[ + const SizedBox(height: 4), + Text( + '已过期', + style: TextStyle( + color: Theme.of(ctx).colorScheme.error, + fontSize: 12, + ), + ), + ], ], - ], + ), ), ), - )), + ), const SizedBox(height: 16), ], ), @@ -449,10 +514,15 @@ class _SettingsPageState extends State { 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 { } } + Future _openNetworkTest(BuildContext context) async { + final agreed = await showDialog( + 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 _confirmLogout(BuildContext context, AuthProvider auth) async { final confirmed = await showDialog( context: context, @@ -486,10 +592,15 @@ class _SettingsPageState extends State { 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 { 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 { } } +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; diff --git a/pubspec.yaml b/pubspec.yaml index 30eb572..c05611a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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