Optimize automatic updates

This commit is contained in:
2026-05-31 09:32:32 +08:00
parent 8dfc22691a
commit 61ad85f6fc
4 changed files with 175 additions and 98 deletions
+4
View File
@@ -39,4 +39,8 @@ class StorageKeys {
// 公告 // 公告
static const String siteAnnouncementDismissedFingerprint = static const String siteAnnouncementDismissedFingerprint =
'site_announcement_dismissed_fingerprint'; 'site_announcement_dismissed_fingerprint';
// 版本更新
static const String updatePromptSkipUntil = 'update_prompt_skip_until';
static const String updatePromptSkipVersion = 'update_prompt_skip_version';
} }
@@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../../data/models/user_model.dart'; import '../../../data/models/user_model.dart';
import '../../../data/models/user_setting_model.dart'; import '../../../data/models/user_setting_model.dart';
import '../../../services/update_service.dart';
import '../../../services/user_setting_service.dart'; import '../../../services/user_setting_service.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/user_setting_provider.dart'; import '../../providers/user_setting_provider.dart';
@@ -154,11 +155,7 @@ class _SettingsPageState extends State<SettingsPage> {
title: const Text('应用名称'), title: const Text('应用名称'),
subtitle: const Text('公云存储'), subtitle: const Text('公云存储'),
), ),
ListTile( _buildVersionTile(),
leading: const Icon(Icons.tag),
title: const Text('版本号'),
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion),
),
ListTile( ListTile(
leading: const Icon(Icons.copyright), leading: const Icon(Icons.copyright),
title: const Text('License'), title: const Text('License'),
@@ -404,6 +401,22 @@ class _SettingsPageState extends State<SettingsPage> {
); );
} }
Widget _buildVersionTile() {
return AnimatedBuilder(
animation: UpdateService.instance,
builder: (context, _) {
return ListTile(
leading: const Icon(Icons.tag),
title: const Text('版本号'),
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion),
trailing: UpdateService.instance.hasUpdate
? const _BlinkingUpdateDot()
: null,
);
},
);
}
Widget _buildLogoutButton(BuildContext context, AuthProvider auth) { Widget _buildLogoutButton(BuildContext context, AuthProvider auth) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -628,6 +641,53 @@ class _SettingsPageState extends State<SettingsPage> {
} }
} }
class _BlinkingUpdateDot extends StatefulWidget {
const _BlinkingUpdateDot();
@override
State<_BlinkingUpdateDot> createState() => _BlinkingUpdateDotState();
}
class _BlinkingUpdateDotState extends State<_BlinkingUpdateDot>
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) {
return FadeTransition(
opacity: _opacity,
child: Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
);
}
}
class _NetworkTestConsentText extends StatefulWidget { class _NetworkTestConsentText extends StatefulWidget {
final VoidCallback onOpenPrivacy; final VoidCallback onOpenPrivacy;
final VoidCallback onOpenTerms; final VoidCallback onOpenTerms;
+43 -86
View File
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../data/models/app_update_model.dart'; import '../../data/models/app_update_model.dart';
@@ -31,6 +29,8 @@ class _UpdatePromptState extends State<UpdatePrompt> {
final update = await UpdateService.instance.checkForUpdate(); final update = await UpdateService.instance.checkForUpdate();
if (update == null || !mounted) return; if (update == null || !mounted) return;
final shouldShow = await UpdateService.instance.shouldShowPrompt(update);
if (!shouldShow || !mounted) return;
_dialogVisible = true; _dialogVisible = true;
try { try {
@@ -41,98 +41,55 @@ class _UpdatePromptState extends State<UpdatePrompt> {
} }
Future<void> _showUpdateDialog(AppUpdateInfo update) async { Future<void> _showUpdateDialog(AppUpdateInfo update) async {
var downloading = false;
var progress = 0.0;
String? status;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return StatefulBuilder( Future<void> openDownloadsPage() async {
builder: (context, setDialogState) { try {
final canDownload = await UpdateService.instance.openDownloadsPage();
update.downloadUrl != null && if (dialogContext.mounted) Navigator.of(dialogContext).pop();
(Platform.isAndroid || Platform.isWindows); } catch (e) {
ToastHelper.failure('打开下载页面失败: $e');
}
}
Future<void> startUpdate() async { Future<void> skipPrompt() async {
if (downloading || !canDownload) return; await UpdateService.instance.skipPromptForFiveDays(update);
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
}
setDialogState(() { final releaseNotes = (update.description ?? '').trim();
downloading = true;
progress = 0;
status = '正在下载更新...';
});
try { return AlertDialog(
final file = await UpdateService.instance.downloadUpdate( title: Text('发现新版本 ${update.version}'),
update, content: ConstrainedBox(
onProgress: (received, total) { constraints: const BoxConstraints(maxWidth: 420, maxHeight: 360),
if (!mounted || total <= 0) return; child: SingleChildScrollView(
setDialogState(() { child: Column(
progress = received / total; mainAxisSize: MainAxisSize.min,
}); crossAxisAlignment: CrossAxisAlignment.start,
}, children: [
); Text('新版本号:${update.version}'),
if (file == null) return; const SizedBox(height: 12),
Text(
setDialogState(() { '版本日志',
progress = 1; style: Theme.of(dialogContext).textTheme.titleSmall,
status = '下载完成,正在打开安装包...'; ),
}); const SizedBox(height: 6),
await UpdateService.instance.openInstaller(file); Text(releaseNotes.isEmpty ? update.title : releaseNotes),
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 ?? ''),
],
],
),
), ),
actions: [ ),
TextButton( ),
onPressed: downloading actions: [
? null TextButton(onPressed: skipPrompt, child: const Text('跳过')),
: () => Navigator.of(dialogContext).pop(), TextButton(
child: const Text('稍后'), onPressed: () => Navigator.of(dialogContext).pop(),
), child: const Text('取消'),
FilledButton( ),
onPressed: downloading ? null : startUpdate, FilledButton(onPressed: openDownloadsPage, child: const Text('更新')),
child: const Text('立即更新'), ],
),
],
);
},
); );
}, },
); );
+63 -7
View File
@@ -9,10 +9,12 @@ import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
import '../core/constants/storage_keys.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
import '../data/models/app_update_model.dart'; import '../data/models/app_update_model.dart';
import 'storage_service.dart';
class UpdateService { class UpdateService extends ChangeNotifier {
UpdateService._(); UpdateService._();
static final UpdateService instance = UpdateService._(); static final UpdateService instance = UpdateService._();
@@ -23,6 +25,9 @@ class UpdateService {
static final Uri releasesPageUrl = Uri.parse( static final Uri releasesPageUrl = Uri.parse(
'https://git.saont.net/gongyun/app/releases', 'https://git.saont.net/gongyun/app/releases',
); );
static final Uri downloadsPageUrl = Uri.parse(
'https://www.gongyun.org/downloads.html',
);
final Dio _dio = Dio( final Dio _dio = Dio(
BaseOptions( BaseOptions(
@@ -34,17 +39,24 @@ class UpdateService {
); );
bool _checking = false; bool _checking = false;
AppUpdateInfo? _availableUpdate;
AppUpdateInfo? get availableUpdate => _availableUpdate;
bool get hasUpdate => _availableUpdate != null;
Future<AppUpdateInfo?> checkForUpdate() async { Future<AppUpdateInfo?> checkForUpdate() async {
if (_checking) return null; if (_checking) return null;
if (!_supportsAutoDownload) return null;
_checking = true; _checking = true;
try { try {
final current = await PackageInfo.fromPlatform(); final current = await PackageInfo.fromPlatform();
final latest = await _fetchLatestRelease(currentVersion: current.version); final latest = await _fetchLatestRelease(currentVersion: current.version);
if (latest == null) return null; if (latest == null ||
if (_compareVersions(latest.version, current.version) <= 0) return null; _compareVersions(latest.version, current.version) <= 0) {
_setAvailableUpdate(null);
return null;
}
_setAvailableUpdate(latest);
return latest; return latest;
} catch (e, stackTrace) { } catch (e, stackTrace) {
AppLogger.e('检查更新失败: $e\n$stackTrace'); AppLogger.e('检查更新失败: $e\n$stackTrace');
@@ -54,6 +66,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( Future<File?> downloadUpdate(
AppUpdateInfo update, { AppUpdateInfo update, {
void Function(int received, int total)? onProgress, void Function(int received, int total)? onProgress,
@@ -96,6 +136,15 @@ class UpdateService {
} }
} }
Future<void> openDownloadsPage() async {
if (!await launchUrl(
downloadsPageUrl,
mode: LaunchMode.externalApplication,
)) {
throw Exception('无法打开下载页面: $downloadsPageUrl');
}
}
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async { Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
final response = await _dio.getUri<String>(releasesRssUrl); final response = await _dio.getUri<String>(releasesRssUrl);
final body = response.data; final body = response.data;
@@ -119,7 +168,6 @@ class UpdateService {
final update = await _buildUpdateInfo(release); final update = await _buildUpdateInfo(release);
if (update == null) continue; if (update == null) continue;
if (update.downloadUrl == null) continue;
return update; return update;
} }
@@ -206,8 +254,6 @@ class UpdateService {
return null; return null;
} }
bool get _supportsAutoDownload => Platform.isAndroid || Platform.isWindows;
bool _isReleaseAttachment(Uri uri) { bool _isReleaseAttachment(Uri uri) {
return uri.path.contains('/releases/download/'); return uri.path.contains('/releases/download/');
} }
@@ -345,6 +391,16 @@ class UpdateService {
return Uri.decodeComponent(segment); 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 @visibleForTesting
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease(); Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
} }