From b7054145e77a4f313cfdb0d33f1d02e798ee4992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 23 Jun 2026 14:48:31 +0800 Subject: [PATCH] Redesign global toast as auto-dismissing top banner. Use a full-width primary banner with centered text, auto-hide after 2.2 seconds, and tests for timer reset behavior. Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 8 +-- suixinkan/App/App.md | 8 ++- suixinkan/App/State/ToastCenter.swift | 91 ++++++++++++++++++++------- suixinkan/Core/Core.md | 6 ++ suixinkanTests/ToastCenterTests.swift | 60 ++++++++++++++++++ 5 files changed, 146 insertions(+), 27 deletions(-) create mode 100644 suixinkanTests/ToastCenterTests.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index f8ce19e..c3ea9a1 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -38,15 +38,11 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 939AC7982FE3F832004B22E4 /* suixinkan */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkan; sourceTree = ""; }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanTests; sourceTree = ""; }; @@ -259,10 +255,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; diff --git a/suixinkan/App/App.md b/suixinkan/App/App.md index 7a595f1..ab0d3b5 100644 --- a/suixinkan/App/App.md +++ b/suixinkan/App/App.md @@ -19,7 +19,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 - `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。 - `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。 - `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。 -- `ToastCenter`:保存当前全局 Toast 文案。 +- `ToastCenter`:管理当前全局 Toast 文案和自动隐藏任务。 - `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。 - `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。 - `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。 @@ -55,6 +55,12 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 - `AppSession` 切换为 `loggedOut`。 - 保留上次手机号、协议状态等非敏感偏好。 +## Toast + +全局 Toast 由 `RootView` 挂载在页面最上层,业务页面只调用 `toastCenter.show(...)` 发出提示命令。 + +Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。 + ## 导航规则 主界面使用 `TabView`,每个 Tab 内部由单独的 `NavigationStack` 包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。 diff --git a/suixinkan/App/State/ToastCenter.swift b/suixinkan/App/State/ToastCenter.swift index 5d838f2..7138b24 100644 --- a/suixinkan/App/State/ToastCenter.swift +++ b/suixinkan/App/State/ToastCenter.swift @@ -13,16 +13,54 @@ import SwiftUI /// 全局 Toast 状态中心,负责保存和清除当前提示文案。 final class ToastCenter { fileprivate var message: String? + @ObservationIgnored private let autoDismissNanoseconds: UInt64 + @ObservationIgnored private var dismissTask: Task? + @ObservationIgnored private var displayToken: UInt64 = 0 + + /// 创建 Toast 状态中心,默认 2.2 秒后自动隐藏。 + init(autoDismissNanoseconds: UInt64 = 2_200_000_000) { + self.autoDismissNanoseconds = autoDismissNanoseconds + } /// 显示一条全局 Toast 文案。 func show(_ message: String) { + displayToken &+= 1 self.message = message + scheduleAutoDismiss(token: displayToken) } /// 清除当前 Toast 文案。 func dismiss() { + displayToken &+= 1 + dismissTask?.cancel() + dismissTask = nil message = nil } + + /// 为当前 Toast 安排自动隐藏任务,并避免旧任务误清除新 Toast。 + private func scheduleAutoDismiss(token: UInt64) { + dismissTask?.cancel() + let delay = autoDismissNanoseconds + dismissTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: delay) + } catch { + return + } + await MainActor.run { + guard let self, self.displayToken == token else { return } + self.message = nil + self.dismissTask = nil + } + } + } + + #if DEBUG + /// 测试专用快照,业务代码不应读取 Toast 展示状态。 + var snapshotForTests: ToastSnapshot { + ToastSnapshot(message: message) + } + #endif } /// 全局 Toast 叠加层修饰器,负责把 Toast 展示到页面顶部。 @@ -32,35 +70,44 @@ private struct GlobalToastOverlay: ViewModifier { func body(content: Content) -> some View { content .overlay(alignment: .top) { - if let message = toastCenter.message { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 14, weight: .semibold)) - - Text(message) - .font(.system(size: 13, weight: .medium)) - .lineLimit(2) - - Spacer(minLength: 0) - - Button("关闭") { - toastCenter.dismiss() - } - .font(.system(size: 13, weight: .semibold)) + GeometryReader { proxy in + if let message = toastCenter.message { + toastBanner(message, topInset: proxy.safeAreaInsets.top) + .transition(.move(edge: .top).combined(with: .opacity)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(Color.black.opacity(0.78), in: RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 16) - .padding(.top, 10) - .transition(.move(edge: .top).combined(with: .opacity)) } + .allowsHitTesting(false) } .animation(.easeInOut(duration: 0.18), value: toastCenter.message) } + + /// 构建顶部全宽 Toast 横幅。 + private func toastBanner(_ message: String, topInset: CGFloat) -> some View { + HStack { + Text(message) + .font(.system(size: 14, weight: .medium)) + .lineLimit(2) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .center) + } + .foregroundStyle(.white) + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.top, topInset + AppMetrics.Spacing.small) + .padding(.bottom, AppMetrics.Spacing.small) + .frame(maxWidth: .infinity, alignment: .center) + .background(AppDesign.primary.ignoresSafeArea(edges: .top)) + } } +#if DEBUG +/// 全局 Toast 测试快照,用于验证文案和自动隐藏行为。 +struct ToastSnapshot: Equatable { + let message: String? +} +#endif + extension View { /// 给任意根视图追加全局 Toast 展示能力。 func globalToastOverlay() -> some View { diff --git a/suixinkan/Core/Core.md b/suixinkan/Core/Core.md index c22e1ac..fe983cf 100644 --- a/suixinkan/Core/Core.md +++ b/suixinkan/Core/Core.md @@ -75,6 +75,12 @@ Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并 全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。 +### 全局 Toast + +`ToastCenter` 只用于展示轻量提示文案,业务页面通过 `toastCenter.show(...)` 发出提示命令,不直接读取 Toast 展示状态。 + +Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部安全区和屏幕左右边;文案居中展示,无关闭按钮,默认 2.2 秒自动隐藏。新的 Toast 会覆盖旧 Toast 并重新计时。 + ## Upload `UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。 diff --git a/suixinkanTests/ToastCenterTests.swift b/suixinkanTests/ToastCenterTests.swift new file mode 100644 index 0000000..ea3aee1 --- /dev/null +++ b/suixinkanTests/ToastCenterTests.swift @@ -0,0 +1,60 @@ +// +// ToastCenterTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/23. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 全局 Toast 状态中心测试,覆盖自动隐藏和连续展示行为。 +final class ToastCenterTests: XCTestCase { + /// 测试 show 会立即写入当前 Toast 文案。 + func testShowStoresMessage() { + let center = ToastCenter(autoDismissNanoseconds: 100_000_000) + + center.show("保存成功") + + XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: "保存成功")) + } + + /// 测试 Toast 到达自动隐藏时间后会清空文案。 + func testToastAutoDismissesAfterDelay() async throws { + let center = ToastCenter(autoDismissNanoseconds: 60_000_000) + + center.show("保存成功") + try await Task.sleep(nanoseconds: 90_000_000) + + XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil)) + } + + /// 测试连续 show 会覆盖旧文案,并重新计算自动隐藏时间。 + func testRepeatedShowResetsAutoDismissTimer() async throws { + let center = ToastCenter(autoDismissNanoseconds: 100_000_000) + + center.show("第一条") + try await Task.sleep(nanoseconds: 70_000_000) + center.show("第二条") + try await Task.sleep(nanoseconds: 60_000_000) + + XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: "第二条")) + + try await Task.sleep(nanoseconds: 70_000_000) + XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil)) + } + + /// 测试手动 dismiss 会立即清空文案,并取消当前自动隐藏任务。 + func testDismissClearsMessageImmediately() async throws { + let center = ToastCenter(autoDismissNanoseconds: 100_000_000) + + center.show("保存成功") + center.dismiss() + + XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil)) + + try await Task.sleep(nanoseconds: 130_000_000) + XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil)) + } +}