refactor(ui): share detail feedback timing lifecycle

This commit is contained in:
Qiu Yuzhou
2026-10-16 01:46:01 +08:00
parent 1be0976bc6
commit 7bd0cb44cb
9 changed files with 266 additions and 153 deletions
@@ -1,25 +0,0 @@
import Combine
import Foundation
/// 详情结果从命令结束时计时;命令状态保留给其他激活入口。
@MainActor
final class ActivationDetailFeedbackState: ObservableObject {
@Published private(set) var completion: ActivationFeedbackState.Completion?
var expiresAt: Date? {
completion.map { Self.expiration(of: $0) }
}
func present(_ completion: ActivationFeedbackState.Completion?, now: Date = Date()) {
self.completion = completion.flatMap { Self.expiration(of: $0) > now ? $0 : nil }
}
func expire(_ id: UUID) {
guard completion?.id == id else { return }
completion = nil
}
private static func expiration(of completion: ActivationFeedbackState.Completion) -> Date {
completion.completedAt.addingTimeInterval(completion.feedback.isSuccess ? 3 : 10)
}
}
@@ -0,0 +1,52 @@
import Foundation
import Observation
/// 结果身份和完成时间来自命令;业务内容留在各自的呈现入口。
protocol DetailFeedbackResult: Identifiable where ID == UUID {
var completedAt: Date { get }
var isSuccess: Bool { get }
}
extension ActivationFeedbackState.Completion: DetailFeedbackResult {
var isSuccess: Bool { feedback.isSuccess }
}
extension SubscriptionRefreshCompletion: DetailFeedbackResult {
var isSuccess: Bool { failure == nil }
}
/// 详情会话持有展示寿命;命令结果仍由原工作流保留。
@Observable
@MainActor
final class DetailFeedbackState<Result: DetailFeedbackResult> {
private(set) var completion: Result?
private var generation = 0
private let now: () -> Date
private let sleep: (TimeInterval) async throws -> Void
init(
now: @escaping () -> Date = Date.init,
sleep: @escaping (TimeInterval) async throws -> Void = {
try await Task.sleep(for: .seconds($0))
}
) {
self.now = now
self.sleep = sleep
}
func present(_ result: Result?) async {
guard !Task.isCancelled else { return }
generation += 1
let currentGeneration = generation
completion = nil
guard let result else { return }
let remaining = result.completedAt.addingTimeInterval(result.isSuccess ? 3 : 10)
.timeIntervalSince(now())
guard remaining > 0 else { return }
completion = result
defer {
if generation == currentGeneration { completion = nil }
}
try? await sleep(remaining)
}
}
@@ -1,25 +0,0 @@
import Combine
import Foundation
/// 提示寿命从命令结束时计算;切回详情不会重新计时。
@MainActor
final class SubscriptionRefreshFeedbackState: ObservableObject {
@Published private(set) var completion: SubscriptionRefreshCompletion?
var expiresAt: Date? {
completion.map { Self.expiration(of: $0) }
}
func present(_ completion: SubscriptionRefreshCompletion?, now: Date = Date()) {
self.completion = completion.flatMap { Self.expiration(of: $0) > now ? $0 : nil }
}
func expire(_ id: UUID) {
guard completion?.id == id else { return }
completion = nil
}
private static func expiration(of completion: SubscriptionRefreshCompletion) -> Date {
completion.completedAt.addingTimeInterval(completion.failure == nil ? 3 : 10)
}
}
@@ -4,31 +4,25 @@ import SwiftUI
struct ActivationDetailFeedbackView: View {
let messages: [String]
let completion: ActivationFeedbackState.Completion?
@StateObject private var feedback = ActivationDetailFeedbackState()
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(messages, id: \.self) { message in
feedbackRow(message, symbol: "exclamationmark.triangle.fill", color: .orange)
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
ForEach(messages, id: \.self) { message in
feedbackRow(message, symbol: "exclamationmark.triangle.fill", color: .orange)
}
}
if let result = feedback.completion, result.id == completion?.id {
DetailFeedback(completion: completion) { result in
feedbackRow(
message(for: result.feedback),
symbol: result.feedback.isSuccess
? "checkmark.circle.fill" : "exclamationmark.triangle.fill",
color: result.feedback.isSuccess ? .green : .red)
color: result.feedback.isSuccess ? .green : .red
)
.padding(.top, messages.isEmpty ? 0 : 8)
}
}
.font(.callout)
.task(id: completion?.id) {
feedback.present(completion)
guard let completion, let expiresAt = feedback.expiresAt else { return }
do {
try await Task.sleep(for: .seconds(max(0, expiresAt.timeIntervalSinceNow)))
} catch { return }
guard !Task.isCancelled else { return }
feedback.expire(completion.id)
}
}
private func feedbackRow(_ message: String, symbol: String, color: Color) -> some View {
@@ -0,0 +1,19 @@
import SwiftUI
/// 稳定容器持有详情计时会话,即使暂时没有结果也能接收后续完成事件。
struct DetailFeedback<Result: DetailFeedbackResult, Content: View>: View {
let completion: Result?
@ViewBuilder let content: (Result) -> Content
@State private var feedback = DetailFeedbackState<Result>()
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let result = feedback.completion, result.id == completion?.id {
content(result)
}
}
.task(id: completion?.id) {
await feedback.present(completion)
}
}
}
@@ -4,7 +4,6 @@ import SwiftUI
struct SubscriptionRefreshFeedbackView: View {
let isRefreshing: Bool
let completion: SubscriptionRefreshCompletion?
@StateObject private var feedback = SubscriptionRefreshFeedbackState()
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -14,31 +13,25 @@ struct SubscriptionRefreshFeedbackView: View {
Text(String(localized: "正在更新订阅…", table: "ServerDetails"))
}
.foregroundStyle(.secondary)
} else if let result = feedback.completion {
HStack(alignment: .top, spacing: 8) {
Image(
systemName: result.failure == nil
? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
)
.foregroundStyle(result.failure == nil ? Color.green : Color.red)
Text(message(for: result))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
DetailFeedback(completion: completion) { result in
if !isRefreshing {
HStack(alignment: .top, spacing: 8) {
Image(
systemName: result.failure == nil
? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
)
.foregroundStyle(result.failure == nil ? Color.green : Color.red)
Text(message(for: result))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
.accessibilityElement(children: .combine)
}
.accessibilityElement(children: .combine)
}
}
.font(.callout)
.task(id: completion?.id) {
feedback.present(completion)
guard let completion, let expiresAt = feedback.expiresAt else { return }
do {
try await Task.sleep(for: .seconds(max(0, expiresAt.timeIntervalSinceNow)))
} catch { return }
guard !Task.isCancelled else { return }
feedback.expire(completion.id)
}
}
private func message(for result: SubscriptionRefreshCompletion) -> String {
@@ -1,41 +0,0 @@
import Foundation
import Testing
@testable import ShadowsocksX_NG2
@MainActor
struct ActivationDetailFeedbackTests {
@Test func successExpiresFromCompletionWithoutRestartingOnReturn() {
let date = Date(timeIntervalSince1970: 100)
let result = ActivationFeedbackState.Completion(
targetID: NodeID(rawValue: "a"), feedback: .activated(skippedInvalid: 2), completedAt: date)
let state = ActivationDetailFeedbackState()
state.present(result, now: date.addingTimeInterval(2))
#expect(state.completion == result)
#expect(state.expiresAt == date.addingTimeInterval(3))
state.present(result, now: date.addingTimeInterval(3))
#expect(state.completion == nil)
}
@Test(arguments: [
ActivationFeedbackState.Feedback.failed(message: "failure"),
.rejected(.noActiveTarget),
])
func failuresLastTenSecondsAndStaleExpirationDoesNotClearNewResult(
feedback: ActivationFeedbackState.Feedback
) {
let date = Date(timeIntervalSince1970: 100)
let first = ActivationFeedbackState.Completion(
targetID: NodeID(rawValue: "a"), feedback: .activated(skippedInvalid: 0), completedAt: date)
let next = ActivationFeedbackState.Completion(
targetID: NodeID(rawValue: "a"), feedback: feedback, completedAt: date)
let state = ActivationDetailFeedbackState()
state.present(first, now: date)
state.present(next, now: date.addingTimeInterval(3))
state.expire(first.id)
#expect(state.completion == next)
#expect(state.expiresAt == date.addingTimeInterval(10))
state.present(next, now: date.addingTimeInterval(10))
#expect(state.completion == nil)
}
}
@@ -0,0 +1,171 @@
import Foundation
import Testing
@testable import ShadowsocksX_NG2
@MainActor
struct DetailFeedbackStateTests {
@Test func successExpiresFromCompletionRatherThanAppearance() async {
let completedAt = Date(timeIntervalSince1970: 100)
let result = SubscriptionRefreshCompletion(completedAt: completedAt)
let gate = AsyncGate()
let entered = AsyncGate()
var delays: [TimeInterval] = []
let state = DetailFeedbackState<SubscriptionRefreshCompletion>(
now: { completedAt.addingTimeInterval(2) },
sleep: { delay in
delays.append(delay)
entered.release()
await gate.wait()
})
let task = Task { await state.present(result) }
await entered.wait()
#expect(state.completion == result)
#expect(delays == [1])
gate.release()
await task.value
#expect(state.completion == nil)
}
@Test func supersededTaskCannotClearNewResult() async {
let date = Date(timeIntervalSince1970: 100)
let first = SubscriptionRefreshCompletion(completedAt: date)
let next = SubscriptionRefreshCompletion(completedAt: date, failure: .httpStatus(code: 503))
let firstEntered = AsyncGate()
let nextEntered = AsyncGate()
let firstSleep = AsyncGate()
let nextSleep = AsyncGate()
var calls = 0
let state = DetailFeedbackState<SubscriptionRefreshCompletion>(
now: { date },
sleep: { _ in
calls += 1
if calls == 1 {
firstEntered.release()
await firstSleep.wait()
} else {
nextEntered.release()
await nextSleep.wait()
}
})
let oldTask = Task { await state.present(first) }
await firstEntered.wait()
oldTask.cancel()
let newTask = Task { await state.present(next) }
await nextEntered.wait()
firstSleep.release()
await oldTask.value
#expect(state.completion == next)
nextSleep.release()
await newTask.value
#expect(state.completion == nil)
}
@Test(arguments: [
(ActivationFeedbackState.Feedback.activated(skippedInvalid: 2), 3.0),
(.rejected(.noActiveTarget), 10.0),
(.failed(message: "failure"), 10.0),
])
func activationUsesTheSharedLifetime(
feedback: ActivationFeedbackState.Feedback, seconds: TimeInterval
) async {
let date = Date(timeIntervalSince1970: 100)
let result = ActivationFeedbackState.Completion(
targetID: NodeID(rawValue: "a"), feedback: feedback, completedAt: date)
await checkLifetime(result, seconds: seconds)
}
@Test func subscriptionFailureLastsTenSeconds() async {
await checkLifetime(
SubscriptionRefreshCompletion(
completedAt: Date(timeIntervalSince1970: 100), failure: .httpStatus(code: 503)),
seconds: 10)
}
@Test func leavingCancelsAndReturningUsesRemainingLifetime() async {
let date = Date(timeIntervalSince1970: 100)
let result = SubscriptionRefreshCompletion(completedAt: date)
var now = date
var delays: [TimeInterval] = []
let firstEntered = AsyncGate()
let nextEntered = AsyncGate()
let firstSleep = AsyncGate()
let nextSleep = AsyncGate()
let state = DetailFeedbackState<SubscriptionRefreshCompletion>(
now: { now },
sleep: { delay in
delays.append(delay)
let isFirst = delays.count == 1
let entered = isFirst ? firstEntered : nextEntered
let sleeper = isFirst ? firstSleep : nextSleep
try await withTaskCancellationHandler {
entered.release()
await sleeper.wait()
try Task.checkCancellation()
} onCancel: {
sleeper.release()
}
})
let firstVisit = Task { await state.present(result) }
await firstEntered.wait()
firstVisit.cancel()
await firstVisit.value
#expect(state.completion == nil)
now = date.addingTimeInterval(2)
let nextVisit = Task { await state.present(result) }
await nextEntered.wait()
#expect(state.completion == result)
#expect(delays == [3, 1])
nextSleep.release()
await nextVisit.value
now = date.addingTimeInterval(3)
await state.present(result)
#expect(state.completion == nil)
#expect(delays == [3, 1])
}
@Test func clearingInputInvalidatesPendingExpiration() async {
let date = Date(timeIntervalSince1970: 100)
let result = SubscriptionRefreshCompletion(completedAt: date)
let entered = AsyncGate()
let sleeper = AsyncGate()
let state = DetailFeedbackState<SubscriptionRefreshCompletion>(
now: { date },
sleep: { _ in
entered.release()
await sleeper.wait()
})
let task = Task { await state.present(result) }
await entered.wait()
await state.present(nil)
#expect(state.completion == nil)
sleeper.release()
await task.value
#expect(state.completion == nil)
}
private func checkLifetime<Result: DetailFeedbackResult & Equatable>(
_ result: Result, seconds: TimeInterval
) async {
var now = result.completedAt
var delays: [TimeInterval] = []
let entered = AsyncGate()
let sleeper = AsyncGate()
let state = DetailFeedbackState<Result>(
now: { now },
sleep: { delay in
delays.append(delay)
entered.release()
await sleeper.wait()
})
let task = Task { await state.present(result) }
await entered.wait()
#expect(state.completion == result)
#expect(delays == [seconds])
sleeper.release()
await task.value
#expect(state.completion == nil)
now = result.completedAt.addingTimeInterval(seconds)
await state.present(result)
#expect(state.completion == nil)
#expect(delays == [seconds])
}
}
@@ -5,31 +5,6 @@ import Testing
@MainActor
struct SubscriptionRefreshFeedbackTests {
@Test func failureLastsTenSecondsAndOldExpirationCannotClearNewResult() {
let date = Date(timeIntervalSince1970: 100)
let first = SubscriptionRefreshCompletion(completedAt: date)
let next = SubscriptionRefreshCompletion(completedAt: date, failure: .httpStatus(code: 503))
let state = SubscriptionRefreshFeedbackState()
state.present(first, now: date)
state.present(next, now: date.addingTimeInterval(3))
state.expire(first.id)
#expect(state.completion == next)
#expect(state.expiresAt == date.addingTimeInterval(10))
state.present(next, now: date.addingTimeInterval(10))
#expect(state.completion == nil)
}
@Test func successExpiresThreeSecondsAfterCompletionWithoutRestartingOnReturn() {
let date = Date(timeIntervalSince1970: 100)
let completion = SubscriptionRefreshCompletion(completedAt: date)
let state = SubscriptionRefreshFeedbackState()
state.present(completion, now: date.addingTimeInterval(2))
#expect(state.completion == completion)
#expect(state.expiresAt == date.addingTimeInterval(3))
state.present(completion, now: date.addingTimeInterval(3))
#expect(state.completion == nil)
}
@Test func successfulRefreshPublishesSessionResultWithoutReplayingOnReload() async throws {
let fixture = try Fixture()
defer { fixture.removeFiles() }