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 <cursoragent@cursor.com>
This commit is contained in:
@ -38,15 +38,11 @@
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
939AC7982FE3F832004B22E4 /* suixinkan */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = suixinkan;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A00000022FE9000000000002 /* suixinkanTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = suixinkanTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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 的内部导航路径。
|
||||
|
||||
@ -13,16 +13,54 @@ import SwiftUI
|
||||
/// 全局 Toast 状态中心,负责保存和清除当前提示文案。
|
||||
final class ToastCenter {
|
||||
fileprivate var message: String?
|
||||
@ObservationIgnored private let autoDismissNanoseconds: UInt64
|
||||
@ObservationIgnored private var dismissTask: Task<Void, Never>?
|
||||
@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,34 +70,43 @@ private struct GlobalToastOverlay: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(alignment: .top) {
|
||||
GeometryReader { proxy in
|
||||
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))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.black.opacity(0.78), in: RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
toastBanner(message, topInset: proxy.safeAreaInsets.top)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
.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 展示能力。
|
||||
|
||||
@ -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。
|
||||
|
||||
60
suixinkanTests/ToastCenterTests.swift
Normal file
60
suixinkanTests/ToastCenterTests.swift
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user