Files
OSGKeyboard/OSGKeyboardMac/MacAccountView.swift
T
rockyandClaude Opus 5 5d51c5bf77 chore: snapshot uncommitted local work before rebasing onto cloud-agent
Safety commit so the 94 uncommitted files in the working tree (in-flight
Account/Mac/Tip work plus the feature-preview recording tooling) survive the
move onto cursor/cloud-agent-1787824905651-vxuwc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:28:07 -07:00

364 lines
14 KiB
Swift

// MacAccountView.swift
// OSGKeyboard · Mac
//
// `MacAccountSettingsSection` — the account card that opens the Settings
// page: Sign in with Apple, credit balance, and the permanent invitation
// link. Everything here reuses the iOS
// account stack (`AccountSessionCoordinator`, `LiveAccountServices`,
// `ReferralProfileViewModel`) compiled at source level — see project.yml.
//
// Unlike iOS there is no separate Account tab. macOS has one settings surface,
// so the account is simply the first `MacSettingsSection` on it and every row
// sits on the same grid (`settingsCardInset` + `settingsRowMinHeight`) as the
// provider rows below — the old standalone page stacked free-floating VStacks
// that lined up with nothing.
//
// Deliberately no purchase UI. A Developer ID build cannot use StoreKit, so
// credits are bought on iPhone or the web and only *spent* here; the balance
// is server-authoritative either way.
import SwiftUI
#if os(macOS)
import AppKit
#endif
struct MacAccountSettingsSection: View {
@EnvironmentObject private var coordinator: AccountSessionCoordinator
@Environment(\.themePalette) private var palette
let language: AppUILanguage
@State private var isConfirmingDelete = false
private var lang: AppUILanguage { language }
var body: some View {
MacSettingsSection(
title: MacL10n.string("mac.section.account", language: lang),
footer: footer
) {
VStack(spacing: MacMetrics.settingsRowGap) {
switch coordinator.sessionPhase {
case .restoring:
statusRow(MacL10n.string("mac.account.restoring", language: lang))
case .signedOut:
signedOutRow
case let .signedIn(session):
identityRow(session)
creditRows
MacInviteLinkRow(profile: coordinator.referralProfile, language: lang)
referralCountRows
accountActionsRow
deleteRows
}
}
}
.task { await coordinator.restoreIfNeeded() }
.alert(
MacL10n.string("mac.account.error.title", language: lang),
isPresented: operationErrorBinding
) {
Button(MacL10n.string("mac.account.error.dismiss", language: lang)) {
coordinator.dismissOperationError()
}
} message: {
if let key = coordinator.operationErrorKey {
// The code is appended verbatim: on Mac there is no TestFlight
// crash trail behind a failed sign-in, and "登录失败,请重试"
// alone is not something anyone can act on.
Text(alertMessage(for: key))
}
}
}
private var footer: String {
switch coordinator.sessionPhase {
case .signedOut, .restoring:
return MacL10n.string("mac.account.signedOut.footnote", language: lang)
case .signedIn:
return MacL10n.string("mac.account.credits.purchaseNote", language: lang)
}
}
private func alertMessage(for key: String) -> String {
let message = MacL10n.string(key, language: lang)
guard let detail = coordinator.operationErrorDetail else { return message }
return "\(message)\n(\(detail))"
}
// MARK: - Signed out
/// Title + explanation on the left, the Apple button right-aligned — the
/// same two-column shape every other settings row uses.
private var signedOutRow: some View {
MacInlineRow(
title: MacL10n.string("mac.account.signedOut.title", language: lang),
subtitle: MacL10n.string("mac.account.signedOut.body", language: lang)
) {
MacAccountAppleButton(purpose: .signIn, language: lang)
}
}
// MARK: - Signed in
private func identityRow(_ session: AccountSession) -> some View {
MacInlineRow(
title: session.displayName?.isEmpty == false
? session.displayName!
: MacL10n.string("mac.account.profile.unnamed", language: lang),
subtitle: MacL10n.format(
"mac.account.profile.accountID",
language: lang,
String(session.accountID.uuidString.prefix(8)).lowercased()
)
) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.accent)
}
}
@ViewBuilder
private var creditRows: some View {
switch coordinator.snapshotPhase {
case .idle, .loading:
statusRow(MacL10n.string("mac.account.credits.loading", language: lang))
case let .loaded(snapshot):
MacInlineRow(title: MacL10n.string("mac.account.credits.balance", language: lang)) {
Text("\(snapshot.credits.balance)")
.font(TypeStyle.title3)
.monospacedDigit()
.foregroundStyle(palette.accent)
}
MacInlineRow(title: MacL10n.string("mac.account.credits.used", language: lang)) {
Text("\(snapshot.credits.usedCredits)")
.font(MacSettingsType.rowLabel)
.monospacedDigit()
.foregroundStyle(palette.textSecondary)
}
case let .failed(messageKey):
MacInlineRow(
title: MacL10n.string("mac.account.credits.balance", language: lang),
subtitle: MacL10n.string(messageKey, language: lang)
) {
EmptyView()
}
}
}
@ViewBuilder
private var referralCountRows: some View {
if case let .loaded(snapshot) = coordinator.snapshotPhase {
let summary = AccountReferralSummary(referrals: snapshot.referrals)
MacInlineRow(title: MacL10n.string("mac.account.invite.rewarded", language: lang)) {
Text("\(summary.rewarded)")
.font(MacSettingsType.rowLabel)
.monospacedDigit()
.foregroundStyle(palette.textSecondary)
}
MacInlineRow(title: MacL10n.string("mac.account.invite.pending", language: lang)) {
Text("\(summary.pending)")
.font(MacSettingsType.rowLabel)
.monospacedDigit()
.foregroundStyle(palette.textSecondary)
}
}
}
/// Refresh / sign out share one row so the card ends on the row grid rather
/// than on two stacked full-width buttons.
private var accountActionsRow: some View {
HStack(spacing: Spacing.lg) {
linkButton(
MacL10n.string("mac.account.credits.refresh", language: lang),
isBusy: coordinator.isRefreshingAccountData
) {
Task { await coordinator.refreshAccountData(force: true) }
}
linkButton(
MacL10n.string("mac.account.signOut", language: lang),
isBusy: coordinator.operation == .signingOut
) {
Task { await coordinator.signOut() }
}
Spacer(minLength: 0)
}
.padding(.horizontal, MacMetrics.settingsCardInset)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
}
@ViewBuilder
private var deleteRows: some View {
if isConfirmingDelete {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(MacL10n.string("mac.account.delete.confirmBody", language: lang))
.font(MacSettingsType.hint)
.foregroundStyle(palette.danger)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: Spacing.lg) {
// Apple requires re-authentication before deletion, so this
// is a second Sign in with Apple pass, not a confirm button.
MacAccountAppleButton(
purpose: .deleteAccount,
language: lang,
onDeleted: { isConfirmingDelete = false }
)
Button(MacL10n.string("mac.account.delete.cancel", language: lang)) {
isConfirmingDelete = false
}
.buttonStyle(.plain)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textSecondary)
Spacer(minLength: 0)
}
}
.padding(.horizontal, MacMetrics.settingsCardInset)
} else {
MacInlineRow(
title: MacL10n.string("mac.account.delete.title", language: lang),
subtitle: MacL10n.string("mac.account.delete.body", language: lang)
) {
Button(MacL10n.string("mac.account.delete.action", language: lang)) {
isConfirmingDelete = true
}
.buttonStyle(.plain)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.danger)
.accessibilityIdentifier("mac.account.delete.action")
}
}
}
// MARK: - Helpers
private func statusRow(_ text: String) -> some View {
HStack(spacing: Spacing.sm) {
ProgressView().controlSize(.small)
Text(text)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textSecondary)
Spacer(minLength: 0)
}
.padding(.horizontal, MacMetrics.settingsCardInset)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
}
private func linkButton(
_ title: String,
isBusy: Bool,
action: @escaping () -> Void
) -> some View {
HStack(spacing: Spacing.xs) {
Button(title, action: action)
.buttonStyle(.plain)
.font(MacSettingsType.rowLabel)
.foregroundStyle(isBusy ? palette.textTertiary : palette.accent)
.disabled(isBusy)
if isBusy {
ProgressView().controlSize(.small)
}
}
}
private var operationErrorBinding: Binding<Bool> {
Binding(
get: { coordinator.operationErrorKey != nil },
set: { isPresented in
if !isPresented { coordinator.dismissOperationError() }
}
)
}
}
// MARK: - Invitation link
/// Separate view so the nested `ReferralProfileViewModel` is observed
/// directly — the parent only observes `AccountSessionCoordinator`, and
/// SwiftUI does not propagate a child ObservableObject's changes through it.
private struct MacInviteLinkRow: View {
@ObservedObject var profile: ReferralProfileViewModel
@Environment(\.themePalette) private var palette
let language: AppUILanguage
@State private var didCopy = false
@State private var copyResetTask: Task<Void, Never>?
var body: some View {
content
.onDisappear { copyResetTask?.cancel() }
}
@ViewBuilder
private var content: some View {
switch profile.state {
case .idle, .loading:
HStack(spacing: Spacing.sm) {
ProgressView().controlSize(.small)
Text(MacL10n.string("mac.account.invite.loading", language: language))
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textSecondary)
Spacer(minLength: 0)
}
.padding(.horizontal, MacMetrics.settingsCardInset)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
case let .loaded(loaded):
MacInlineRow(
title: MacL10n.string("mac.account.invite.link", language: language),
subtitle: loaded.code.inviteURL.absoluteString
) {
Button {
copy(loaded.code.inviteURL)
} label: {
Label(
MacL10n.string(
didCopy
? "mac.account.invite.copied"
: "mac.account.invite.copy",
language: language
),
systemImage: didCopy ? "checkmark" : "doc.on.doc"
)
.font(MacSettingsType.rowLabel)
}
.buttonStyle(.plain)
.foregroundStyle(palette.accent)
.accessibilityIdentifier("mac.account.invite.copy")
}
case let .failed(messageKey):
MacInlineRow(
title: MacL10n.string("mac.account.invite.link", language: language),
subtitle: MacL10n.string(messageKey, language: language)
) {
Button(MacL10n.string("mac.account.invite.retry", language: language)) {
Task { await profile.refresh() }
}
.buttonStyle(.plain)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.accent)
}
}
}
private func copy(_ url: URL) {
#if os(macOS)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(url.absoluteString, forType: .string)
#endif
didCopy = true
copyResetTask?.cancel()
copyResetTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard !Task.isCancelled else { return }
didCopy = false
}
}
}