fix(account): load permanent referral profiles
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:
@@ -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] {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" = "Couldn’t 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 isn’t 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";
|
||||
|
||||
@@ -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" = "不符合条件";
|
||||
|
||||
Reference in New Issue
Block a user