Version: CURRENT_PROJECT_VERSION 130 -> 131 (project.yml, AGENTS.md, READMEs, docs/llms.txt). fix(flow): host-app identification now compiles in every build configuration (Debug, TestFlight, Release). The arbiter probe and HostAppIdentity were DEBUG-only, so TestFlight/App Store builds never knew the host bundle id and could not auto-return after a startflow handoff. Debug-only logging and the Home test card are unchanged. Also includes the pending work in the tree: - Home: single account/setup card surface (HomeAccountSurfacePolicy/Views) - Wide screens: voice key row and centred mic placement, iPhone Duo layout - Keyboard: clipboard skill ranking and AI skill layout updates - Debug: account session handoff to the Simulator (+ import script) - Docs: portfolio assets, privacy policy URL, site/README refresh
255 lines
9.6 KiB
Swift
255 lines
9.6 KiB
Swift
// AccountRewardsCard.swift
|
|
// OSGKeyboard · Main App
|
|
//
|
|
// Quiet signed-in account summary on Home: credit progress plus invitation
|
|
// controls. `HomeAccountSurfacePolicy` decides when it appears; signed-out and
|
|
// problem states use `HomeAccountProblemCard` instead.
|
|
|
|
import OSGKeyboardShared
|
|
import SwiftUI
|
|
|
|
struct AccountRewardsCard: View {
|
|
@Environment(\.themePalette) private var palette
|
|
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
|
@ObservedObject private var config = ProviderConfig.shared
|
|
|
|
/// BYOK users keep credits as account metadata, so the card says which
|
|
/// service is actually in use.
|
|
var showsOwnKeyNote = false
|
|
let onOpenAccount: () -> Void
|
|
|
|
var body: some View {
|
|
signedInContent
|
|
.surfaceCard()
|
|
}
|
|
|
|
private var signedInContent: some View {
|
|
VStack(spacing: 0) {
|
|
switch coordinator.snapshotPhase {
|
|
case let .loaded(snapshot):
|
|
creditSummary(snapshot)
|
|
case .failed:
|
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
|
Text(AppL10n.string("account.error.load"))
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
Button(AppL10n.string("account.retry")) {
|
|
Task { await coordinator.refreshAccountData(force: true) }
|
|
}
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.accent)
|
|
}
|
|
.padding(Spacing.md)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
case .idle, .loading:
|
|
loadingContent("account.loading.center")
|
|
}
|
|
|
|
Divider().background(palette.divider)
|
|
AccountReferralLinkView(
|
|
viewModel: coordinator.referralProfile,
|
|
session: signedInSession
|
|
)
|
|
}
|
|
}
|
|
|
|
private var signedInSession: AccountSession? {
|
|
guard case let .signedIn(session) = coordinator.sessionPhase else { return nil }
|
|
return session
|
|
}
|
|
|
|
private func creditSummary(_ snapshot: AccountCenterSnapshot) -> some View {
|
|
let totalCredits = creditTotal(snapshot)
|
|
return Button(action: onOpenAccount) {
|
|
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(AppL10n.string("account.credits.balance"))
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textSecondary)
|
|
if showsOwnKeyNote {
|
|
Text(AppL10n.string("home.account.usingOwnKey"))
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
}
|
|
}
|
|
|
|
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(TypeStyle.caption.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)
|
|
}
|
|
|
|
private func loadingContent(_ key: String) -> some View {
|
|
HStack(spacing: Spacing.sm) {
|
|
ProgressView()
|
|
.tint(palette.accent)
|
|
Text(AppL10n.string(key))
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
Spacer()
|
|
}
|
|
.settingsListRow()
|
|
}
|
|
|
|
private func creditTotal(_ snapshot: AccountCenterSnapshot) -> Int64 {
|
|
let remaining = max(snapshot.credits.balance, 0)
|
|
let used = max(snapshot.credits.usedCredits, 0)
|
|
let (total, overflow) = remaining.addingReportingOverflow(used)
|
|
return overflow ? Int64.max : total
|
|
}
|
|
|
|
private func creditRemainingText(_ remaining: Int64) -> String {
|
|
AppL10n.format(
|
|
"account.credits.remainingCompact",
|
|
language: config.uiLanguage,
|
|
max(remaining, 0).formatted(.number.grouping(.automatic))
|
|
)
|
|
}
|
|
|
|
private func creditUsedText(_ used: Int64) -> String {
|
|
AppL10n.format(
|
|
"account.credits.usedCompact",
|
|
language: config.uiLanguage,
|
|
max(used, 0).formatted(.number.grouping(.automatic))
|
|
)
|
|
}
|
|
|
|
private func creditTotalText(_ total: Int64) -> String {
|
|
AppL10n.format(
|
|
"account.credits.totalValueCompact",
|
|
language: config.uiLanguage,
|
|
total.formatted(.number.grouping(.automatic))
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
private struct AccountReferralLinkView: View {
|
|
@Environment(\.themePalette) private var palette
|
|
@ObservedObject var viewModel: ReferralProfileViewModel
|
|
let session: AccountSession?
|
|
|
|
@State private var isCodeEntryPresented = false
|
|
|
|
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(AppL10n.string("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(AppL10n.string(
|
|
showsCodeEntry(profile)
|
|
? "account.referral.codeEntry.windowDescription"
|
|
: "account.referral.equalRewardDescription"
|
|
))
|
|
.font(TypeStyle.body.weight(.bold))
|
|
.foregroundStyle(palette.textSecondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Spacer(minLength: Spacing.xs)
|
|
if showsCodeEntry(profile) {
|
|
// Inviting stays reachable from the bottom of the sheet.
|
|
ReferralCodeEntryButton(
|
|
isPresented: $isCodeEntryPresented,
|
|
invitation: profile.code
|
|
)
|
|
} else {
|
|
AccountInvitationButton(
|
|
invitationURL: profile.code.inviteURL,
|
|
shortCode: profile.code.displayShortCode
|
|
)
|
|
}
|
|
}
|
|
|
|
if viewModel.isRefreshing {
|
|
HStack(spacing: Spacing.xs) {
|
|
ProgressView()
|
|
.controlSize(.mini)
|
|
Text(AppL10n.string("account.referral.refreshingLink"))
|
|
}
|
|
.font(TypeStyle.caption)
|
|
.foregroundStyle(palette.textTertiary)
|
|
} else if let refreshErrorKey = viewModel.refreshErrorKey {
|
|
retryRow(messageKey: refreshErrorKey, actionKey: "account.retry")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// New accounts that can still bind an inviter see "I have a code" in
|
|
/// place of Invite. The open sheet keeps the button mounted, so a
|
|
/// redemption that closes the window cannot tear the sheet down mid-confirmation.
|
|
private func showsCodeEntry(_ profile: ReferralProfile) -> Bool {
|
|
if isCodeEntryPresented { return true }
|
|
guard let session else { return false }
|
|
return ReferralBindingWindow.isOpen(for: session, profile: profile)
|
|
}
|
|
|
|
private func retryRow(
|
|
messageKey: String,
|
|
actionKey: String
|
|
) -> some View {
|
|
HStack(spacing: Spacing.sm) {
|
|
Text(AppL10n.string(messageKey))
|
|
.font(TypeStyle.footnote)
|
|
.foregroundStyle(palette.textSecondary)
|
|
Spacer(minLength: Spacing.xs)
|
|
Button(AppL10n.string(actionKey)) {
|
|
Task { await viewModel.refresh() }
|
|
}
|
|
.font(TypeStyle.bodyEmph)
|
|
.foregroundStyle(palette.accent)
|
|
}
|
|
}
|
|
}
|