feat(account): add QR invitation poster and 8-character invitation codes

- Invite opens an appearance-matched poster with the app icon, inviter name,
  QR code, and short code; tap for a full-brightness QR view; save (add-only
  Photos access), share, or copy the link. Mac shows the poster in a popover
  and can save it as PNG.
- Accounts get look-alike-free 8-character codes. "I have a code" accepts a
  code, link, or pasted message during the server-defined binding window.
- Permanently rejected redemptions show a specific message and drop the
  stored code instead of retrying on every sign-in.
This commit is contained in:
2026-10-20 20:06:36 +02:00
parent eaacc35cf7
commit 03bfbbe08b
26 changed files with 2269 additions and 86 deletions
+2
View File
@@ -108,6 +108,8 @@
<string>OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.</string>
<key>NSMicrophoneUsageDescription</key>
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>OSGKeyboard saves your invitation poster to Photos only when you choose Save Image.</string>
<key>NSRemindersFullAccessUsageDescription</key>
<string>OSGKeyboard adds reminders only when you choose Extract To-dos for copied text.</string>
<key>NSSpeechRecognitionUsageDescription</key>
@@ -0,0 +1,88 @@
// ScreenBrightnessBoost.swift
// OSGKeyboard · Main App
//
// Reference-counted full-brightness hold for content another device scans,
// such as the invitation QR code. iOS keeps an app-set brightness after the
// app leaves the foreground, so the hold restores the user's level while the
// app is in the background and reapplies it on return.
import SwiftUI
import UIKit
@MainActor
protocol ScreenBrightnessControlling: AnyObject {
var brightness: CGFloat { get set }
}
extension UIScreen: ScreenBrightnessControlling {}
@MainActor
final class ScreenBrightnessBoost {
static let shared = ScreenBrightnessBoost(screenProvider: foregroundScreen)
private let screenProvider: @MainActor () -> ScreenBrightnessControlling?
private var holdCount = 0
private var isInForeground = true
/// Set only while boosted, so the user's level is captured exactly once.
private var boost: (screen: ScreenBrightnessControlling, originalBrightness: CGFloat)?
init(screenProvider: @escaping @MainActor () -> ScreenBrightnessControlling?) {
self.screenProvider = screenProvider
}
func acquire() {
holdCount += 1
update()
}
func release() {
guard holdCount > 0 else { return }
holdCount -= 1
update()
}
func setInForeground(_ inForeground: Bool) {
isInForeground = inForeground
update()
}
private func update() {
let wantsBoost = holdCount > 0 && isInForeground
if wantsBoost, boost == nil, let screen = screenProvider() {
boost = (screen, screen.brightness)
screen.brightness = 1
} else if !wantsBoost, let boost {
boost.screen.brightness = boost.originalBrightness
self.boost = nil
}
}
private static func foregroundScreen() -> ScreenBrightnessControlling? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }?
.screen
}
}
extension View {
/// Holds full screen brightness while this view is on screen.
func boostsScreenBrightness() -> some View {
modifier(ScreenBrightnessBoostModifier())
}
}
private struct ScreenBrightnessBoostModifier: ViewModifier {
@Environment(\.scenePhase) private var scenePhase
func body(content: Content) -> some View {
content
.onAppear { ScreenBrightnessBoost.shared.acquire() }
.onDisappear { ScreenBrightnessBoost.shared.release() }
// `.inactive` is skipped on purpose: Control Center makes the scene
// inactive, and restoring there would fight a manual adjustment.
.onChange(of: scenePhase) { _, phase in
ScreenBrightnessBoost.shared.setInForeground(phase != .background)
}
}
}
@@ -80,19 +80,34 @@ struct ReferralCode: Codable, Equatable, Sendable {
let inviteURL: URL
let campaignID: String?
let createdAt: Date
/// 8-character alias friends can type; nil until the server assigns one.
let shortCode: String?
private enum CodingKeys: String, CodingKey {
case code
case inviteURL = "inviteUrl"
case campaignID = "campaignId"
case createdAt
case shortCode
}
init(code: String, inviteURL: URL, campaignID: String?, createdAt: Date) {
init(
code: String,
inviteURL: URL,
campaignID: String?,
createdAt: Date,
shortCode: String? = nil
) {
self.code = code
self.inviteURL = inviteURL
self.campaignID = campaignID
self.createdAt = createdAt
self.shortCode = shortCode.flatMap(ReferralShortCode.normalize)
}
/// `XXXX-XXXX` form for posters and share sheets.
var displayShortCode: String? {
shortCode.map(ReferralShortCode.display)
}
init(from decoder: Decoder) throws {
@@ -115,6 +130,10 @@ struct ReferralCode: Codable, Equatable, Sendable {
self.inviteURL = inviteURL
campaignID = try container.decodeIfPresent(String.self, forKey: .campaignID)
self.createdAt = createdAt
// A malformed alias only hides the short code; the link still works.
shortCode = (try? container.decodeIfPresent(String.self, forKey: .shortCode))
.flatMap { $0 }
.flatMap(ReferralShortCode.normalize)
}
func encode(to encoder: Encoder) throws {
@@ -123,9 +142,10 @@ struct ReferralCode: Codable, Equatable, Sendable {
try container.encode(inviteURL, forKey: .inviteURL)
try container.encodeIfPresent(campaignID, forKey: .campaignID)
try container.encode(Self.string(from: createdAt), forKey: .createdAt)
try container.encodeIfPresent(shortCode, forKey: .shortCode)
}
private static func date(from value: String) -> Date? {
static func date(from value: String) -> Date? {
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let standard = ISO8601DateFormatter()
@@ -133,7 +153,7 @@ struct ReferralCode: Codable, Equatable, Sendable {
return fractional.date(from: value) ?? standard.date(from: value)
}
private static func string(from date: Date) -> String {
static func string(from date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: date)
@@ -214,6 +234,40 @@ enum ReferralJSONValue: Codable, Equatable, Sendable {
struct ReferralProfile: Codable, Equatable, Sendable {
let code: ReferralCode
let binding: ReferralBinding?
/// Last moment this account may still bind an inviter. The server omits it
/// once bound or when no campaign is active; older servers never send it.
let bindingDeadline: Date?
private enum CodingKeys: String, CodingKey {
case code
case binding
case bindingDeadline
}
init(code: ReferralCode, binding: ReferralBinding?, bindingDeadline: Date? = nil) {
self.code = code
self.binding = binding
self.bindingDeadline = bindingDeadline
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decode(ReferralCode.self, forKey: .code)
binding = try container.decodeIfPresent(ReferralBinding.self, forKey: .binding)
bindingDeadline = (try? container.decodeIfPresent(String.self, forKey: .bindingDeadline))
.flatMap { $0 }
.flatMap(ReferralCode.date(from:))
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(code, forKey: .code)
try container.encodeIfPresent(binding, forKey: .binding)
try container.encodeIfPresent(
bindingDeadline.map(ReferralCode.string(from:)),
forKey: .bindingDeadline
)
}
}
struct AccountCenterSnapshot: Equatable, Sendable {
@@ -448,6 +502,38 @@ enum ReferralUniversalLink {
return code
}
/// Extracts a code from typed or pasted text: a short code, a bare long
/// code, an invitation link, or a chat message that contains one of them.
/// Links are matched with the same host and path rules as Universal Links,
/// so arbitrary URLs never qualify. Short codes are returned normalized
/// (uppercase, no hyphen); the server resolves them to the long code.
static func code(fromUserInput text: String) -> String? {
let separators = CharacterSet.whitespacesAndNewlines
.union(CharacterSet(charactersIn: ",。;:、!?「」『』“”‘’<>()()【】《》"))
let tokens = text
.components(separatedBy: separators)
.filter { !$0.isEmpty }
for token in tokens {
if isValid(code: token) {
return token
}
// Chat apps often glue the link to the preceding sentence.
let link = token.range(of: "https://", options: .caseInsensitive)
.map { String(token[$0.lowerBound...]) } ?? token
if let url = URL(string: link), let code = code(from: url) {
return code
}
}
// Short codes go last: an 8-letter word in a pasted message must not
// shadow the link that message carries.
return ReferralShortCode.firstMatch(in: text)
}
/// Anything the bind endpoint accepts: a long code or a normalized short code.
static func isRedeemable(code: String) -> Bool {
isValid(code: code) || ReferralShortCode.isCanonical(code)
}
static func isValid(code: String) -> Bool {
guard code.utf8.count == codeLength else { return false }
return code.utf8.allSatisfy { byte in
@@ -461,6 +547,73 @@ enum ReferralUniversalLink {
}
/// 8-character typeable alias of a referral code. Mirrors the server's
/// `ReferralShortCode`: the alphabet drops 0/O and 1/I/L so a code read aloud
/// or copied from a poster cannot be mistyped into another valid code.
enum ReferralShortCode {
static let length = 8
static let alphabet: Set<Character> = Set("23456789ABCDEFGHJKMNPQRSTUVWXYZ")
static func isCanonical(_ value: String) -> Bool {
value.count == length && value.allSatisfy(alphabet.contains)
}
/// Accepts `K7M2Q9XA` or `k7m2-q9xa`; returns the stored uppercase form.
static func normalize(_ raw: String) -> String? {
var value = raw.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
if value.count == length + 1, value[value.index(value.startIndex, offsetBy: 4)] == "-" {
value.remove(at: value.index(value.startIndex, offsetBy: 4))
}
return isCanonical(value) ? value : nil
}
static func display(_ value: String) -> String {
guard isCanonical(value) else { return value }
return "\(value.prefix(4))-\(value.suffix(4))"
}
/// Finds a short code inside free text such as "邀请码 K7M2-Q9XA" or a
/// Chinese sentence glued to the code, which whitespace splitting misses.
static func firstMatch(in text: String) -> String? {
// Runs of code-like ASCII; anything else (spaces, CJK, punctuation)
// separates them, so a run is exactly one candidate. "_" stays inside
// a run so a fragment of a long code never reads as a short code.
let runs = text.split { character in
!(character.isASCII
&& (character.isLetter || character.isNumber || character == "-" || character == "_"))
}
return runs.lazy.compactMap { normalize(String($0)) }.first
}
}
/// When a signed-in account may still bind an inviter. Prefers the server's
/// `bindingDeadline`, which reflects the active campaign; without it, falls
/// back to the default `REFERRAL_BINDING_DAYS`. The server stays
/// authoritative, so a mismatch still surfaces as a redemption error.
enum ReferralBindingWindow {
static let duration: TimeInterval = 7 * 24 * 60 * 60
static func isOpen(for account: AccountSession, now: Date = Date()) -> Bool {
// Zero means the account record has not loaded yet.
guard account.createdAtEpochSeconds > 0 else { return false }
let registeredAt = Date(timeIntervalSince1970: TimeInterval(account.createdAtEpochSeconds))
return now.timeIntervalSince(registeredAt) < duration
}
/// Open only while unbound and before the deadline the server reported.
static func isOpen(
for account: AccountSession,
profile: ReferralProfile,
now: Date = Date()
) -> Bool {
guard profile.binding == nil else { return false }
if let deadline = profile.bindingDeadline {
return now <= deadline
}
return isOpen(for: account, now: now)
}
}
@MainActor
protocol PendingReferralCodeStoring: AnyObject {
var code: String? { get }
@@ -480,7 +633,7 @@ final class UserDefaultsPendingReferralCodeStore: PendingReferralCodeStoring {
var code: String? {
guard let value = defaults.string(forKey: Self.storageKey),
ReferralUniversalLink.isValid(code: value)
ReferralUniversalLink.isRedeemable(code: value)
else {
return nil
}
@@ -488,7 +641,7 @@ final class UserDefaultsPendingReferralCodeStore: PendingReferralCodeStoring {
}
func save(_ code: String) {
guard ReferralUniversalLink.isValid(code: code) else { return }
guard ReferralUniversalLink.isRedeemable(code: code) else { return }
defaults.set(code, forKey: Self.storageKey)
}
@@ -29,7 +29,8 @@ struct AccountCenterUITestHarness: View {
"--account-snapshot-failure"
),
startsSignedOut: startsSignIn || staysSignedOut,
signInDelay: startsSignIn ? .seconds(5) : .zero
signInDelay: startsSignIn ? .seconds(5) : .zero,
isInsideReferralWindow: arguments.contains("--account-referral-window")
)
self.startsSignIn = startsSignIn
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
@@ -122,15 +123,18 @@ private actor AccountCenterUITestService:
private let shouldFailSnapshot: Bool
private let startsSignedOut: Bool
private let signInDelay: Duration
private let isInsideReferralWindow: Bool
init(
shouldFailSnapshot: Bool = false,
startsSignedOut: Bool = false,
signInDelay: Duration = .zero
signInDelay: Duration = .zero,
isInsideReferralWindow: Bool = false
) {
self.shouldFailSnapshot = shouldFailSnapshot
self.startsSignedOut = startsSignedOut
self.signInDelay = signInDelay
self.isInsideReferralWindow = isInsideReferralWindow
}
func restoreSession() async throws -> AccountSession? {
@@ -180,9 +184,12 @@ private actor AccountCenterUITestService:
code: "UITestInvite_123456789",
inviteURL: URL(string: "https://osglab.com/i/UITestInvite_123456789")!,
campaignID: nil,
createdAt: Date(timeIntervalSince1970: 1_700_000_000)
createdAt: Date(timeIntervalSince1970: 1_700_000_000),
shortCode: "K7M2Q9XA"
),
binding: nil
binding: nil,
// The fixed account is years old; a server deadline reopens the window.
bindingDeadline: isInsideReferralWindow ? Date().addingTimeInterval(3 * 86_400) : nil
)
}
@@ -111,12 +111,7 @@ struct AccountCenterView: View {
AccountAppleAuthorizationButton(purpose: .signIn)
.disabled(coordinator.operation != nil)
if coordinator.pendingReferralCode != nil {
Label(AppL10n.string("account.referral.pendingAfterSignIn"), systemImage: "link")
.font(.footnote)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
ReferralCodeEntryRow()
Text(AppL10n.string("account.signedOut.localUnaffected"))
.font(.footnote)
@@ -136,6 +131,10 @@ struct AccountCenterView: View {
ScrollView {
CardPageContent {
accountSummarySection(session: session)
SignedInReferralCodeCard(
profile: coordinator.referralProfile,
session: session
)
accountRewardsContent(session: session)
securitySection
}
@@ -769,62 +768,63 @@ struct AccountCreditProgress: View {
struct AccountInvitationButton: View {
@Environment(\.themePalette) private var palette
@EnvironmentObject private var coordinator: AccountSessionCoordinator
@State private var showsShareDrawer = false
let invitationURL: URL?
var shortCode: String?
var body: some View {
Button {
guard invitationURL != nil else { return }
showsShareDrawer = true
} label: {
Label(AppL10n.string("account.referral.inviteTitle"), systemImage: "person.badge.plus")
.font(TypeStyle.bodyEmph)
.foregroundStyle(OSGColor.fixedLightContent)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: 38)
.background(
palette.success,
in: RoundedRectangle(
cornerRadius: Radius.medium,
style: .continuous
)
)
AccountReferralActionLabel(
titleKey: "account.referral.inviteTitle",
systemImage: "person.badge.plus"
)
}
.buttonStyle(.plain)
.disabled(invitationURL == nil)
.accessibilityIdentifier("account.referral.share")
.sheet(isPresented: $showsShareDrawer) {
if let invitationURL {
AccountInvitationActivityView(invitationURL: invitationURL)
.presentationDetents([.medium, .large])
InviteShareSheet(
invitationURL: invitationURL,
shortCode: shortCode,
referralProfile: coordinator.referralProfile
)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
}
}
private struct AccountInvitationActivityView: UIViewControllerRepresentable {
let invitationURL: URL
/// Green rounded-rectangle label shared by the Invite and "I have a code"
/// buttons so the two read as one family on the account card.
struct AccountReferralActionLabel: View {
@Environment(\.themePalette) private var palette
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: [invitationURL],
applicationActivities: nil
)
controller.completionWithItemsHandler = { _, completed, _, _ in
guard completed else { return }
Task { @MainActor in
AnalyticsHostService.shared.client.recordReferralShared()
}
}
return controller
let titleKey: String
let systemImage: String
/// Full-width form for a sheet's primary action.
var fillsWidth = false
var body: some View {
Label(AppL10n.string(titleKey), systemImage: systemImage)
.font(TypeStyle.bodyEmph)
.foregroundStyle(OSGColor.fixedLightContent)
.padding(.horizontal, Spacing.sm)
.frame(maxWidth: fillsWidth ? .infinity : nil, minHeight: fillsWidth ? 44 : 38)
.background(
palette.success,
in: RoundedRectangle(
cornerRadius: Radius.medium,
style: .continuous
)
)
}
func updateUIViewController(
_ uiViewController: UIActivityViewController,
context: Context
) {}
}
#if DEBUG
@@ -63,6 +63,9 @@ struct AccountRewardsCard: View {
AccountAppleAuthorizationButton(purpose: .signIn)
.disabled(coordinator.operation != nil)
Divider().background(palette.divider)
ReferralCodeEntryRow()
}
.padding(Spacing.md)
}
@@ -90,10 +93,18 @@ struct AccountRewardsCard: View {
}
Divider().background(palette.divider)
AccountReferralLinkView(viewModel: coordinator.referralProfile)
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) {
@@ -185,6 +196,9 @@ struct AccountRewardsCard: View {
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 {
@@ -217,12 +231,27 @@ private struct AccountReferralLinkView: View {
private func loadedContent(_ profile: ReferralProfile) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
HStack(spacing: Spacing.md) {
Text(AppL10n.string("account.referral.equalRewardDescription"))
.font(TypeStyle.body.weight(.bold))
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
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)
AccountInvitationButton(invitationURL: profile.code.inviteURL)
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 {
@@ -239,6 +268,15 @@ private struct AccountReferralLinkView: View {
}
}
/// 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
@@ -10,9 +10,20 @@ import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
#if canImport(OSGKeyboardHostSupport)
import OSGKeyboardHostSupport
#endif
@MainActor
final class AccountSessionCoordinator: ObservableObject {
/// Result of an invitation the user typed or pasted instead of opening a link.
enum ReferralInputOutcome: Equatable {
case invalidInput
case savedForSignIn
case redeemed
case failed(messageKey: String)
}
enum SessionPhase: Equatable {
case restoring
case signedOut
@@ -208,11 +219,8 @@ final class AccountSessionCoordinator: ObservableObject {
func handleIncomingURL(_ url: URL) -> Bool {
guard let code = ReferralUniversalLink.code(from: url) else { return false }
pendingReferralStore.save(code)
pendingReferralCode = code
stagePendingReferral(code)
shouldPresentAccountCenter = true
operationErrorKey = nil
operationErrorDetail = nil
analyticsClient.recordInviteOpened()
if isSignedIn {
@@ -224,6 +232,26 @@ final class AccountSessionCoordinator: ObservableObject {
return true
}
/// Manual path for invitations that never reached the app as a Universal
/// Link — typically a friend who scanned a poster, installed from the App
/// Store, and typed the short code (or pasted the link). The outcome is
/// returned to the caller instead of `operationErrorKey` so the code-entry
/// sheet can explain it inline rather than through the global alert.
func submitReferralInput(_ text: String) async -> ReferralInputOutcome {
guard let code = ReferralUniversalLink.code(fromUserInput: text) else {
return .invalidInput
}
stagePendingReferral(code)
guard isSignedIn else { return .savedForSignIn }
if let messageKey = await redeemPendingReferral() {
return .failed(messageKey: messageKey)
}
await referralProfile.refresh()
await refreshAccountData(force: true)
return .redeemed
}
func consumeAccountCenterPresentation() -> Bool {
guard shouldPresentAccountCenter else { return false }
shouldPresentAccountCenter = false
@@ -544,22 +572,84 @@ final class AccountSessionCoordinator: ObservableObject {
sessionRevision &+= 1
}
private func stagePendingReferral(_ code: String) {
pendingReferralStore.save(code)
pendingReferralCode = code
operationErrorKey = nil
operationErrorDetail = nil
}
private func redeemPendingReferralIfNeeded() async {
guard isSignedIn, let code = pendingReferralStore.code else { return }
if let messageKey = await redeemPendingReferral() {
operationErrorKey = messageKey
}
}
/// Returns nil on success (or when nothing is pending), otherwise the
/// message key. Rejections the server will repeat forever drop the stored
/// code; transient failures keep it for the next sign-in or launch.
private func redeemPendingReferral() async -> String? {
guard isSignedIn, let code = pendingReferralStore.code else { return nil }
operation = .redeemingReferral
defer { operation = nil }
do {
try await centerService.redeemReferral(code: code)
pendingReferralStore.clear()
pendingReferralCode = nil
return nil
} catch {
pendingReferralCode = code
operationErrorKey = errorMessageKey(
for: error,
fallback: "account.error.redeemReferral"
)
let failure = Self.referralRedemptionFailure(for: error)
if failure.isPermanent {
pendingReferralStore.clear()
pendingReferralCode = nil
} else {
pendingReferralCode = code
}
return failure.messageKey
}
operation = nil
}
private static func referralRedemptionFailure(
for error: Error
) -> (messageKey: String, isPermanent: Bool) {
if error as? AccountIntegrationError == .unavailable {
return ("account.error.unavailable", false)
}
guard let apiError = error as? AccountAPIError else {
return ("account.error.redeemReferral", false)
}
// Referral routes answer with a bare `{"error": message}` body, so most
// rejections arrive as `.server` and are told apart by status code.
switch apiError {
case .invalidRequest:
return ("account.referral.redeem.error.invalid", true)
case .conflict(let message):
return (conflictMessageKey(for: message), true)
case let .server(statusCode, _, message):
switch statusCode {
case 400, 404:
return ("account.referral.redeem.error.invalid", true)
case 409:
return (conflictMessageKey(for: message), true)
case 422:
return ("account.referral.redeem.error.expired", true)
default:
return ("account.error.redeemReferral", false)
}
default:
return ("account.error.redeemReferral", false)
}
}
private static func conflictMessageKey(for message: String) -> String {
if message.localizedCaseInsensitiveContains("self-referral") {
return "account.referral.redeem.error.self"
}
if message.localizedCaseInsensitiveContains("already bound") {
return "account.referral.redeem.error.alreadyBound"
}
return "account.referral.redeem.error.ineligible"
}
private func errorMessageKey(for error: Error, fallback: String) -> String {
@@ -0,0 +1,329 @@
// InvitePoster.swift
// OSGKeyboard · Main App + Mac
//
// Shareable invitation artwork: the app icon, inviter name, a QR code for the
// account's invitation link, and the short code for friends who type it
// instead of scanning. The QR code is generated on device with Core Image,
// so the link is never sent anywhere to be rendered. The Mac target compiles
// this file at the source level (see project.yml), so it must stay free of
// UIKit and AppKit.
import CoreImage
import CoreImage.CIFilterBuiltins
import SwiftUI
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
enum InviteQRCode {
/// Pixels per QR module. Integer scaling keeps module edges sharp.
fileprivate static let moduleScale: CGFloat = 12
static func makeImage(for url: URL) -> CGImage? {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(url.absoluteString.utf8)
// "M" (15%) survives chat-app recompression. Nothing is drawn over the
// code, so a higher level would only shrink the modules.
filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(
by: CGAffineTransform(scaleX: moduleScale, y: moduleScale)
)
// QRGenerator produces a one-channel image that SwiftUI can treat as a
// template and recolor white in dark mode. Materialize explicit black
// and white pixels so both poster appearances remain scannable.
let falseColor = CIFilter.falseColor()
falseColor.inputImage = scaled
falseColor.color0 = CIColor.black
falseColor.color1 = CIColor.white
guard let colored = falseColor.outputImage else { return nil }
return CIContext().createCGImage(colored, from: colored.extent)
}
}
/// Fixed 3:4 layout so the exported image is the same on every device and
/// is never cropped in chat previews or Moments.
struct InvitePosterView: View {
static let size = CGSize(width: 360, height: 480)
let qrImage: CGImage
let language: AppUILanguage?
let inviterName: String?
let colorScheme: ColorScheme
/// `XXXX-XXXX`; lets a friend who cannot scan type the code instead.
var shortCode: String?
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 28)
// Use the active Icon Composer artwork, not the horizontal logo.
Image("AppIcon_Assets/OSGKeyboard Icon2")
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
.accessibilityHidden(true)
Text(
SharedL10n.format(
"invite.poster.inviter",
language: language,
resolvedInviterName
)
)
.font(.system(size: 18, weight: .medium, design: .default))
.foregroundStyle(primaryContent)
.multilineTextAlignment(.center)
.lineLimit(2)
.lineSpacing(3)
.minimumScaleFactor(0.75)
.padding(.top, 24)
// Dark modules on a light card scan most reliably, including in
// WeChat's long-press recognizer.
InviteQRCodeView(image: qrImage)
.frame(width: 156, height: 156)
.padding(Spacing.sm)
.background(
OSGColor.fixedLightContent,
in: Rectangle()
)
.padding(.top, 28)
Text(SharedL10n.string("invite.poster.scan", language: language))
.font(.system(size: 12, weight: .regular, design: .default))
.foregroundStyle(secondaryContent)
.multilineTextAlignment(.center)
.padding(.top, 28)
if let shortCode {
Text(SharedL10n.format("invite.poster.code", language: language, shortCode))
.font(.system(size: 18, weight: .bold, design: .monospaced))
.foregroundStyle(primaryContent)
.padding(.top, 10)
}
Spacer(minLength: 32)
}
.padding(.horizontal, Spacing.xxl)
.frame(width: Self.size.width, height: Self.size.height)
.background(InvitePosterBackground(colorScheme: colorScheme))
}
private var resolvedInviterName: String {
let trimmed = inviterName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty
? SharedL10n.string("invite.poster.defaultInviter", language: language)
: trimmed
}
private var primaryContent: Color {
colorScheme == .dark ? Color(white: 0.96) : Color(white: 0.08)
}
private var secondaryContent: Color {
primaryContent.opacity(0.62)
}
}
/// Draws each QR module as a vector rectangle. `ImageRenderer` can otherwise
/// reinterpret a CGImage as a template on complex dark backgrounds and erase
/// its black modules. Vector modules stay black in every appearance.
private struct InviteQRCodeView: View {
private let sideCount: Int
private let darkModules: [Bool]
init(image: CGImage) {
let matrix = Self.moduleMatrix(from: image)
sideCount = matrix.sideCount
darkModules = matrix.darkModules
}
var body: some View {
Canvas { context, size in
guard sideCount > 0 else { return }
let moduleWidth = size.width / CGFloat(sideCount)
let moduleHeight = size.height / CGFloat(sideCount)
var modules = Path()
for row in 0..<sideCount {
for column in 0..<sideCount where darkModules[row * sideCount + column] {
modules.addRect(
CGRect(
x: CGFloat(column) * moduleWidth,
y: CGFloat(row) * moduleHeight,
width: moduleWidth,
height: moduleHeight
)
)
}
}
context.fill(modules, with: .color(OSGColor.fixedDarkContent))
}
.background(OSGColor.fixedLightContent)
}
private static func moduleMatrix(
from image: CGImage
) -> (sideCount: Int, darkModules: [Bool]) {
let sideCount = max(image.width / Int(InviteQRCode.moduleScale), 1)
var pixels = [UInt8](repeating: 0, count: sideCount * sideCount * 4)
guard let context = CGContext(
data: &pixels,
width: sideCount,
height: sideCount,
bitsPerComponent: 8,
bytesPerRow: sideCount * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
return (0, [])
}
context.interpolationQuality = .none
context.draw(
image,
in: CGRect(x: 0, y: 0, width: sideCount, height: sideCount)
)
let darkModules = (0..<(sideCount * sideCount)).map { index in
let offset = index * 4
let luminance = Int(pixels[offset])
+ Int(pixels[offset + 1])
+ Int(pixels[offset + 2])
return luminance < 3 * 128
}
return (sideCount, darkModules)
}
}
/// A restrained paper surface: warm Nordic stock in light mode and graphite
/// stock in dark mode. The daylight bands and fibers stay below text contrast,
/// so the background adds depth without becoming decoration.
private struct InvitePosterBackground: View {
let colorScheme: ColorScheme
var body: some View {
GeometryReader { proxy in
ZStack {
base
LinearGradient(
colors: daylightColors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Ellipse()
.fill(topTrailingShadow)
.frame(width: 310, height: 250)
.offset(x: 178, y: -186)
.blur(radius: 62)
Ellipse()
.fill(bottomLeadingShadow)
.frame(width: 300, height: 260)
.offset(x: -182, y: 224)
.blur(radius: 68)
Rectangle()
.fill(diagonalShadow)
.frame(width: proxy.size.width * 1.55, height: 82)
.rotationEffect(.degrees(-18))
.offset(x: 72, y: -118)
.blur(radius: 34)
paperFibers(in: proxy.size)
}
}
}
private var base: Color {
colorScheme == .dark
? Color(red: 0.055, green: 0.054, blue: 0.050)
: Color(red: 0.965, green: 0.953, blue: 0.922)
}
private var daylightColors: [Color] {
if colorScheme == .dark {
return [
.white.opacity(0.085),
.clear,
.black.opacity(0.30)
]
}
return [
.white.opacity(0.62),
.clear,
Color(red: 0.52, green: 0.48, blue: 0.41).opacity(0.18)
]
}
private var topTrailingShadow: Color {
colorScheme == .dark
? .black.opacity(0.38)
: Color(red: 0.43, green: 0.40, blue: 0.35).opacity(0.24)
}
private var bottomLeadingShadow: Color {
colorScheme == .dark
? .black.opacity(0.28)
: Color(red: 0.48, green: 0.44, blue: 0.37).opacity(0.15)
}
private var diagonalShadow: Color {
colorScheme == .dark
? .white.opacity(0.055)
: Color.black.opacity(0.055)
}
private func paperFibers(in size: CGSize) -> some View {
ZStack {
ForEach(0..<48, id: \.self) { index in
Rectangle()
.fill(fiberColor)
.frame(width: 2 + 7 * fraction(index * 19), height: 0.35)
.position(
x: size.width * fraction(index * 37),
y: size.height * fraction(index * 61)
)
}
}
.allowsHitTesting(false)
}
private var fiberColor: Color {
colorScheme == .dark
? Color.white.opacity(0.025)
: Color.black.opacity(0.025)
}
private func fraction(_ value: Int) -> CGFloat {
CGFloat(value % 101) / 100
}
}
enum InvitePosterRenderer {
/// 3× the point layout: a 1080×1440 px image.
static let exportScale: CGFloat = 3
@MainActor
static func render(
inviteURL: URL,
shortCode: String? = nil,
inviterName: String? = nil,
language: AppUILanguage?,
colorScheme: ColorScheme
) -> CGImage? {
guard let qrImage = InviteQRCode.makeImage(for: inviteURL) else { return nil }
let renderer = ImageRenderer(
content: InvitePosterView(
qrImage: qrImage,
language: language,
inviterName: inviterName,
colorScheme: colorScheme,
shortCode: shortCode
)
)
renderer.scale = exportScale
return renderer.cgImage
}
}
@@ -0,0 +1,62 @@
// InviteQRFullScreenView.swift
// OSGKeyboard · Main App
//
// Face-to-face mode for the invitation: only the QR code, as large as the
// screen allows, on white at full brightness so a friend's camera locks on
// from arm's length. Tapping anywhere closes it.
import OSGKeyboardShared
import SwiftUI
struct InviteQRFullScreenView: View {
@Environment(\.dismiss) private var dismiss
let qrImage: CGImage
let language: AppUILanguage?
/// Past this size a phone held at arm's length no longer gains anything.
private static let maxCodeSide: CGFloat = 440
var body: some View {
GeometryReader { proxy in
let side = min(
min(proxy.size.width, proxy.size.height) - Spacing.xxl * 2,
Self.maxCodeSide
)
VStack(spacing: Spacing.lg) {
Image(decorative: qrImage, scale: 1)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(width: side, height: side)
.accessibilityLabel(AppL10n.string("account.referral.poster.accessibility"))
Text(SharedL10n.string("invite.poster.hint", language: language))
.font(TypeStyle.headline)
.foregroundStyle(OSGColor.fixedDarkContent.opacity(0.6))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(OSGColor.fixedLightContent.ignoresSafeArea())
.overlay(alignment: .topTrailing) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(OSGColor.fixedDarkContent.opacity(0.6))
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.padding(Spacing.md)
.accessibilityLabel(AppL10n.string("common.done"))
.accessibilityIdentifier("account.referral.poster.fullScreen.close")
}
.contentShape(Rectangle())
.onTapGesture { dismiss() }
.boostsScreenBrightness()
.statusBarHidden()
.accessibilityAction(.escape) { dismiss() }
.accessibilityIdentifier("account.referral.poster.fullScreen")
}
}
@@ -0,0 +1,306 @@
// InviteShareSheet.swift
// OSGKeyboard · Main App
//
// Opened by the Invite button. Shows the invitation poster, which opens a
// full-screen code for face-to-face scanning when tapped, plus the three ways an
// invitation usually leaves the phone: saving the image to send in a chat app,
// the system share sheet, or the plain link.
import OSGKeyboardShared
import Photos
import SwiftUI
import UIKit
struct InviteShareSheet: View {
@Environment(\.themePalette) private var palette
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject private var coordinator: AccountSessionCoordinator
@ObservedObject private var config = ProviderConfig.shared
let invitationURL: URL
/// Display form (`XXXX-XXXX`), printed on the poster when available.
var shortCode: String?
@ObservedObject var referralProfile: ReferralProfileViewModel
@State private var poster: UIImage?
@State private var qrImage: CGImage?
@State private var isShowingFullScreenCode = false
@State private var isSharingPoster = false
@State private var isSaving = false
@State private var status: Status?
private enum Status: Equatable {
case success(messageKey: String)
case failure(messageKey: String)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Spacing.lg) {
posterPreview
actions
statusMessage
}
.padding(Spacing.lg)
.frame(maxWidth: .infinity)
}
.background(palette.background.ignoresSafeArea())
.navigationTitle(AppL10n.string("account.referral.poster.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(AppL10n.string("common.done")) { dismiss() }
.tint(palette.textPrimary)
}
}
}
.task(id: posterRenderID) {
qrImage = InviteQRCode.makeImage(for: invitationURL)
poster = InvitePosterRenderer
.render(
inviteURL: invitationURL,
shortCode: resolvedShortCode,
inviterName: inviterName,
language: config.uiLanguage,
colorScheme: colorScheme
)
.map { UIImage(cgImage: $0) }
}
.task {
// A cached pre-short-code profile may be visible for the first
// frame. Refresh when the sheet opens and rerender once the server
// supplies the account's assigned alias.
guard shortCode == nil else { return }
await referralProfile.refresh()
}
.fullScreenCover(isPresented: $isShowingFullScreenCode) {
if let qrImage {
InviteQRFullScreenView(qrImage: qrImage, language: config.uiLanguage)
}
}
.sheet(isPresented: $isSharingPoster) {
if let poster {
InvitePosterActivityView(poster: poster)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
.accessibilityIdentifier("account.referral.poster")
}
/// Tapping the poster opens the full-screen code for face-to-face scanning.
private var posterPreview: some View {
let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
return Button {
isShowingFullScreenCode = true
} label: {
Group {
if let poster {
Image(uiImage: poster)
.resizable()
.scaledToFit()
} else {
ZStack {
colorScheme == .dark
? OSGColor.fixedDarkContent
: OSGColor.fixedLightContent
ProgressView()
.tint(
colorScheme == .dark
? OSGColor.fixedLightContent
: OSGColor.fixedDarkContent
)
}
}
}
.aspectRatio(
InvitePosterView.size.width / InvitePosterView.size.height,
contentMode: .fit
)
.overlay(alignment: .bottomTrailing) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(posterPrimaryContent)
.frame(width: 32, height: 32)
.background(posterPrimaryContent.opacity(0.16), in: Circle())
.padding(Spacing.sm)
.accessibilityHidden(true)
}
.clipShape(shape)
}
.buttonStyle(.plain)
.disabled(qrImage == nil)
.frame(maxWidth: 320)
.cardElevation()
.accessibilityLabel(AppL10n.string("account.referral.poster.accessibility"))
.accessibilityHint(AppL10n.string("account.referral.poster.enlargeHint"))
.accessibilityIdentifier("account.referral.poster.preview")
}
private var inviterName: String? {
guard case let .signedIn(session) = coordinator.sessionPhase else { return nil }
return session.displayName
}
private var resolvedShortCode: String? {
if let shortCode { return shortCode }
guard case let .loaded(profile) = referralProfile.state else { return nil }
return profile.code.displayShortCode
}
private var posterRenderID: String {
"\(colorScheme)-\(resolvedShortCode ?? "")-\(inviterName ?? "")"
}
private var posterPrimaryContent: Color {
colorScheme == .dark
? OSGColor.fixedLightContent
: OSGColor.fixedDarkContent
}
private var actions: some View {
VStack(spacing: Spacing.sm) {
Button {
Task { await savePoster() }
} label: {
Group {
if isSaving {
ProgressView()
.tint(palette.textOnAccent)
} else {
Label(
AppL10n.string("account.referral.poster.save"),
systemImage: "square.and.arrow.down"
)
}
}
.primaryButton()
}
.buttonStyle(.plain)
.disabled(poster == nil || isSaving)
.accessibilityIdentifier("account.referral.poster.save")
HStack(spacing: Spacing.sm) {
Button {
isSharingPoster = true
} label: {
Label(
AppL10n.string("account.referral.poster.share"),
systemImage: "square.and.arrow.up"
)
.secondaryButton()
}
.buttonStyle(.plain)
.disabled(poster == nil)
.accessibilityIdentifier("account.referral.poster.share")
Button(action: copyLink) {
Label(
AppL10n.string("account.referral.poster.copyLink"),
systemImage: "link"
)
.secondaryButton()
}
.buttonStyle(.plain)
.accessibilityIdentifier("account.referral.poster.copyLink")
}
}
.frame(maxWidth: 360)
}
@ViewBuilder
private var statusMessage: some View {
switch status {
case .success(let messageKey):
Label(AppL10n.string(messageKey), systemImage: "checkmark.circle.fill")
.font(TypeStyle.footnote)
.foregroundStyle(palette.success)
case .failure(let messageKey):
Label(AppL10n.string(messageKey), systemImage: "exclamationmark.triangle.fill")
.font(TypeStyle.footnote)
.foregroundStyle(palette.danger)
.multilineTextAlignment(.center)
case nil:
Text(AppL10n.string("account.referral.poster.footnote"))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
}
}
private func savePoster() async {
guard let poster, !isSaving else { return }
isSaving = true
defer { isSaving = false }
// PhotoKit terminates the process instead of throwing when this usage
// description is missing from the built app. Keep this preflight so a
// future project-generation regression becomes a visible error.
guard let usageDescription = Bundle.main.object(
forInfoDictionaryKey: "NSPhotoLibraryAddUsageDescription"
) as? String, !usageDescription.isEmpty else {
status = .failure(messageKey: "account.referral.poster.saveFailed")
return
}
// Add-only access: the app never needs to read the user's library.
let authorization = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
guard authorization == .authorized || authorization == .limited else {
status = .failure(messageKey: "account.referral.poster.saveDenied")
return
}
guard let pngData = poster.pngData() else {
status = .failure(messageKey: "account.referral.poster.saveFailed")
return
}
do {
try await Self.addToPhotos(pngData: pngData)
status = .success(messageKey: "account.referral.poster.saved")
AnalyticsHostService.shared.client.recordReferralShared()
} catch {
status = .failure(messageKey: "account.referral.poster.saveFailed")
}
}
/// Photos runs the change block on its own transaction queue. It must be
/// built outside the view's main-actor isolation: Swift 6 traps when a
/// main-actor closure runs off the main thread.
private nonisolated static func addToPhotos(pngData: Data) async throws {
try await PHPhotoLibrary.shared().performChanges {
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: pngData, options: nil)
}
}
private func copyLink() {
UIPasteboard.general.string = invitationURL.absoluteString
status = .success(messageKey: "account.referral.poster.linkCopied")
AnalyticsHostService.shared.client.recordReferralShared()
}
}
/// Shares the poster image only. Chat apps' share extensions often refuse a
/// mixed image + URL payload, and the link is one tap away via Copy Link.
private struct InvitePosterActivityView: UIViewControllerRepresentable {
let poster: UIImage
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: [poster],
applicationActivities: nil
)
controller.completionWithItemsHandler = { _, completed, _, _ in
guard completed else { return }
Task { @MainActor in
AnalyticsHostService.shared.client.recordReferralShared()
}
}
return controller
}
func updateUIViewController(
_ uiViewController: UIActivityViewController,
context: Context
) {}
}
@@ -0,0 +1,288 @@
// ReferralCodeEntry.swift
// OSGKeyboard · Main App
//
// Manual path for invitations that never opened the app directly. A friend
// who scans a poster usually installs from the App Store, so the Universal
// Link does not fire; instead they type the 8-character code printed on the
// poster and the landing page. Pasting the full link into the field works
// too. The clipboard is never read on its own.
import OSGKeyboardShared
import SwiftUI
/// Green "I have a code" button, styled like the Invite button. The caller
/// owns `isPresented` so it can keep the button on screen while the sheet is
/// open, even if a successful redemption closes the binding window.
struct ReferralCodeEntryButton: View {
@Binding var isPresented: Bool
/// Signed-in accounts also get an Invite entry at the bottom of the sheet.
var invitation: ReferralCode?
var body: some View {
Button {
isPresented = true
} label: {
AccountReferralActionLabel(
titleKey: "account.referral.codeEntry.button",
systemImage: "gift"
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("account.referral.codeEntry")
.sheet(isPresented: $isPresented) {
ReferralCodeEntrySheet(invitation: invitation)
}
}
}
/// Text field + confirm. Outcomes are shown inline rather than through the
/// global alert; success closes the sheet after a short confirmation.
struct ReferralCodeEntrySheet: View {
typealias Outcome = AccountSessionCoordinator.ReferralInputOutcome
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette
@EnvironmentObject private var coordinator: AccountSessionCoordinator
var invitation: ReferralCode?
@State private var text = ""
@State private var isSubmitting = false
@State private var outcome: Outcome?
@FocusState private var isFieldFocused: Bool
/// Long enough to read the confirmation before the sheet closes.
private static let successDismissDelay: Duration = .milliseconds(1_200)
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.md) {
Text(AppL10n.string("account.referral.codeEntry.sheetBody"))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
codeField
confirmButton
feedback
if coordinator.isSignedIn, let invitation {
inviteMore(invitation)
}
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
}
.scrollBounceBehavior(.basedOnSize)
.background(palette.background.ignoresSafeArea())
.navigationTitle(AppL10n.string("account.referral.codeEntry.sheetTitle"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(AppL10n.string("common.cancel")) { dismiss() }
.tint(palette.textPrimary)
}
}
.onAppear { isFieldFocused = true }
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.interactiveDismissDisabled(isSubmitting)
.accessibilityIdentifier("account.referral.codeEntry.sheet")
}
private var codeField: some View {
TextField(AppL10n.string("account.referral.codeEntry.placeholder"), text: $text)
.font(.system(.title3, design: .monospaced, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.tint(palette.textPrimary)
.focused($isFieldFocused)
// Codes are uppercase; links pasted into the field are unaffected.
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.keyboardType(.asciiCapable)
.submitLabel(.done)
.onSubmit { Task { await submit() } }
.onChange(of: text) { _, _ in outcome = nil }
.settingsListRow()
.background(
palette.formSurface,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.accessibilityLabel(AppL10n.string("account.referral.codeEntry.fieldLabel"))
.accessibilityIdentifier("account.referral.codeEntry.field")
}
private var confirmButton: some View {
Button {
Task { await submit() }
} label: {
Group {
if isSubmitting {
ProgressView()
.tint(OSGColor.fixedLightContent)
.frame(maxWidth: .infinity, minHeight: 44)
.background(
palette.success,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
} else {
AccountReferralActionLabel(
titleKey: "account.referral.codeEntry.confirm",
systemImage: "checkmark",
fillsWidth: true
)
}
}
}
.buttonStyle(.plain)
.disabled(!canSubmit)
.opacity(canSubmit || isSubmitting ? 1 : 0.5)
.accessibilityIdentifier("account.referral.codeEntry.confirm")
}
@ViewBuilder
private var feedback: some View {
switch outcome {
case .invalidInput:
ReferralFeedbackLabel(messageKey: "account.referral.codeEntry.invalid", isSuccess: false)
case .failed(let messageKey):
ReferralFeedbackLabel(messageKey: messageKey, isSuccess: false)
case .redeemed:
ReferralFeedbackLabel(messageKey: "account.referral.codeEntry.redeemed", isSuccess: true)
case .savedForSignIn:
ReferralFeedbackLabel(messageKey: "account.referral.pendingAfterSignIn", isSuccess: true)
case nil:
EmptyView()
}
}
/// The "invite in the sheet" decision: during the binding window the card
/// shows "I have a code" instead of Invite, so inviting stays one tap away.
private func inviteMore(_ invitation: ReferralCode) -> some View {
VStack(alignment: .leading, spacing: Spacing.md) {
Divider().background(palette.divider)
HStack(spacing: Spacing.md) {
Text(AppL10n.string("account.referral.codeEntry.inviteMore"))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: Spacing.xs)
AccountInvitationButton(
invitationURL: invitation.inviteURL,
shortCode: invitation.displayShortCode
)
}
}
.padding(.top, Spacing.sm)
}
private var canSubmit: Bool {
!isSubmitting
&& coordinator.operation == nil
&& !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private func submit() async {
guard canSubmit else { return }
isSubmitting = true
let result = await coordinator.submitReferralInput(text)
isSubmitting = false
outcome = result
guard result == .redeemed || result == .savedForSignIn else { return }
isFieldFocused = false
try? await Task.sleep(for: Self.successDismissDelay)
dismiss()
}
}
/// "Have an invitation?" row. Signed out it sits under the Apple button on
/// Home and in the account center, and the code is held until sign-in.
struct ReferralCodeEntryRow: View {
@Environment(\.themePalette) private var palette
@EnvironmentObject private var coordinator: AccountSessionCoordinator
var invitation: ReferralCode?
/// Pass a binding when the parent must know the sheet is open.
var isEntryPresented: Binding<Bool>?
@State private var isLocallyPresented = false
private var presentation: Binding<Bool> {
isEntryPresented ?? $isLocallyPresented
}
var body: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(alignment: .center, spacing: Spacing.sm) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(AppL10n.string("account.referral.codeEntry.title"))
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Text(AppL10n.string(
coordinator.isSignedIn
? "account.referral.codeEntry.bodySignedIn"
: "account.referral.codeEntry.body"
))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
ReferralCodeEntryButton(isPresented: presentation, invitation: invitation)
.disabled(coordinator.operation != nil)
}
if coordinator.pendingReferralCode != nil, !coordinator.isSignedIn {
ReferralFeedbackLabel(messageKey: "account.referral.pendingAfterSignIn", isSuccess: true)
}
}
}
}
/// Signed-in variant for the account center: only accounts that have not
/// bound an inviter and are still inside the binding window see it.
struct SignedInReferralCodeCard: View {
@ObservedObject var profile: ReferralProfileViewModel
let session: AccountSession
@State private var isEntryPresented = false
var body: some View {
if isEntryPresented || isEligible {
ReferralCodeEntryRow(invitation: loadedCode, isEntryPresented: $isEntryPresented)
.padding(Spacing.md)
.surfaceCard()
.accessibilityIdentifier("account.referral.codeEntryCard")
}
}
private var loadedCode: ReferralCode? {
guard case let .loaded(loaded) = profile.state else { return nil }
return loaded.code
}
private var isEligible: Bool {
guard case let .loaded(loaded) = profile.state else { return false }
return ReferralBindingWindow.isOpen(for: session, profile: loaded)
}
}
struct ReferralFeedbackLabel: View {
@Environment(\.themePalette) private var palette
let messageKey: String
let isSuccess: Bool
var body: some View {
Label(
AppL10n.string(messageKey),
systemImage: isSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
)
.font(TypeStyle.footnote)
.foregroundStyle(isSuccess ? palette.success : palette.danger)
.fixedSize(horizontal: false, vertical: true)
}
}
+1
View File
@@ -5,3 +5,4 @@
"NSContactsUsageDescription" = "OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.";
"NSRemindersFullAccessUsageDescription" = "OSGKeyboard adds reminders only when you choose Extract To-dos for copied text.";
"NSCalendarsWriteOnlyAccessUsageDescription" = "OSGKeyboard adds calendar events only when you choose Extract Events for copied text. It never reads your existing events.";
"NSPhotoLibraryAddUsageDescription" = "OSGKeyboard saves your invitation poster to Photos only when you choose Save Image.";
+29
View File
@@ -983,6 +983,35 @@
"account.referral.pendingAfterSignIn" = "Your invitation is saved and will be redeemed after sign-in.";
"account.referral.rewardGranted" = "You received %@ credits from the invitation.";
"account.referral.rewardGrantedGeneric" = "Your invitation reward has been added to your balance.";
"account.referral.poster.title" = "Invite Friends";
"account.referral.poster.accessibility" = "Invitation poster with a QR code for your invitation link";
"account.referral.poster.enlargeHint" = "Shows the QR code full screen for scanning in person.";
"account.referral.poster.save" = "Save Image";
"account.referral.poster.share" = "Share";
"account.referral.poster.copyLink" = "Copy Link";
"account.referral.poster.footnote" = "Send the image to a friend, or tap the poster to show a large code to scan in person. Once your friend signs up and completes a first qualifying use, you both earn credits.";
"account.referral.poster.saved" = "Saved to Photos";
"account.referral.poster.saveDenied" = "Allow OSGKeyboard to add photos in Settings, then try again.";
"account.referral.poster.saveFailed" = "Couldn’t save the image. Please try again.";
"account.referral.poster.linkCopied" = "Invitation link copied";
"account.referral.codeEntry.title" = "Have an invitation?";
"account.referral.codeEntry.body" = "Enter your friend’s code, then sign in to accept it.";
"account.referral.codeEntry.bodySignedIn" = "Enter your friend’s code and you both earn credits.";
"account.referral.codeEntry.button" = "I have a code";
"account.referral.codeEntry.windowDescription" = "Enter a code, or invite more friends for credits";
"account.referral.codeEntry.sheetTitle" = "Enter Invitation Code";
"account.referral.codeEntry.sheetBody" = "Enter the 8-character code from your friend’s poster or invitation page, or paste the invitation link.";
"account.referral.codeEntry.placeholder" = "e.g. K7M2-Q9XA";
"account.referral.codeEntry.fieldLabel" = "Invitation code";
"account.referral.codeEntry.confirm" = "Confirm";
"account.referral.codeEntry.invalid" = "That doesn’t look like an invitation code. Check it and try again.";
"account.referral.codeEntry.redeemed" = "Invitation accepted. You both earn credits after qualifying usage.";
"account.referral.codeEntry.inviteMore" = "You can also invite more friends for credits";
"account.referral.redeem.error.invalid" = "This invitation code is invalid, or the campaign has ended.";
"account.referral.redeem.error.expired" = "The time to add an invitation to this account has passed.";
"account.referral.redeem.error.self" = "You can’t use your own invitation.";
"account.referral.redeem.error.alreadyBound" = "This account has already accepted another invitation.";
"account.referral.redeem.error.ineligible" = "This account can’t accept this invitation.";
"account.security.section" = "Account controls";
"account.signOut.action" = "Sign Out";
"account.signOut.confirmTitle" = "Sign out?";
@@ -5,3 +5,4 @@
"NSContactsUsageDescription" = "仅当你为复制的电话号码选择“新建联系人”时,OSGKeyboard 才会使用通讯录。";
"NSRemindersFullAccessUsageDescription" = "仅当你为复制的文本选择“提取待办”时,OSGKeyboard 才会添加提醒事项。";
"NSCalendarsWriteOnlyAccessUsageDescription" = "仅当你为复制的文本选择“提取日程”时,OSGKeyboard 才会添加日历事件,不会读取你已有的日程。";
"NSPhotoLibraryAddUsageDescription" = "仅当你选择“保存图片”时,OSGKeyboard 才会把邀请海报存入相册。";
@@ -983,6 +983,35 @@
"account.referral.pendingAfterSignIn" = "邀请已暂存,登录后会自动兑换。";
"account.referral.rewardGranted" = "恭喜获得 %@ 积分";
"account.referral.rewardGrantedGeneric" = "邀请奖励已发放,积分已到账。";
"account.referral.poster.title" = "邀请好友";
"account.referral.poster.accessibility" = "带有你的邀请链接二维码的邀请海报";
"account.referral.poster.enlargeHint" = "全屏显示二维码,方便当面扫码。";
"account.referral.poster.save" = "保存图片";
"account.referral.poster.share" = "分享";
"account.referral.poster.copyLink" = "复制链接";
"account.referral.poster.footnote" = "把图片发给好友,或点海报放大二维码当面扫。好友注册并完成首次有效使用后,你们都能获得积分。";
"account.referral.poster.saved" = "已保存到相册";
"account.referral.poster.saveDenied" = "请在“设置”中允许 OSGKeyboard 添加照片,然后重试。";
"account.referral.poster.saveFailed" = "图片保存失败,请重试。";
"account.referral.poster.linkCopied" = "邀请链接已复制";
"account.referral.codeEntry.title" = "有好友邀请?";
"account.referral.codeEntry.body" = "输入好友的邀请码后登录,即可接受邀请";
"account.referral.codeEntry.bodySignedIn" = "输入好友的邀请码,你们都能获得积分。";
"account.referral.codeEntry.button" = "我有邀请码";
"account.referral.codeEntry.windowDescription" = "输入邀请码,或邀请更多好友获取积分";
"account.referral.codeEntry.sheetTitle" = "输入邀请码";
"account.referral.codeEntry.sheetBody" = "输入好友海报或邀请页上的 8 位邀请码,也可以直接粘贴邀请链接。";
"account.referral.codeEntry.placeholder" = "例如 K7M2-Q9XA";
"account.referral.codeEntry.fieldLabel" = "邀请码";
"account.referral.codeEntry.confirm" = "确认";
"account.referral.codeEntry.invalid" = "没有识别到邀请码,请核对后重试。";
"account.referral.codeEntry.redeemed" = "已接受好友邀请,完成一次有效使用后双方均可获得积分。";
"account.referral.codeEntry.inviteMore" = "也可以邀请更多好友获取积分";
"account.referral.redeem.error.invalid" = "邀请码无效,或活动已结束。";
"account.referral.redeem.error.expired" = "这个账号已超过补填邀请的期限。";
"account.referral.redeem.error.self" = "不能使用自己的邀请。";
"account.referral.redeem.error.alreadyBound" = "这个账号已经接受过其他好友的邀请。";
"account.referral.redeem.error.ineligible" = "这个账号暂时不能接受该邀请。";
"account.security.section" = "账号操作";
"account.signOut.action" = "退出登录";
"account.signOut.confirmTitle" = "退出登录?";