Files
OSGKeyboard/OSGKeyboard/Views/Account/AccountRewardsCard.swift
T
Rocky 4dce67df84 chore(release): bump version to 2.1.6 (build 131)
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
2026-10-29 11:54:13 +08:00

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)
}
}
}