diff --git a/lib/core/constants/storage_keys.dart b/lib/core/constants/storage_keys.dart index e76a5ed..08873be 100644 --- a/lib/core/constants/storage_keys.dart +++ b/lib/core/constants/storage_keys.dart @@ -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'; } diff --git a/lib/presentation/pages/settings/settings_page.dart b/lib/presentation/pages/settings/settings_page.dart index 2e370ff..c5323a6 100644 --- a/lib/presentation/pages/settings/settings_page.dart +++ b/lib/presentation/pages/settings/settings_page.dart @@ -5,6 +5,7 @@ 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'; @@ -154,11 +155,7 @@ class _SettingsPageState extends State { title: const Text('应用名称'), subtitle: const Text('公云存储'), ), - ListTile( - leading: const Icon(Icons.tag), - title: const Text('版本号'), - subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion), - ), + _buildVersionTile(), ListTile( leading: const Icon(Icons.copyright), title: const Text('License'), @@ -404,6 +401,22 @@ class _SettingsPageState extends State { ); } + 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) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -628,6 +641,53 @@ class _SettingsPageState extends State { } } +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 _opacity; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + _opacity = Tween( + 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 { final VoidCallback onOpenPrivacy; final VoidCallback onOpenTerms; diff --git a/lib/presentation/widgets/update_prompt.dart b/lib/presentation/widgets/update_prompt.dart index cad14ac..90dcbd1 100644 --- a/lib/presentation/widgets/update_prompt.dart +++ b/lib/presentation/widgets/update_prompt.dart @@ -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 { 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 { } Future _showUpdateDialog(AppUpdateInfo update) async { - var downloading = false; - var progress = 0.0; - String? status; - await showDialog( context: context, barrierDismissible: false, builder: (dialogContext) { - return StatefulBuilder( - builder: (context, setDialogState) { - final canDownload = - update.downloadUrl != null && - (Platform.isAndroid || Platform.isWindows); + Future openDownloadsPage() async { + try { + await UpdateService.instance.openDownloadsPage(); + if (dialogContext.mounted) Navigator.of(dialogContext).pop(); + } catch (e) { + ToastHelper.failure('打开下载页面失败: $e'); + } + } - Future startUpdate() async { - if (downloading || !canDownload) return; + Future 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('更新')), + ], ); }, ); diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index ab0d03b..4f3bbde 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -9,10 +9,12 @@ 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 { +class UpdateService extends ChangeNotifier { UpdateService._(); static final UpdateService instance = UpdateService._(); @@ -23,6 +25,9 @@ class UpdateService { static final Uri releasesPageUrl = Uri.parse( 'https://git.saont.net/gongyun/app/releases', ); + static final Uri downloadsPageUrl = Uri.parse( + 'https://www.gongyun.org/downloads.html', + ); final Dio _dio = Dio( BaseOptions( @@ -34,17 +39,24 @@ class UpdateService { ); bool _checking = false; + AppUpdateInfo? _availableUpdate; + + AppUpdateInfo? get availableUpdate => _availableUpdate; + bool get hasUpdate => _availableUpdate != null; Future checkForUpdate() async { if (_checking) return null; - if (!_supportsAutoDownload) return null; _checking = true; try { final current = await PackageInfo.fromPlatform(); 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 +66,34 @@ class UpdateService { } } + Future 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 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 downloadUpdate( AppUpdateInfo update, { void Function(int received, int total)? onProgress, @@ -96,6 +136,15 @@ class UpdateService { } } + Future openDownloadsPage() async { + if (!await launchUrl( + downloadsPageUrl, + mode: LaunchMode.externalApplication, + )) { + throw Exception('无法打开下载页面: $downloadsPageUrl'); + } + } + Future _fetchLatestRelease({String? currentVersion}) async { final response = await _dio.getUri(releasesRssUrl); final body = response.data; @@ -119,7 +168,6 @@ class UpdateService { final update = await _buildUpdateInfo(release); if (update == null) continue; - if (update.downloadUrl == null) continue; return update; } @@ -206,8 +254,6 @@ class UpdateService { return null; } - bool get _supportsAutoDownload => Platform.isAndroid || Platform.isWindows; - bool _isReleaseAttachment(Uri uri) { return uri.path.contains('/releases/download/'); } @@ -345,6 +391,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 debugFetchLatestRelease() => _fetchLatestRelease(); }