feat(client/update): 更新提示改 jiu 式顶部横条(非强制不再弹窗)
按需求去掉居中弹窗,改成不打断的顶部 banner(对照 jiu 黄条): - 非强制更新 → home_shell 顶部 UpdateBanner「发现新版本 vX + 立即更新 + ×」; 「立即更新」App 内下载安装,「×」忽略此版本(banner 隐藏,更新版本会重现) - 强制更新(force_update)→ 仍走不可关闭弹窗 showUpdateDialog - 忽略状态改 dismissedUpdateVersionProvider(按版本号,响应式);去掉 notifier 的 _dismissed - 设置页「检查更新」有更新时:清忽略版本→banner 显示 + toast,不再弹窗 - 新增 6 语「立即更新」文案。flutter analyze 仅 2 个既有 withOpacity info Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -308,6 +308,24 @@ extension AppLangMisc on AppLang {
|
|||||||
return 'Cancelar';
|
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 各实现一份。
|
/// 全部界面文案的抽象契约。zh / en 各实现一份。
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ import '../state/settings_provider.dart';
|
|||||||
import '../state/update_provider.dart';
|
import '../state/update_provider.dart';
|
||||||
import '../widgets/pangolin_icons.dart';
|
import '../widgets/pangolin_icons.dart';
|
||||||
import '../widgets/pangolin_toast.dart';
|
import '../widgets/pangolin_toast.dart';
|
||||||
import '../widgets/update_dialog.dart';
|
|
||||||
|
|
||||||
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
|
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
|
||||||
/// 有更新则弹 [showUpdateDialog]。
|
/// 有更新则清掉「已忽略版本」→ 顶部更新 banner 显示 + toast(不弹窗)。
|
||||||
Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async {
|
Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async {
|
||||||
final info = await ref.read(updateProvider.notifier).forceCheck();
|
final info = await ref.read(updateProvider.notifier).forceCheck();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -30,7 +28,10 @@ Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) asy
|
|||||||
showPangolinToast(context, t.updateUpToDate);
|
showPangolinToast(context, t.updateUpToDate);
|
||||||
return;
|
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 {
|
class SettingsPage extends ConsumerWidget {
|
||||||
|
|||||||
@@ -22,27 +22,36 @@ class HomeShell extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final c = context.pangolin;
|
final c = context.pangolin;
|
||||||
|
|
||||||
// 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询);
|
// 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询)。
|
||||||
// 有新版且本次会话未忽略时弹更新对话框。非强制更新弹前即标 dismiss,避免
|
// 非强制更新 → 顶部 banner(见下,不打断);强制更新 → 不可关闭弹窗。
|
||||||
// rebuild 重复弹(下轮轮询会重置);强制更新走不可关闭弹窗。
|
|
||||||
ref.listen<AsyncValue<AppUpdateInfo?>>(updateProvider, (prev, next) {
|
ref.listen<AsyncValue<AppUpdateInfo?>>(updateProvider, (prev, next) {
|
||||||
final info = next.valueOrNull;
|
final info = next.valueOrNull;
|
||||||
if (info == null || !info.hasUpdate) return;
|
if (info != null && info.hasUpdate && info.forceUpdate) {
|
||||||
final notifier = ref.read(updateProvider.notifier);
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (notifier.isDismissed) return;
|
if (context.mounted) {
|
||||||
if (!info.forceUpdate) notifier.dismiss();
|
showUpdateDialog(context, ref.read(appTextProvider), info);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
}
|
||||||
if (!context.mounted) return;
|
});
|
||||||
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) {
|
final Widget body = switch (context.formFactor) {
|
||||||
FormFactor.desktop => const DesktopShell(),
|
FormFactor.desktop => const DesktopShell(),
|
||||||
FormFactor.tablet => const TabletShell(),
|
FormFactor.tablet => const TabletShell(),
|
||||||
FormFactor.mobile => const MobileShell(),
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,6 @@ class AppUpdateInfo {
|
|||||||
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
|
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
|
||||||
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _dismissed = false;
|
|
||||||
|
|
||||||
/// 本次会话是否已被用户「稍后」忽略(强制更新不看这个)。
|
|
||||||
bool get isDismissed => _dismissed;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AppUpdateInfo?> build() async {
|
Future<AppUpdateInfo?> build() async {
|
||||||
@@ -58,18 +54,13 @@ class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
|||||||
await Future<void>.delayed(_kInitialDelay);
|
await Future<void>.delayed(_kInitialDelay);
|
||||||
final first = await _check();
|
final first = await _check();
|
||||||
_timer = Timer.periodic(_kPollInterval, (_) async {
|
_timer = Timer.periodic(_kPollInterval, (_) async {
|
||||||
_dismissed = false; // 新一轮允许再次提示
|
|
||||||
state = AsyncValue.data(await _check());
|
state = AsyncValue.data(await _check());
|
||||||
});
|
});
|
||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用户点「稍后」——本次会话内不再自动弹(直到下轮轮询)。
|
/// 设置页「检查更新」手动触发:立即查一次并回结果。
|
||||||
void dismiss() => _dismissed = true;
|
|
||||||
|
|
||||||
/// 设置页「检查更新」手动触发:立即查一次并回结果(不受 dismiss 影响)。
|
|
||||||
Future<AppUpdateInfo?> forceCheck() async {
|
Future<AppUpdateInfo?> forceCheck() async {
|
||||||
_dismissed = false;
|
|
||||||
final info = await _check();
|
final info = await _check();
|
||||||
state = AsyncValue.data(info);
|
state = AsyncValue.data(info);
|
||||||
return info;
|
return info;
|
||||||
@@ -133,6 +124,11 @@ class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
|
|||||||
final updateProvider =
|
final updateProvider =
|
||||||
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
|
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
|
||||||
|
|
||||||
|
/// 用户在更新 banner 点「稍后再说」时忽略的版本号。忽略后 banner 隐藏;出现号
|
||||||
|
/// 不同的更新版本会重新显示;设置页手动「检查更新」会清空以重新提示。响应式,
|
||||||
|
/// 供 shell 顶部 banner 的显隐判断。
|
||||||
|
final dismissedUpdateVersionProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
/// 按当前平台从服务端 download_urls 里取对应下载直链。
|
/// 按当前平台从服务端 download_urls 里取对应下载直链。
|
||||||
String? platformDownloadUrl(Map<String, String> downloadUrls) {
|
String? platformDownloadUrl(Map<String, String> downloadUrls) {
|
||||||
if (Platform.isMacOS) return downloadUrls['macos'];
|
if (Platform.isMacOS) return downloadUrls['macos'];
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../core/update/app_updater.dart';
|
import '../core/update/app_updater.dart';
|
||||||
import '../l10n/app_text.dart';
|
import '../l10n/app_text.dart';
|
||||||
import '../pangolin_theme.dart';
|
import '../pangolin_theme.dart';
|
||||||
|
import '../state/app_providers.dart';
|
||||||
import '../state/update_provider.dart';
|
import '../state/update_provider.dart';
|
||||||
import 'pangolin_icons.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,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user