diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 7aedbc2..e27ce4a 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -308,6 +308,24 @@ extension AppLangMisc on AppLang { return 'Cancelar'; } } + + /// 更新 banner:「立即更新」。 + String get updateNow { + switch (this) { + case AppLang.zh: + return '立即更新'; + case AppLang.en: + return 'Update now'; + case AppLang.ja: + return '今すぐ更新'; + case AppLang.ko: + return '지금 업데이트'; + case AppLang.ru: + return 'Обновить'; + case AppLang.es: + return 'Actualizar'; + } + } } /// 全部界面文案的抽象契约。zh / en 各实现一份。 diff --git a/client/lib/screens/settings_page.dart b/client/lib/screens/settings_page.dart index 619499b..97a9604 100644 --- a/client/lib/screens/settings_page.dart +++ b/client/lib/screens/settings_page.dart @@ -15,10 +15,8 @@ import '../state/settings_provider.dart'; import '../state/update_provider.dart'; import '../widgets/pangolin_icons.dart'; import '../widgets/pangolin_toast.dart'; -import '../widgets/update_dialog.dart'; - /// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示, -/// 有更新则弹 [showUpdateDialog]。 +/// 有更新则清掉「已忽略版本」→ 顶部更新 banner 显示 + toast(不弹窗)。 Future _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async { final info = await ref.read(updateProvider.notifier).forceCheck(); if (!context.mounted) return; @@ -30,7 +28,10 @@ Future _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) asy showPangolinToast(context, t.updateUpToDate); return; } - await showUpdateDialog(context, t, info); + // 有更新:清掉「已忽略版本」→ 顶部 banner 重新显示(不弹窗);toast 提示一下。 + // 强制更新由 home_shell 的 listen 走不可关闭弹窗,这里无需处理。 + ref.read(dismissedUpdateVersionProvider.notifier).state = null; + showPangolinToast(context, t.updateAvailableTitle(info.latestVersion)); } class SettingsPage extends ConsumerWidget { diff --git a/client/lib/shell/home_shell.dart b/client/lib/shell/home_shell.dart index a837156..4b08e3e 100644 --- a/client/lib/shell/home_shell.dart +++ b/client/lib/shell/home_shell.dart @@ -22,27 +22,36 @@ class HomeShell extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final c = context.pangolin; - // 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询); - // 有新版且本次会话未忽略时弹更新对话框。非强制更新弹前即标 dismiss,避免 - // rebuild 重复弹(下轮轮询会重置);强制更新走不可关闭弹窗。 + // 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询)。 + // 非强制更新 → 顶部 banner(见下,不打断);强制更新 → 不可关闭弹窗。 ref.listen>(updateProvider, (prev, next) { final info = next.valueOrNull; - if (info == null || !info.hasUpdate) return; - final notifier = ref.read(updateProvider.notifier); - if (notifier.isDismissed) return; - if (!info.forceUpdate) notifier.dismiss(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - showUpdateDialog(context, ref.read(appTextProvider), info); - }); + if (info != null && info.hasUpdate && info.forceUpdate) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + showUpdateDialog(context, ref.read(appTextProvider), info); + } + }); + } }); - ref.watch(updateProvider); // 激活 provider(惰性 build) + + final info = ref.watch(updateProvider).valueOrNull; + final dismissedVer = ref.watch(dismissedUpdateVersionProvider); + final showBanner = info != null && + info.hasUpdate && + !info.forceUpdate && + dismissedVer != info.latestVersion; final Widget body = switch (context.formFactor) { FormFactor.desktop => const DesktopShell(), FormFactor.tablet => const TabletShell(), FormFactor.mobile => const MobileShell(), }; - return Scaffold(backgroundColor: c.bg, body: body); + return Scaffold( + backgroundColor: c.bg, + body: showBanner + ? Column(children: [UpdateBanner(info: info), Expanded(child: body)]) + : body, + ); } } diff --git a/client/lib/state/update_provider.dart b/client/lib/state/update_provider.dart index ac209e5..e52b2c7 100644 --- a/client/lib/state/update_provider.dart +++ b/client/lib/state/update_provider.dart @@ -47,10 +47,6 @@ class AppUpdateInfo { /// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。 class UpdateNotifier extends AsyncNotifier { Timer? _timer; - bool _dismissed = false; - - /// 本次会话是否已被用户「稍后」忽略(强制更新不看这个)。 - bool get isDismissed => _dismissed; @override Future build() async { @@ -58,18 +54,13 @@ class UpdateNotifier extends AsyncNotifier { await Future.delayed(_kInitialDelay); final first = await _check(); _timer = Timer.periodic(_kPollInterval, (_) async { - _dismissed = false; // 新一轮允许再次提示 state = AsyncValue.data(await _check()); }); return first; } - /// 用户点「稍后」——本次会话内不再自动弹(直到下轮轮询)。 - void dismiss() => _dismissed = true; - - /// 设置页「检查更新」手动触发:立即查一次并回结果(不受 dismiss 影响)。 + /// 设置页「检查更新」手动触发:立即查一次并回结果。 Future forceCheck() async { - _dismissed = false; final info = await _check(); state = AsyncValue.data(info); return info; @@ -133,6 +124,11 @@ class UpdateNotifier extends AsyncNotifier { final updateProvider = AsyncNotifierProvider(UpdateNotifier.new); +/// 用户在更新 banner 点「稍后再说」时忽略的版本号。忽略后 banner 隐藏;出现号 +/// 不同的更新版本会重新显示;设置页手动「检查更新」会清空以重新提示。响应式, +/// 供 shell 顶部 banner 的显隐判断。 +final dismissedUpdateVersionProvider = StateProvider((ref) => null); + /// 按当前平台从服务端 download_urls 里取对应下载直链。 String? platformDownloadUrl(Map downloadUrls) { if (Platform.isMacOS) return downloadUrls['macos']; diff --git a/client/lib/widgets/update_dialog.dart b/client/lib/widgets/update_dialog.dart index 9d6a6b4..6e89ef4 100644 --- a/client/lib/widgets/update_dialog.dart +++ b/client/lib/widgets/update_dialog.dart @@ -6,10 +6,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/update/app_updater.dart'; import '../l10n/app_text.dart'; import '../pangolin_theme.dart'; +import '../state/app_providers.dart'; import '../state/update_provider.dart'; import 'pangolin_icons.dart'; @@ -78,3 +80,60 @@ class _UpdateDialog extends StatelessWidget { ); } } + +/// jiu 式顶部更新横条(非强制更新用;强制更新仍走不可关闭弹窗 showUpdateDialog)。 +/// 「立即更新」App 内下载安装;「×」忽略此版本(banner 隐藏,更新版本会重现)。 +class UpdateBanner extends ConsumerWidget { + const UpdateBanner({super.key, required this.info}); + final AppUpdateInfo info; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + final t = ref.watch(appTextProvider); + return Material( + color: c.accentSubtle, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 8, 6, 8), + child: Row(children: [ + Icon(PangolinIcons.zap, size: 17, color: c.accent), + const SizedBox(width: 10), + Expanded( + child: Text( + t.updateAvailableTitle(info.latestVersion), + style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () => unawaited(startInAppUpdate(context, t, info)), + style: TextButton.styleFrom( + backgroundColor: c.accent, + foregroundColor: c.fgOnAccent, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(PangolinRadius.full)), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text(t.lang.updateNow, + style: PangolinText.caption + .copyWith(fontWeight: FontWeight.w700, fontSize: 12.5)), + ), + IconButton( + onPressed: () => + ref.read(dismissedUpdateVersionProvider.notifier).state = info.latestVersion, + icon: Icon(Icons.close, size: 18, color: c.fg3), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + tooltip: t.updateLater, + ), + ]), + ), + ), + ); + } +}