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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
|
||||
@@ -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" = "退出登录?";
|
||||
|
||||
Reference in New Issue
Block a user