fix(account): load permanent referral profiles
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

Use authenticated server invitation URLs with account-scoped caching and bounded state transitions, then advance the iOS build metadata to 86.
This commit is contained in:
Rocky
2026-08-20 22:59:58 +08:00
parent ca0a1f9b9a
commit 7bd77509b6
19 changed files with 1151 additions and 228 deletions
@@ -20,7 +20,8 @@ struct AccountCenterUITestHarness: View {
wrappedValue: AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
centerService: service,
referralService: service
),
creditStore: store
)
@@ -54,7 +55,8 @@ struct ManagedCloudConsentUITestHarness: View {
wrappedValue: AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
centerService: service,
referralService: service
),
creditStore: AccountCenterUITestCreditStore(
accountID: service.account.accountID
@@ -80,7 +82,10 @@ struct ManagedCloudConsentUITestHarness: View {
}
}
private actor AccountCenterUITestService: AccountSessionServicing, AccountCenterServicing {
private actor AccountCenterUITestService:
AccountSessionServicing,
AccountCenterServicing,
ReferralProfileServicing {
nonisolated let account = AccountSession(
accountID: UUID(uuidString: "00000000-0000-0000-0000-000000000200")!,
createdAtEpochSeconds: 1_700_000_000,
@@ -106,7 +111,6 @@ private actor AccountCenterUITestService: AccountSessionServicing, AccountCenter
AccountCenterSnapshot(
account: account,
credits: AccountCreditSummary(balance: 1_500, usedCredits: 500),
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
referrals: []
)
}
@@ -119,14 +123,22 @@ private actor AccountCenterUITestService: AccountSessionServicing, AccountCenter
)
}
func createReferralCode() async throws -> String {
"UITestInvite_1234567890"
}
func redeemReferral(code: String) async throws {
_ = code
}
func loadReferralProfile() async throws -> ReferralProfile {
ReferralProfile(
code: ReferralCode(
code: "UITestInvite_123456789",
inviteURL: URL(string: "https://osglab.com/i/UITestInvite_123456789")!,
campaignID: nil,
createdAt: Date(timeIntervalSince1970: 1_700_000_000)
),
binding: nil
)
}
func loadCreditProducts() async throws -> [AccountCreditProduct] {
[
AccountCreditProduct(productID: "500tks", credits: 500),
@@ -751,10 +751,11 @@ struct AccountInvitationButton: View {
@Environment(\.themePalette) private var palette
@State private var showsShareDrawer = false
let invitationURL: URL
let invitationURL: URL?
var body: some View {
Button {
guard invitationURL != nil else { return }
showsShareDrawer = true
} label: {
Label("account.referral.inviteTitle", systemImage: "person.badge.plus")
@@ -771,10 +772,14 @@ struct AccountInvitationButton: View {
)
}
.buttonStyle(.plain)
.disabled(invitationURL == nil)
.accessibilityIdentifier("account.referral.share")
.sheet(isPresented: $showsShareDrawer) {
AccountInvitationActivityView(invitationURL: invitationURL)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
if let invitationURL {
AccountInvitationActivityView(invitationURL: invitationURL)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
}
}
@@ -850,7 +855,6 @@ struct IAPReviewScreenshotHarness: View {
displayName: "OSG User"
),
credits: AccountCreditSummary(balance: 1_000, usedCredits: 0),
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
referrals: []
)
}
@@ -860,10 +864,6 @@ private struct IAPReviewAccountService: AccountCenterServicing {
throw AccountIntegrationError.unavailable
}
func createReferralCode() async throws -> String {
throw AccountIntegrationError.unavailable
}
func redeemReferral(code: String) async throws {}
func loadCreditProducts() async throws -> [AccountCreditProduct] {
+203 -26
View File
@@ -75,29 +75,150 @@ struct AccountReferral: Identifiable, Equatable, Sendable {
let rewardCredits: Int64?
}
struct AccountReferralProfile: Equatable, Sendable {
let code: String?
let boundCode: String?
let inviterRewardCredits: Int64?
let inviteeRewardCredits: Int64?
struct ReferralCode: Codable, Equatable, Sendable {
let code: String
let inviteURL: URL
let campaignID: String?
let createdAt: Date
init(
code: String?,
boundCode: String?,
inviterRewardCredits: Int64? = nil,
inviteeRewardCredits: Int64? = nil
) {
self.code = code
self.boundCode = boundCode
self.inviterRewardCredits = inviterRewardCredits
self.inviteeRewardCredits = inviteeRewardCredits
private enum CodingKeys: String, CodingKey {
case code
case inviteURL = "inviteUrl"
case campaignID = "campaignId"
case createdAt
}
init(code: String, inviteURL: URL, campaignID: String?, createdAt: Date) {
self.code = code
self.inviteURL = inviteURL
self.campaignID = campaignID
self.createdAt = createdAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let code = try container.decode(String.self, forKey: .code)
let inviteURL = try container.decode(URL.self, forKey: .inviteURL)
let createdAtValue = try container.decode(String.self, forKey: .createdAt)
guard ReferralUniversalLink.isValid(code: code),
inviteURL.scheme?.lowercased() == "https",
inviteURL.host != nil,
let createdAt = Self.date(from: createdAtValue) else {
throw DecodingError.dataCorrupted(
.init(
codingPath: container.codingPath,
debugDescription: "Invalid referral code, invite URL, or creation date."
)
)
}
self.code = code
self.inviteURL = inviteURL
campaignID = try container.decodeIfPresent(String.self, forKey: .campaignID)
self.createdAt = createdAt
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(code, forKey: .code)
try container.encode(inviteURL, forKey: .inviteURL)
try container.encodeIfPresent(campaignID, forKey: .campaignID)
try container.encode(Self.string(from: createdAt), forKey: .createdAt)
}
private static func date(from value: String) -> Date? {
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let standard = ISO8601DateFormatter()
standard.formatOptions = [.withInternetDateTime]
return fractional.date(from: value) ?? standard.date(from: value)
}
private static func string(from date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: date)
}
}
/// The binding payload can gain server-owned fields without breaking cached
/// profiles. The client only needs to preserve it; invitation sharing uses code.
struct ReferralBinding: Codable, Equatable, Sendable {
let fields: [String: ReferralJSONValue]
init(from decoder: Decoder) throws {
fields = try decoder.singleValueContainer().decode(
[String: ReferralJSONValue].self
)
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(fields)
}
}
enum ReferralJSONValue: Codable, Equatable, Sendable {
case string(String)
case integer(Int64)
case number(Double)
case boolean(Bool)
case object([String: ReferralJSONValue])
case array([ReferralJSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .boolean(value)
} else if let value = try? container.decode(Int64.self) {
self = .integer(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([String: ReferralJSONValue].self) {
self = .object(value)
} else if let value = try? container.decode([ReferralJSONValue].self) {
self = .array(value)
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Unsupported referral binding value."
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let value):
try container.encode(value)
case .integer(let value):
try container.encode(value)
case .number(let value):
try container.encode(value)
case .boolean(let value):
try container.encode(value)
case .object(let value):
try container.encode(value)
case .array(let value):
try container.encode(value)
case .null:
try container.encodeNil()
}
}
}
struct ReferralProfile: Codable, Equatable, Sendable {
let code: ReferralCode
let binding: ReferralBinding?
}
struct AccountCenterSnapshot: Equatable, Sendable {
let account: AccountSession
let credits: AccountCreditSummary
let referralProfile: AccountReferralProfile
let referrals: [AccountReferral]
}
@@ -158,7 +279,6 @@ protocol AccountCenterServicing: Sendable {
cachedSnapshot: AccountCenterSnapshot?
) async throws -> AccountCenterSnapshot
func updateDisplayName(_ displayName: String) async throws -> AccountSession
func createReferralCode() async throws -> String
func redeemReferral(code: String) async throws
func loadCreditProducts() async throws -> [AccountCreditProduct]
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase
@@ -195,13 +315,32 @@ extension AccountCenterServicing {
}
}
protocol ReferralProfileServicing: Sendable {
func loadReferralProfile() async throws -> ReferralProfile
}
struct AccountDependencies: Sendable {
let sessionService: any AccountSessionServicing
let centerService: any AccountCenterServicing
let referralService: any ReferralProfileServicing
init(
sessionService: any AccountSessionServicing,
centerService: any AccountCenterServicing,
referralService: (any ReferralProfileServicing)? = nil
) {
self.sessionService = sessionService
self.centerService = centerService
self.referralService = referralService ?? UnavailableReferralProfileService()
}
static let unavailable: AccountDependencies = {
let service = UnavailableAccountService()
return AccountDependencies(sessionService: service, centerService: service)
return AccountDependencies(
sessionService: service,
centerService: service,
referralService: UnavailableReferralProfileService()
)
}()
}
@@ -238,10 +377,6 @@ private struct UnavailableAccountService: AccountSessionServicing, AccountCenter
throw AccountIntegrationError.unavailable
}
func createReferralCode() async throws -> String {
throw AccountIntegrationError.unavailable
}
func redeemReferral(code: String) async throws {
throw AccountIntegrationError.unavailable
}
@@ -255,6 +390,12 @@ private struct UnavailableAccountService: AccountSessionServicing, AccountCenter
}
}
private struct UnavailableReferralProfileService: ReferralProfileServicing {
func loadReferralProfile() async throws -> ReferralProfile {
throw AccountIntegrationError.unavailable
}
}
enum ReferralUniversalLink {
static let host = "osglab.com"
static let codeLength = 22
@@ -285,10 +426,6 @@ enum ReferralUniversalLink {
}
}
static func invitationURL(for code: String) -> URL? {
guard isValid(code: code) else { return nil }
return URL(string: "https://\(host)/i/\(code)")
}
}
@MainActor
@@ -326,3 +463,43 @@ final class UserDefaultsPendingReferralCodeStore: PendingReferralCodeStoring {
defaults.removeObject(forKey: Self.storageKey)
}
}
@MainActor
protocol ReferralProfileStoring: AnyObject {
func profile(for accountID: UUID) -> ReferralProfile?
func save(_ profile: ReferralProfile, for accountID: UUID)
func removeProfile(for accountID: UUID)
}
@MainActor
final class UserDefaultsReferralProfileStore: ReferralProfileStoring {
private static let storageKeyPrefix = "account.referralProfile.v1."
private let defaults: UserDefaults
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func profile(for accountID: UUID) -> ReferralProfile? {
guard let data = defaults.data(forKey: storageKey(for: accountID)) else {
return nil
}
return try? decoder.decode(ReferralProfile.self, from: data)
}
func save(_ profile: ReferralProfile, for accountID: UUID) {
guard let data = try? encoder.encode(profile) else { return }
defaults.set(data, forKey: storageKey(for: accountID))
}
func removeProfile(for accountID: UUID) {
defaults.removeObject(forKey: storageKey(for: accountID))
}
private func storageKey(for accountID: UUID) -> String {
Self.storageKeyPrefix + accountID.uuidString.lowercased()
}
}
@@ -43,6 +43,7 @@ final class AccountSessionCoordinator: ObservableObject {
@Published private(set) var accountRefreshErrorKey: String?
let creditPurchases: AccountCreditPurchaseManager
let referralProfile: ReferralProfileViewModel
private let sessionService: any AccountSessionServicing
private let centerService: any AccountCenterServicing
@@ -60,6 +61,8 @@ final class AccountSessionCoordinator: ObservableObject {
creditStore: any AccountCreditStore = LiveAccountCreditStore(),
pendingReferralStore: any PendingReferralCodeStoring =
UserDefaultsPendingReferralCodeStore(),
referralProfileStore: any ReferralProfileStoring =
UserDefaultsReferralProfileStore(),
accountRefreshInterval: TimeInterval = 10 * 60,
now: @escaping () -> Date = Date.init,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
@@ -73,6 +76,10 @@ final class AccountSessionCoordinator: ObservableObject {
store: creditStore,
analyticsClient: analyticsClient
)
referralProfile = ReferralProfileViewModel(
service: dependencies.referralService,
store: referralProfileStore
)
self.pendingReferralStore = pendingReferralStore
self.analyticsClient = analyticsClient
self.onAccountAuthenticated = onAccountAuthenticated
@@ -107,6 +114,7 @@ final class AccountSessionCoordinator: ObservableObject {
}
await onAccountAuthenticated(session.accountID)
sessionPhase = .signedIn(session)
referralProfile.startSession(accountID: session.accountID)
await redeemPendingReferralIfNeeded()
await refreshAccountData(force: true)
} catch {
@@ -153,6 +161,7 @@ final class AccountSessionCoordinator: ObservableObject {
let session = try await sessionService.signIn(with: payload)
await onAccountAuthenticated(session.accountID)
sessionPhase = .signedIn(session)
referralProfile.startSession(accountID: session.accountID)
await redeemPendingReferralIfNeeded()
await refreshAccountData(force: true)
} catch {
@@ -210,7 +219,6 @@ final class AccountSessionCoordinator: ObservableObject {
AccountCenterSnapshot(
account: account,
credits: snapshot.credits,
referralProfile: snapshot.referralProfile,
referrals: snapshot.referrals
)
)
@@ -251,11 +259,13 @@ final class AccountSessionCoordinator: ObservableObject {
operation = .signingOut
operationErrorKey = nil
defer { operation = nil }
referralProfile.cancelRefresh()
do {
try await sessionService.signOut()
creditPurchases.reset()
clearAccountRefreshState()
referralProfile.endSession()
sessionPhase = .signedOut
snapshotPhase = .idle
} catch {
@@ -271,12 +281,14 @@ final class AccountSessionCoordinator: ObservableObject {
operation = .deletingAccount
operationErrorKey = nil
defer { operation = nil }
referralProfile.cancelRefresh()
do {
try await sessionService.deleteAccount(with: payload)
await onAccountDeleted()
creditPurchases.reset()
clearAccountRefreshState()
referralProfile.endSession(removeCache: true)
sessionPhase = .signedOut
snapshotPhase = .idle
pendingReferralStore.clear()
@@ -38,7 +38,11 @@ enum LiveAccountDependencyFactory {
grantStore: grantStore,
configuration: AppGroupStore()
)
return AccountDependencies(sessionService: service, centerService: service)
return AccountDependencies(
sessionService: service,
centerService: service,
referralService: LiveReferralProfileService(apiClient: apiClient)
)
}
}
@@ -55,21 +59,11 @@ actor AccountCenterSnapshotLoader {
) async throws -> AccountCenterSnapshot {
async let account = apiClient.account()
async let balance = resource(CreditBalanceDTO.self, .creditsBalance)
async let profile = optionalResource(
ReferralProfileDTO.self,
.referralProfile,
diagnosticName: "referral-profile"
)
async let referrals = optionalResource(
[ReferralBindingDTO].self,
.referrals(limit: 50),
diagnosticName: "referrals"
)
async let campaigns = optionalResource(
[ReferralCampaignDTO].self,
.referralCampaigns,
diagnosticName: "referral-campaigns"
)
let core: (OSGAccount, CreditBalanceDTO)
do {
@@ -81,28 +75,7 @@ actor AccountCenterSnapshotLoader {
)
throw error
}
let optionalValues = await (profile, referrals, campaigns)
let invitationCode = await resolvedInvitationCode(
profile: optionalValues.0,
cachedProfile: cachedSnapshot?.referralProfile
)
let campaign = optionalValues.2?.first {
$0.id == optionalValues.0?.code?.campaignId
} ?? optionalValues.2?.first
let referralProfile = if optionalValues.0 == nil,
let cachedProfile = cachedSnapshot?.referralProfile {
cachedProfile
} else {
AccountReferralProfile(
code: invitationCode,
boundCode: cachedSnapshot?.referralProfile.boundCode,
inviterRewardCredits: campaign?.inviterRewardCredits
?? cachedSnapshot?.referralProfile.inviterRewardCredits,
inviteeRewardCredits: campaign?.inviteeRewardCredits
?? cachedSnapshot?.referralProfile.inviteeRewardCredits
)
}
let loadedReferrals = optionalValues.1?.map {
let loadedReferrals = await referrals?.map {
AccountReferral(
id: UUID(),
status: Self.referralStatus($0.rewardStatus),
@@ -121,7 +94,6 @@ actor AccountCenterSnapshotLoader {
balance: core.1.balance,
usedCredits: core.1.lifetimeUsed ?? 0
),
referralProfile: referralProfile,
referrals: loadedReferrals
)
}
@@ -151,26 +123,6 @@ actor AccountCenterSnapshotLoader {
}
}
private func resolvedInvitationCode(
profile: ReferralProfileDTO?,
cachedProfile: AccountReferralProfile?
) async -> String? {
if let code = profile?.code?.code ?? cachedProfile?.code {
return code
}
do {
let data = try await apiClient.authorizedResourceData(.createReferralCode)
return try decoder.decode(ReferralCodeDTO.self, from: data).code
} catch {
OSGDiag.log(
"account refresh optional=referral-code fallback=cache "
+ "error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
return cachedProfile?.code
}
}
private static func referralStatus(_ value: String) -> AccountReferralStatus {
switch value.uppercased() {
case "REWARDED":
@@ -192,6 +144,24 @@ actor AccountCenterSnapshotLoader {
}
}
actor LiveReferralProfileService: ReferralProfileServicing {
private let apiClient: AccountAPIClient
private let decoder = JSONDecoder()
init(apiClient: AccountAPIClient) {
self.apiClient = apiClient
}
func loadReferralProfile() async throws -> ReferralProfile {
let data = try await apiClient.authorizedResourceData(.referralProfile)
do {
return try decoder.decode(ReferralProfile.self, from: data)
} catch {
throw AccountAPIError.decoding
}
}
}
private actor LiveAccountService: AccountSessionServicing, AccountCenterServicing {
private let apiClient: AccountAPIClient
private let accountCenterLoader: AccountCenterSnapshotLoader
@@ -356,11 +326,6 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
)
}
func createReferralCode() async throws -> String {
let data = try await apiClient.authorizedResourceData(.createReferralCode)
return try decoder.decode(ReferralCodeDTO.self, from: data).code
}
func redeemReferral(code: String) async throws {
struct Request: Encodable { let code: String }
let body = try encoder.encode(Request(code: code))
@@ -586,21 +551,6 @@ private struct CreditBalanceDTO: Decodable, Sendable {
let lifetimeUsed: Int64?
}
private struct ReferralCodeDTO: Decodable, Sendable {
let code: String
let campaignId: String?
}
private struct ReferralProfileDTO: Decodable, Sendable {
let code: ReferralCodeDTO?
}
private struct ReferralCampaignDTO: Decodable, Sendable {
let id: String
let inviterRewardCredits: Int64
let inviteeRewardCredits: Int64
}
private struct ReferralBindingDTO: Decodable, Sendable {
let boundAt: String
let rewardStatus: String
@@ -0,0 +1,197 @@
// ReferralProfileViewModel.swift
// OSGKeyboard · Main App
//
// Account-scoped invitation state. The server profile is authoritative while
// the local cache keeps the permanent invitation link visible when offline.
import Foundation
import OSGKeyboardHostSupport
@MainActor
final class ReferralProfileViewModel: ObservableObject {
enum State: Equatable {
case idle
case loading
case loaded(ReferralProfile)
case failed(messageKey: String)
}
@Published private(set) var state: State = .idle
@Published private(set) var isRefreshing = false
@Published private(set) var refreshErrorKey: String?
private let service: any ReferralProfileServicing
private let store: any ReferralProfileStoring
private var accountID: UUID?
private var requestID: UUID?
private var refreshTask: Task<Void, Never>?
init(
service: any ReferralProfileServicing,
store: any ReferralProfileStoring = UserDefaultsReferralProfileStore()
) {
self.service = service
self.store = store
}
deinit {
refreshTask?.cancel()
}
/// Starts one automatic load for this signed-in account session. Repeated
/// view appearances are ignored; ending or changing the session cancels it.
func startSession(accountID: UUID) {
guard self.accountID != accountID else { return }
cancelCurrentRequest()
self.accountID = accountID
refreshErrorKey = nil
if let cachedProfile = store.profile(for: accountID) {
state = .loaded(cachedProfile)
} else {
state = .loading
}
beginRefresh()
}
func endSession(removeCache: Bool = false) {
let previousAccountID = accountID
cancelCurrentRequest()
accountID = nil
state = .idle
refreshErrorKey = nil
if removeCache, let previousAccountID {
store.removeProfile(for: previousAccountID)
}
}
func cancelRefresh() {
guard let accountID else { return }
cancelCurrentRequest()
if case .loaded = state {
// Preserve the visible server profile.
} else if let cachedProfile = store.profile(for: accountID) {
state = .loaded(cachedProfile)
} else {
state = .idle
}
refreshErrorKey = nil
}
func refresh() async {
guard accountID != nil else {
state = .idle
return
}
if let refreshTask {
await refreshTask.value
return
}
beginRefresh()
await refreshTask?.value
}
private func beginRefresh() {
guard refreshTask == nil, let accountID else { return }
let id = UUID()
requestID = id
refreshErrorKey = nil
isRefreshing = true
if case .loaded = state {
// Keep a cached or previously loaded profile visible while refreshing.
} else {
state = .loading
}
refreshTask = Task { @MainActor [weak self] in
await self?.performRefresh(accountID: accountID, requestID: id)
}
}
private func performRefresh(accountID: UUID, requestID: UUID) async {
let previousProfile: ReferralProfile? = if case let .loaded(profile) = state {
profile
} else {
nil
}
defer {
if self.requestID == requestID {
isRefreshing = false
refreshTask = nil
self.requestID = nil
}
}
do {
try Task.checkCancellation()
let profile = try await service.loadReferralProfile()
try Task.checkCancellation()
guard self.accountID == accountID, self.requestID == requestID else {
return
}
store.save(profile, for: accountID)
state = .loaded(profile)
refreshErrorKey = nil
} catch is CancellationError {
guard self.accountID == accountID, self.requestID == requestID else {
return
}
state = previousProfile.map(State.loaded) ?? .idle
refreshErrorKey = nil
} catch {
guard self.accountID == accountID, self.requestID == requestID else {
return
}
let messageKey = Self.errorMessageKey(for: error)
refreshErrorKey = messageKey
if let previousProfile {
state = .loaded(previousProfile)
} else {
state = .failed(messageKey: messageKey)
}
}
}
private func cancelCurrentRequest() {
refreshTask?.cancel()
refreshTask = nil
requestID = nil
isRefreshing = false
}
private static func errorMessageKey(for error: Error) -> String {
guard let error = error as? AccountAPIError else {
return error as? AccountIntegrationError == .unavailable
? "account.error.unavailable"
: "account.referral.error.load"
}
switch error {
case .unauthorized, .refreshTokenReuse, .sessionUnavailable:
return "account.referral.error.session"
case .transport:
return "account.referral.error.network"
case .externalServiceUnavailable:
return "account.referral.error.unavailable"
case .conflict:
return "account.referral.error.conflict"
case .server(let statusCode, _, _):
switch statusCode {
case 401:
return "account.referral.error.session"
case 404:
return "account.referral.error.notFound"
case 409:
return "account.referral.error.conflict"
case 500..<600:
return "account.referral.error.unavailable"
default:
return "account.referral.error.load"
}
default:
return "account.referral.error.load"
}
}
}
+115 -71
View File
@@ -230,55 +230,50 @@ struct SettingsView: View {
}
private var accountRewardsEntrySection: some View {
Group {
VStack(spacing: 0) {
switch accountSession.snapshotPhase {
case let .loaded(snapshot):
let totalCredits = creditTotal(snapshot)
VStack(alignment: .leading, spacing: Spacing.sm) {
Button {
path.append(SettingsRoute.account)
} label: {
VStack(alignment: .leading, spacing: Spacing.sm) {
HStack(alignment: .top, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(creditRemainingText(snapshot.credits.balance))
.font(TypeStyle.title3.monospacedDigit())
.foregroundStyle(palette.textPrimary)
Text("account.credits.balance")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
Spacer(minLength: Spacing.xs)
VStack(alignment: .trailing, spacing: Spacing.xxs) {
Text(creditUsedText(snapshot.credits.usedCredits))
Text(creditTotalText(totalCredits))
}
.font(TypeStyle.caption)
.monospacedDigit()
.foregroundStyle(palette.textSecondary)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
.frame(minWidth: 32, minHeight: 32)
.accessibilityHidden(true)
Button {
path.append(SettingsRoute.account)
} label: {
VStack(alignment: .leading, spacing: Spacing.sm) {
HStack(alignment: .top, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(creditRemainingText(snapshot.credits.balance))
.font(TypeStyle.title3.monospacedDigit())
.foregroundStyle(palette.textPrimary)
Text("account.credits.balance")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
AccountCreditProgress(
remaining: snapshot.credits.balance,
used: snapshot.credits.usedCredits,
showsLabels: false
)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Spacer(minLength: Spacing.xs)
Divider().background(palette.divider)
invitationLink(snapshot.referralProfile)
VStack(alignment: .trailing, spacing: Spacing.xxs) {
Text(creditUsedText(snapshot.credits.usedCredits))
Text(creditTotalText(totalCredits))
}
.font(TypeStyle.caption)
.monospacedDigit()
.foregroundStyle(palette.textSecondary)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
.frame(minWidth: 32, minHeight: 32)
.accessibilityHidden(true)
}
AccountCreditProgress(
remaining: snapshot.credits.balance,
used: snapshot.credits.usedCredits,
showsLabels: false
)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(Spacing.md)
case .failed:
VStack(alignment: .leading, spacing: Spacing.sm) {
@@ -302,39 +297,13 @@ struct SettingsView: View {
}
.settingsListRow()
}
Divider().background(palette.divider)
AccountReferralLinkView(viewModel: accountSession.referralProfile)
}
.surfaceCard()
}
@ViewBuilder
private func invitationLink(_ profile: AccountReferralProfile) -> some View {
if let code = profile.code,
let invitationURL = ReferralUniversalLink.invitationURL(for: code) {
HStack(spacing: Spacing.md) {
Text(
AppL10n.string(
"account.referral.equalRewardDescription",
language: config.uiLanguage
)
)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: Spacing.xs)
AccountInvitationButton(invitationURL: invitationURL)
}
} else {
HStack(spacing: Spacing.sm) {
ProgressView()
.controlSize(.small)
.tint(palette.accent)
Text("account.referral.preparingLink")
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
}
}
}
private func creditTotal(_ snapshot: AccountCenterSnapshot) -> Int64 {
let remaining = max(snapshot.credits.balance, 0)
let used = max(snapshot.credits.usedCredits, 0)
@@ -493,6 +462,81 @@ struct SettingsView: View {
}
}
private struct AccountReferralLinkView: View {
@Environment(\.themePalette) private var palette
@ObservedObject var viewModel: ReferralProfileViewModel
var body: some View {
Group {
switch viewModel.state {
case .idle:
retryRow(
messageKey: "account.referral.loadLink",
actionKey: "account.referral.loadLink"
)
case .loading:
HStack(spacing: Spacing.sm) {
ProgressView()
.controlSize(.small)
.tint(palette.accent)
Text("account.referral.loadingLink")
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
}
case .failed(let messageKey):
retryRow(messageKey: messageKey, actionKey: "account.retry")
case .loaded(let profile):
loadedContent(profile)
}
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("account.referral.profile")
}
private func loadedContent(_ profile: ReferralProfile) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
HStack(spacing: Spacing.md) {
Text("account.referral.equalRewardDescription")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: Spacing.xs)
AccountInvitationButton(invitationURL: profile.code.inviteURL)
}
if viewModel.isRefreshing {
HStack(spacing: Spacing.xs) {
ProgressView()
.controlSize(.mini)
Text("account.referral.refreshingLink")
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
} else if let refreshErrorKey = viewModel.refreshErrorKey {
retryRow(messageKey: refreshErrorKey, actionKey: "account.retry")
}
}
}
private func retryRow(
messageKey: String,
actionKey: LocalizedStringKey
) -> some View {
HStack(spacing: Spacing.sm) {
Text(LocalizedStringKey(messageKey))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
Spacer(minLength: Spacing.xs)
Button(actionKey) {
Task { await viewModel.refresh() }
}
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.accent)
}
}
}
// MARK: - Sheet dismiss (isolated from Settings root)
private struct SettingsSheetDismissButton: View {
+11 -2
View File
@@ -824,8 +824,17 @@
"account.referral.equalRewardDescription" = "For each successful invitation, you and your friend each receive 1,000 credits.";
"account.referral.equalRewardGeneric" = "For each successful invitation, both of you receive credits.";
"account.referral.summaryCompact" = "Rewarded %d · Pending %d";
"account.referral.preparingLink" = "Preparing your invitation link…";
"account.referral.createCode" = "Create invitation code";
"account.referral.preparingLink" = "Loading your invitation link…";
"account.referral.createCode" = "Load invitation link";
"account.referral.loadLink" = "Load invitation link";
"account.referral.loadingLink" = "Loading your invitation link…";
"account.referral.refreshingLink" = "Refreshing invitation link…";
"account.referral.error.load" = "Couldnt load your invitation link.";
"account.referral.error.session" = "Sign in again to load your invitation link.";
"account.referral.error.network" = "Check your connection and try again.";
"account.referral.error.notFound" = "Your invitation link isnt available yet.";
"account.referral.error.conflict" = "Your invitation link is temporarily unavailable.";
"account.referral.error.unavailable" = "The invitation service is temporarily unavailable.";
"account.referral.status.pending" = "Pending";
"account.referral.status.rewarded" = "Rewarded";
"account.referral.status.ineligible" = "Ineligible";
+11 -2
View File
@@ -823,8 +823,17 @@
"account.referral.equalRewardDescription" = "每成功邀请一位好友,你和好友各获得 1000 积分。";
"account.referral.equalRewardGeneric" = "每成功邀请一位好友,双方均可获得积分。";
"account.referral.summaryCompact" = "已奖励 %d · 待确认 %d";
"account.referral.preparingLink" = "正在生成邀请链接…";
"account.referral.createCode" = "生成邀请码";
"account.referral.preparingLink" = "正在加载邀请链接…";
"account.referral.createCode" = "加载邀请链接";
"account.referral.loadLink" = "加载邀请链接";
"account.referral.loadingLink" = "正在加载邀请链接…";
"account.referral.refreshingLink" = "正在刷新邀请链接…";
"account.referral.error.load" = "无法加载邀请链接。";
"account.referral.error.session" = "请重新登录后加载邀请链接。";
"account.referral.error.network" = "请检查网络连接后重试。";
"account.referral.error.notFound" = "邀请链接暂不可用。";
"account.referral.error.conflict" = "邀请链接暂时不可用。";
"account.referral.error.unavailable" = "邀请服务暂时不可用。";
"account.referral.status.pending" = "待确认";
"account.referral.status.rewarded" = "已奖励";
"account.referral.status.ineligible" = "不符合条件";