Compare commits
4 Commits
v1.3.4
...
edcde96051
| Author | SHA1 | Date | |
|---|---|---|---|
| edcde96051 | |||
|
5ee6ba1e28
|
|||
|
61ad85f6fc
|
|||
|
8dfc22691a
|
@@ -39,4 +39,8 @@ class StorageKeys {
|
||||
// 公告
|
||||
static const String siteAnnouncementDismissedFingerprint =
|
||||
'site_announcement_dismissed_fingerprint';
|
||||
|
||||
// 版本更新
|
||||
static const String updatePromptSkipUntil = 'update_prompt_skip_until';
|
||||
static const String updatePromptSkipVersion = 'update_prompt_skip_version';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
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';
|
||||
import '../../widgets/toast_helper.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');
|
||||
static final _primaryIpLookupUri = Uri.parse('https://ipwho.is/');
|
||||
static final _secondaryIpLookupUri = Uri.parse('https://ipapi.co/json/');
|
||||
static const _probeHeaders = {'Cache-Control': 'no-cache'};
|
||||
static const _probeGetFallbackHeaders = {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Range': 'bytes=0-0',
|
||||
};
|
||||
|
||||
_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: () => _openExternalUrl(_infoUri),
|
||||
),
|
||||
],
|
||||
),
|
||||
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> _openExternalUrl(Uri uri) async {
|
||||
try {
|
||||
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!opened) {
|
||||
ToastHelper.failure('无法打开链接: $uri');
|
||||
}
|
||||
} catch (e) {
|
||||
ToastHelper.failure('无法打开链接: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runNetworkTest() async {
|
||||
if (_networkLoading) return;
|
||||
|
||||
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 {
|
||||
if (_serviceLoading) return;
|
||||
|
||||
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(_primaryIpLookupUri)
|
||||
.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(_secondaryIpLookupUri)
|
||||
.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: _probeHeaders,
|
||||
)
|
||||
.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 {
|
||||
var response = await http
|
||||
.head(uri, headers: _probeHeaders)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
if (_shouldRetryProbeWithGet(response.statusCode)) {
|
||||
response = await http
|
||||
.get(uri, headers: _probeGetFallbackHeaders)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
}
|
||||
stopwatch.stop();
|
||||
final isHealthy = _isHealthyStatus(
|
||||
response.statusCode,
|
||||
healthyStatusCodes,
|
||||
);
|
||||
return _ProbeResult(
|
||||
reachable: isHealthy,
|
||||
latencyMs: stopwatch.elapsedMilliseconds,
|
||||
);
|
||||
} catch (_) {
|
||||
stopwatch.stop();
|
||||
return const _ProbeResult(reachable: false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldRetryProbeWithGet(int statusCode) {
|
||||
return statusCode == 403 || statusCode == 405 || statusCode == 501;
|
||||
}
|
||||
|
||||
bool _isHealthyStatus(int statusCode, Set<int> healthyStatusCodes) {
|
||||
if (healthyStatusCodes.isNotEmpty) {
|
||||
return healthyStatusCodes.contains(statusCode);
|
||||
}
|
||||
return statusCode >= 200 && statusCode < 500;
|
||||
}
|
||||
|
||||
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(
|
||||
'当前网络 IP 地址、AS 与运营商信息通过第三方服务 ipwho.is 查询;如该服务不可用,将使用 ipapi.co 作为备用查询。',
|
||||
),
|
||||
_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,9 +1,11 @@
|
||||
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';
|
||||
import '../../../data/models/user_model.dart';
|
||||
import '../../../data/models/user_setting_model.dart';
|
||||
import '../../../services/update_service.dart';
|
||||
import '../../../services/user_setting_service.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/user_setting_provider.dart';
|
||||
@@ -16,6 +18,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 +86,121 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
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('公云存储'),
|
||||
),
|
||||
_buildVersionTile(),
|
||||
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: () {
|
||||
_openExternalUrl(
|
||||
Uri.parse('https://git.saont.net/gongyun/app'),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.code),
|
||||
title: const Text('基于'),
|
||||
subtitle: const Text('cloudreve4_flutter'),
|
||||
trailing: const Icon(Icons.open_in_new, size: 16),
|
||||
onTap: () {
|
||||
_openExternalUrl(
|
||||
Uri.parse(
|
||||
'https://github.com/LimoYuan/cloudreve4_flutter',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildLogoutButton(context, auth),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 财务区域(存储包、积分、会员)
|
||||
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,40 +209,54 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
if (hasStoragePacks || hasCredit || hasMembership) {
|
||||
final children = <Widget>[];
|
||||
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 +279,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 +349,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
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 +370,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(
|
||||
@@ -360,17 +399,43 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVersionTile() {
|
||||
return AnimatedBuilder(
|
||||
animation: UpdateService.instance,
|
||||
builder: (context, _) {
|
||||
final hasUpdate = UpdateService.instance.hasUpdate;
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
hasUpdate ? Icons.new_releases_outlined : Icons.tag,
|
||||
color: hasUpdate ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
title: const Text('版本号'),
|
||||
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion),
|
||||
onTap: hasUpdate ? () => _openDownloadsPage() : null,
|
||||
trailing: hasUpdate ? const _UpdateAvailableBadge() : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogoutButton(BuildContext context, AuthProvider auth) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
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 +472,47 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
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 +528,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 +563,59 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openExternalUrl(Uri uri) async {
|
||||
try {
|
||||
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!opened) {
|
||||
ToastHelper.failure('无法打开链接: $uri');
|
||||
}
|
||||
} catch (e) {
|
||||
ToastHelper.failure('无法打开链接: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openDownloadsPage() async {
|
||||
try {
|
||||
await UpdateService.instance.openDownloadsPage();
|
||||
} catch (e) {
|
||||
ToastHelper.failure('打开下载页面失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openNetworkTest(BuildContext context) async {
|
||||
final agreed = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('会话信息授权'),
|
||||
content: _NetworkTestConsentText(
|
||||
onOpenPrivacy: () => _openExternalUrl(
|
||||
Uri.parse('https://www.gongyun.org/policy/privacy.html'),
|
||||
),
|
||||
onOpenTerms: () => _openExternalUrl(
|
||||
Uri.parse('https://www.gongyun.org/policy/terms.html'),
|
||||
),
|
||||
),
|
||||
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 +623,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 +648,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 +659,140 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _UpdateAvailableBadge extends StatefulWidget {
|
||||
const _UpdateAvailableBadge();
|
||||
|
||||
@override
|
||||
State<_UpdateAvailableBadge> createState() => _UpdateAvailableBadgeState();
|
||||
}
|
||||
|
||||
class _UpdateAvailableBadgeState extends State<_UpdateAvailableBadge>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _opacity;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..repeat(reverse: true);
|
||||
_opacity = Tween<double>(
|
||||
begin: 0.3,
|
||||
end: 1,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return FadeTransition(
|
||||
opacity: _opacity,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'发现新版本',
|
||||
style: TextStyle(
|
||||
color: colorScheme.error,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.chevron_right, size: 18, color: colorScheme.error),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
const TextSpan(text: '您需同意'),
|
||||
TextSpan(
|
||||
text: '隐私政策',
|
||||
style: linkStyle,
|
||||
recognizer: _privacyRecognizer,
|
||||
),
|
||||
const TextSpan(text: '与'),
|
||||
TextSpan(
|
||||
text: '用户协议',
|
||||
style: linkStyle,
|
||||
recognizer: _termsRecognizer,
|
||||
),
|
||||
const TextSpan(
|
||||
text:
|
||||
'方可进入网络测试页。测试会访问公云存储服务,并通过第三方服务 ipwho.is 查询当前网络 IP、AS 与运营商信息;如 ipwho.is 不可用,将使用 ipapi.co 作为备用查询。',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../data/models/app_update_model.dart';
|
||||
@@ -31,6 +29,8 @@ class _UpdatePromptState extends State<UpdatePrompt> {
|
||||
|
||||
final update = await UpdateService.instance.checkForUpdate();
|
||||
if (update == null || !mounted) return;
|
||||
final shouldShow = await UpdateService.instance.shouldShowPrompt(update);
|
||||
if (!shouldShow || !mounted) return;
|
||||
|
||||
_dialogVisible = true;
|
||||
try {
|
||||
@@ -41,98 +41,55 @@ class _UpdatePromptState extends State<UpdatePrompt> {
|
||||
}
|
||||
|
||||
Future<void> _showUpdateDialog(AppUpdateInfo update) async {
|
||||
var downloading = false;
|
||||
var progress = 0.0;
|
||||
String? status;
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
final canDownload =
|
||||
update.downloadUrl != null &&
|
||||
(Platform.isAndroid || Platform.isWindows);
|
||||
Future<void> openDownloadsPage() async {
|
||||
try {
|
||||
await UpdateService.instance.openDownloadsPage();
|
||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||
} catch (e) {
|
||||
ToastHelper.failure('打开下载页面失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startUpdate() async {
|
||||
if (downloading || !canDownload) return;
|
||||
Future<void> skipPrompt() async {
|
||||
await UpdateService.instance.skipPromptForFiveDays(update);
|
||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||
}
|
||||
|
||||
setDialogState(() {
|
||||
downloading = true;
|
||||
progress = 0;
|
||||
status = '正在下载更新...';
|
||||
});
|
||||
final releaseNotes = (update.description ?? '').trim();
|
||||
|
||||
try {
|
||||
final file = await UpdateService.instance.downloadUpdate(
|
||||
update,
|
||||
onProgress: (received, total) {
|
||||
if (!mounted || total <= 0) return;
|
||||
setDialogState(() {
|
||||
progress = received / total;
|
||||
});
|
||||
},
|
||||
);
|
||||
if (file == null) return;
|
||||
|
||||
setDialogState(() {
|
||||
progress = 1;
|
||||
status = '下载完成,正在打开安装包...';
|
||||
});
|
||||
await UpdateService.instance.openInstaller(file);
|
||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||
} catch (e) {
|
||||
setDialogState(() {
|
||||
downloading = false;
|
||||
status = '下载或打开安装包失败';
|
||||
});
|
||||
ToastHelper.failure('更新失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('发现新版本 ${update.version}'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(update.title),
|
||||
if ((update.description ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
update.description!,
|
||||
maxLines: 6,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (downloading) ...[
|
||||
const SizedBox(height: 16),
|
||||
LinearProgressIndicator(
|
||||
value: progress == 0 ? null : progress,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(status ?? ''),
|
||||
],
|
||||
],
|
||||
),
|
||||
return AlertDialog(
|
||||
title: Text('发现新版本 ${update.version}'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 360),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('新版本号:${update.version}'),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'版本日志',
|
||||
style: Theme.of(dialogContext).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(releaseNotes.isEmpty ? update.title : releaseNotes),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: downloading
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('稍后'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: downloading ? null : startUpdate,
|
||||
child: const Text('立即更新'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: skipPrompt, child: const Text('跳过')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(onPressed: openDownloadsPage, child: const Text('更新')),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,11 +9,22 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import '../core/constants/storage_keys.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
import '../data/models/app_update_model.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
class UpdateService {
|
||||
UpdateService._();
|
||||
class UpdateService extends ChangeNotifier {
|
||||
UpdateService._()
|
||||
: _dio = Dio(_dioOptions),
|
||||
_packageInfoProvider = PackageInfo.fromPlatform;
|
||||
|
||||
@visibleForTesting
|
||||
UpdateService.test({
|
||||
Dio? dio,
|
||||
Future<PackageInfo> Function()? packageInfoProvider,
|
||||
}) : _dio = dio ?? Dio(_dioOptions),
|
||||
_packageInfoProvider = packageInfoProvider ?? PackageInfo.fromPlatform;
|
||||
|
||||
static final UpdateService instance = UpdateService._();
|
||||
|
||||
@@ -23,28 +34,39 @@ class UpdateService {
|
||||
static final Uri releasesPageUrl = Uri.parse(
|
||||
'https://git.saont.net/gongyun/app/releases',
|
||||
);
|
||||
|
||||
final Dio _dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 12),
|
||||
receiveTimeout: const Duration(seconds: 20),
|
||||
followRedirects: true,
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
static final Uri downloadsPageUrl = Uri.parse(
|
||||
'https://www.gongyun.org/downloads.html',
|
||||
);
|
||||
|
||||
static final BaseOptions _dioOptions = BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 12),
|
||||
receiveTimeout: const Duration(seconds: 20),
|
||||
followRedirects: true,
|
||||
responseType: ResponseType.plain,
|
||||
);
|
||||
|
||||
final Dio _dio;
|
||||
final Future<PackageInfo> Function() _packageInfoProvider;
|
||||
|
||||
bool _checking = false;
|
||||
AppUpdateInfo? _availableUpdate;
|
||||
|
||||
AppUpdateInfo? get availableUpdate => _availableUpdate;
|
||||
bool get hasUpdate => _availableUpdate != null;
|
||||
|
||||
Future<AppUpdateInfo?> checkForUpdate() async {
|
||||
if (_checking) return null;
|
||||
if (!_supportsAutoDownload) return null;
|
||||
|
||||
_checking = true;
|
||||
try {
|
||||
final current = await PackageInfo.fromPlatform();
|
||||
final current = await _packageInfoProvider();
|
||||
final latest = await _fetchLatestRelease(currentVersion: current.version);
|
||||
if (latest == null) return null;
|
||||
if (_compareVersions(latest.version, current.version) <= 0) return null;
|
||||
if (latest == null ||
|
||||
_compareVersions(latest.version, current.version) <= 0) {
|
||||
_setAvailableUpdate(null);
|
||||
return null;
|
||||
}
|
||||
_setAvailableUpdate(latest);
|
||||
return latest;
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('检查更新失败: $e\n$stackTrace');
|
||||
@@ -54,6 +76,34 @@ class UpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> shouldShowPrompt(AppUpdateInfo update) async {
|
||||
final storage = StorageService.instance;
|
||||
final skipVersion = await storage.getString(
|
||||
StorageKeys.updatePromptSkipVersion,
|
||||
);
|
||||
final skipUntil = await storage.getInt(StorageKeys.updatePromptSkipUntil);
|
||||
if (skipVersion != update.version || skipUntil == null) return true;
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (skipUntil > now) return false;
|
||||
|
||||
await storage.remove(StorageKeys.updatePromptSkipUntil);
|
||||
await storage.remove(StorageKeys.updatePromptSkipVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> skipPromptForFiveDays(AppUpdateInfo update) async {
|
||||
final skipUntil = DateTime.now()
|
||||
.add(const Duration(days: 5))
|
||||
.millisecondsSinceEpoch;
|
||||
final storage = StorageService.instance;
|
||||
await storage.setString(
|
||||
StorageKeys.updatePromptSkipVersion,
|
||||
update.version,
|
||||
);
|
||||
await storage.setInt(StorageKeys.updatePromptSkipUntil, skipUntil);
|
||||
}
|
||||
|
||||
Future<File?> downloadUpdate(
|
||||
AppUpdateInfo update, {
|
||||
void Function(int received, int total)? onProgress,
|
||||
@@ -96,6 +146,15 @@ class UpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openDownloadsPage() async {
|
||||
if (!await launchUrl(
|
||||
downloadsPageUrl,
|
||||
mode: LaunchMode.externalApplication,
|
||||
)) {
|
||||
throw Exception('无法打开下载页面: $downloadsPageUrl');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
|
||||
final response = await _dio.getUri<String>(releasesRssUrl);
|
||||
final body = response.data;
|
||||
@@ -119,7 +178,6 @@ class UpdateService {
|
||||
final update = await _buildUpdateInfo(release);
|
||||
if (update == null) continue;
|
||||
|
||||
if (update.downloadUrl == null) continue;
|
||||
return update;
|
||||
}
|
||||
|
||||
@@ -206,8 +264,6 @@ class UpdateService {
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get _supportsAutoDownload => Platform.isAndroid || Platform.isWindows;
|
||||
|
||||
bool _isReleaseAttachment(Uri uri) {
|
||||
return uri.path.contains('/releases/download/');
|
||||
}
|
||||
@@ -345,6 +401,16 @@ class UpdateService {
|
||||
return Uri.decodeComponent(segment);
|
||||
}
|
||||
|
||||
void _setAvailableUpdate(AppUpdateInfo? update) {
|
||||
final previous = _availableUpdate;
|
||||
if (previous?.version == update?.version &&
|
||||
previous?.pageUrl == update?.pageUrl) {
|
||||
return;
|
||||
}
|
||||
_availableUpdate = update;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:cloudreve4_flutter/services/update_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('UpdateService', () {
|
||||
test(
|
||||
'checkForUpdate releases checking flag when package info fails',
|
||||
() async {
|
||||
var calls = 0;
|
||||
final service = UpdateService.test(
|
||||
packageInfoProvider: () async {
|
||||
calls += 1;
|
||||
throw Exception('package info unavailable');
|
||||
},
|
||||
);
|
||||
|
||||
expect(await service.checkForUpdate(), isNull);
|
||||
expect(await service.checkForUpdate(), isNull);
|
||||
expect(calls, 2);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user