feat(app): add flow diagnostics and refine interface
Improve failure recovery visibility, strengthen account-session handling, and make the cross-platform interface more consistent.
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
struct OSGKeyboardApp: App {
|
||||
@@ -19,6 +20,11 @@ struct OSGKeyboardApp: App {
|
||||
|
||||
init() {
|
||||
MaterialIconsFont.registerIfNeeded()
|
||||
// Keep system alert and confirmation actions neutral instead of
|
||||
// inheriting the app's green AccentColor.
|
||||
UIView.appearance(
|
||||
whenContainedInInstancesOf: [UIAlertController.self]
|
||||
).tintColor = .label
|
||||
// Deliberately do NOT prepare Custom LM here. App launch already sits
|
||||
// near ~150 MB RSS in Debug; compiling CLM during onboarding races the
|
||||
// keyboard extension and gets the host jetsammed (signal 9). Warmup
|
||||
@@ -29,7 +35,10 @@ struct OSGKeyboardApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("--whats-new-host") {
|
||||
if OnboardingExperienceView.isPreviewEnabled {
|
||||
OOBEPreviewHarness()
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--whats-new-host") {
|
||||
// Approach A: Notes-like host only; real keyboard extension overlays it.
|
||||
// Also used by `--keyboard-appear-stress=` (pass both flags).
|
||||
Self.makeWhatsNewHostView()
|
||||
@@ -43,6 +52,10 @@ struct OSGKeyboardApp: App {
|
||||
AssistantKeyboardUITestHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--account-ui-test") {
|
||||
AccountCenterUITestHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--polish-styles-screenshot") {
|
||||
PolishStylesScreenshotHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--home-dictionary-screenshot") {
|
||||
HomeDictionaryScreenshotHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--managed-consent-ui-test") {
|
||||
ManagedCloudConsentUITestHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--pip-device-ui-test") {
|
||||
|
||||
@@ -10,10 +10,31 @@ import OSGKeyboardShared
|
||||
|
||||
enum FlowDiagnostics {
|
||||
static func log(_ message: String) {
|
||||
FlowFailureLogStore.shared.record(message)
|
||||
OSGDiag.log(message, category: "flow")
|
||||
}
|
||||
|
||||
static func logDrain(_ report: FlowCaptureDrainReport) {
|
||||
FlowPipelineDiagnostics.logDrain(report)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func persistStartupFailure(
|
||||
reason: String,
|
||||
context: [String: String]
|
||||
) -> URL? {
|
||||
let url = FlowFailureLogStore.shared.persistStartupFailure(
|
||||
reason: reason,
|
||||
context: context
|
||||
)
|
||||
if let url {
|
||||
OSGDiag.log(
|
||||
"Flow startup failure report saved file=\(url.lastPathComponent)",
|
||||
category: "flow"
|
||||
)
|
||||
} else {
|
||||
OSGDiag.log("Flow startup failure report could not be saved", category: "flow")
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// FlowFailureLogStore.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Keeps a short in-memory Flow breadcrumb window and writes it only when a
|
||||
// session start reaches a terminal failure. Reports contain operational state,
|
||||
// never audio, transcripts, clipboard contents, prompts, or credentials.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FlowFailureLogEvent: Codable, Equatable, Sendable {
|
||||
let timestamp: Date
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct FlowStartupFailureReport: Codable, Equatable, Sendable {
|
||||
let schemaVersion: Int
|
||||
let reportID: UUID
|
||||
let capturedAt: Date
|
||||
let windowStartedAt: Date
|
||||
let reason: String
|
||||
let context: [String: String]
|
||||
let appVersion: String
|
||||
let buildNumber: String
|
||||
let operatingSystem: String
|
||||
let events: [FlowFailureLogEvent]
|
||||
}
|
||||
|
||||
private struct FlowStartupFailureExport: Codable, Sendable {
|
||||
let schemaVersion: Int
|
||||
let generatedAt: Date
|
||||
let reports: [FlowStartupFailureReport]
|
||||
}
|
||||
|
||||
final class FlowFailureLogStore: @unchecked Sendable {
|
||||
static let shared = FlowFailureLogStore()
|
||||
|
||||
static let retentionWindow: TimeInterval = 10
|
||||
|
||||
private let directoryURL: URL
|
||||
private let maxReportCount: Int
|
||||
private let maxReportAge: TimeInterval
|
||||
private let maxTotalBytes: Int
|
||||
private let now: () -> Date
|
||||
private let fileManager: FileManager
|
||||
private let lock = NSLock()
|
||||
private var events: [FlowFailureLogEvent] = []
|
||||
|
||||
init(
|
||||
directoryURL: URL = FlowFailureLogStore.defaultDirectoryURL(),
|
||||
maxReportCount: Int = 20,
|
||||
maxReportAge: TimeInterval = 14 * 24 * 60 * 60,
|
||||
maxTotalBytes: Int = 2 * 1_024 * 1_024,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
fileManager: FileManager = .default
|
||||
) {
|
||||
self.directoryURL = directoryURL
|
||||
self.maxReportCount = maxReportCount
|
||||
self.maxReportAge = maxReportAge
|
||||
self.maxTotalBytes = maxTotalBytes
|
||||
self.now = now
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
func record(_ message: String) {
|
||||
let timestamp = now()
|
||||
let event = FlowFailureLogEvent(
|
||||
timestamp: timestamp,
|
||||
message: Self.redact(message)
|
||||
)
|
||||
lock.withLock {
|
||||
events.append(event)
|
||||
pruneEvents(referenceDate: timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func persistStartupFailure(
|
||||
reason: String,
|
||||
context: [String: String]
|
||||
) -> URL? {
|
||||
let capturedAt = now()
|
||||
let capturedEvents = lock.withLock {
|
||||
pruneEvents(referenceDate: capturedAt)
|
||||
return events
|
||||
}
|
||||
let report = FlowStartupFailureReport(
|
||||
schemaVersion: 1,
|
||||
reportID: UUID(),
|
||||
capturedAt: capturedAt,
|
||||
windowStartedAt: capturedEvents.first?.timestamp ?? capturedAt,
|
||||
reason: Self.redact(reason),
|
||||
context: context.mapValues(Self.redact),
|
||||
appVersion: Self.bundleValue("CFBundleShortVersionString"),
|
||||
buildNumber: Self.bundleValue("CFBundleVersion"),
|
||||
operatingSystem: ProcessInfo.processInfo.operatingSystemVersionString,
|
||||
events: capturedEvents
|
||||
)
|
||||
|
||||
do {
|
||||
try prepareDirectory()
|
||||
let fileURL = directoryURL.appendingPathComponent(
|
||||
Self.reportFilename(date: capturedAt, id: report.reportID),
|
||||
isDirectory: false
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
try encoder.encode(report).write(to: fileURL, options: .atomic)
|
||||
try? fileManager.setAttributes(
|
||||
[
|
||||
.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication,
|
||||
.modificationDate: capturedAt
|
||||
],
|
||||
ofItemAtPath: fileURL.path
|
||||
)
|
||||
pruneReports(referenceDate: capturedAt)
|
||||
return fileURL
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func reportURLs() -> [URL] {
|
||||
reportFileURLs().sorted { lhs, rhs in
|
||||
modificationDate(for: lhs) > modificationDate(for: rhs)
|
||||
}
|
||||
}
|
||||
|
||||
func makeExportURL() -> URL? {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let reports = reportURLs().compactMap { url -> FlowStartupFailureReport? in
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return try? decoder.decode(FlowStartupFailureReport.self, from: data)
|
||||
}
|
||||
guard !reports.isEmpty else { return nil }
|
||||
|
||||
let export = FlowStartupFailureExport(
|
||||
schemaVersion: 1,
|
||||
generatedAt: now(),
|
||||
reports: reports
|
||||
)
|
||||
do {
|
||||
try prepareDirectory()
|
||||
let exportURL = directoryURL.appendingPathComponent(
|
||||
"flow-startup-diagnostics-export.json",
|
||||
isDirectory: false
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
try encoder.encode(export).write(to: exportURL, options: .atomic)
|
||||
try? fileManager.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: exportURL.path
|
||||
)
|
||||
return exportURL
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAllReports() {
|
||||
for url in reportFileURLs() {
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
try? fileManager.removeItem(
|
||||
at: directoryURL.appendingPathComponent(
|
||||
"flow-startup-diagnostics-export.json",
|
||||
isDirectory: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func pruneEvents(referenceDate: Date) {
|
||||
let cutoff = referenceDate.addingTimeInterval(-Self.retentionWindow)
|
||||
events.removeAll { $0.timestamp < cutoff }
|
||||
}
|
||||
|
||||
private func prepareDirectory() throws {
|
||||
try fileManager.createDirectory(
|
||||
at: directoryURL,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try? fileManager.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: directoryURL.path
|
||||
)
|
||||
}
|
||||
|
||||
private func pruneReports(referenceDate: Date) {
|
||||
let sorted = reportURLs()
|
||||
var retainedBytes = 0
|
||||
|
||||
for (index, url) in sorted.enumerated() {
|
||||
let values = try? url.resourceValues(
|
||||
forKeys: [.contentModificationDateKey, .fileSizeKey]
|
||||
)
|
||||
let modifiedAt = values?.contentModificationDate ?? .distantPast
|
||||
let fileSize = values?.fileSize ?? 0
|
||||
let isExpired = referenceDate.timeIntervalSince(modifiedAt) > maxReportAge
|
||||
let exceedsCount = index >= maxReportCount
|
||||
let exceedsSize = retainedBytes + fileSize > maxTotalBytes
|
||||
|
||||
if isExpired || exceedsCount || exceedsSize {
|
||||
try? fileManager.removeItem(at: url)
|
||||
} else {
|
||||
retainedBytes += fileSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reportFileURLs() -> [URL] {
|
||||
guard let urls = try? fileManager.contentsOfDirectory(
|
||||
at: directoryURL,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
return urls.filter {
|
||||
$0.pathExtension == "json" && $0.lastPathComponent.hasPrefix("flow-start-failure-")
|
||||
}
|
||||
}
|
||||
|
||||
private func modificationDate(for url: URL) -> Date {
|
||||
(try? url.resourceValues(forKeys: [.contentModificationDateKey]))
|
||||
.flatMap(\.contentModificationDate) ?? .distantPast
|
||||
}
|
||||
|
||||
private static func defaultDirectoryURL() -> URL {
|
||||
let baseURL = (try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)) ?? FileManager.default.temporaryDirectory
|
||||
return baseURL.appendingPathComponent("FlowDiagnostics", isDirectory: true)
|
||||
}
|
||||
|
||||
private static func bundleValue(_ key: String) -> String {
|
||||
Bundle.main.object(forInfoDictionaryKey: key) as? String ?? "unknown"
|
||||
}
|
||||
|
||||
private static func reportFilename(date: Date, id: UUID) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
let timestamp = formatter.string(from: date)
|
||||
.replacingOccurrences(of: ":", with: "-")
|
||||
return "flow-start-failure-\(timestamp)-\(id.uuidString.lowercased()).json"
|
||||
}
|
||||
|
||||
private static func redact(_ value: String) -> String {
|
||||
var redacted = value.replacingOccurrences(
|
||||
of: #"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[1-5][0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}"#,
|
||||
with: "<uuid>",
|
||||
options: .regularExpression
|
||||
)
|
||||
redacted = redacted.replacingOccurrences(
|
||||
of: #"/private/var/mobile/Containers/\S+"#,
|
||||
with: "<container-path>",
|
||||
options: .regularExpression
|
||||
)
|
||||
redacted = redacted.replacingOccurrences(
|
||||
of: #"(?i)(api[_-]?key|authorization|bearer|access[_-]?token|refresh[_-]?token)=\S+"#,
|
||||
with: "$1=<redacted>",
|
||||
options: .regularExpression
|
||||
)
|
||||
return redacted
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import AVFoundation
|
||||
import AVKit
|
||||
import CoreMedia
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
|
||||
/// Why `startAndWait` could not prove an active PiP window.
|
||||
@@ -174,11 +175,19 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
|
||||
) async -> FlowPiPStartOutcome {
|
||||
if isPictureInPictureActive { return .started }
|
||||
|
||||
FlowDiagnostics.log(
|
||||
"PiP startAndWait begin generation=\(generation) "
|
||||
+ "hostTimeout=\(String(format: "%.2f", hostTimeout)) "
|
||||
+ "activeTimeout=\(String(format: "%.2f", activeTimeout)) "
|
||||
+ "supported=\(AVPictureInPictureController.isPictureInPictureSupported()) "
|
||||
+ hostDiagnosticTag
|
||||
)
|
||||
let deadline = Date().addingTimeInterval(activeTimeout)
|
||||
let supportsPiP = await waitForPictureInPictureSupport(
|
||||
timeout: min(max(0, deadline.timeIntervalSinceNow), 0.8)
|
||||
)
|
||||
guard supportsPiP else {
|
||||
FlowDiagnostics.log("PiP unavailable: unsupported \(hostDiagnosticTag)")
|
||||
return .failed(.unsupported)
|
||||
}
|
||||
|
||||
@@ -186,6 +195,7 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
|
||||
timeout: min(hostTimeout, max(0, deadline.timeIntervalSinceNow))
|
||||
)
|
||||
guard hostReady else {
|
||||
FlowDiagnostics.log("PiP unavailable: hostNotReady \(hostDiagnosticTag)")
|
||||
return .failed(.hostNotReady)
|
||||
}
|
||||
|
||||
@@ -193,8 +203,12 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
|
||||
guard await prepareControllerForStart() else {
|
||||
stopFramePump()
|
||||
if lastSystemStartFailure != nil {
|
||||
FlowDiagnostics.log(
|
||||
"PiP unavailable: systemRejected \(systemFailureDiagnosticTag)"
|
||||
)
|
||||
return .failed(.systemRejected)
|
||||
}
|
||||
FlowDiagnostics.log("PiP unavailable: notPossible \(hostDiagnosticTag)")
|
||||
return .failed(hasHostView ? .notPossible : .hostNotReady)
|
||||
}
|
||||
|
||||
@@ -212,7 +226,7 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
|
||||
|
||||
guard pipController?.isPictureInPicturePossible == true else {
|
||||
FlowDiagnostics.log(
|
||||
"PiP generation \(generation) never became possible"
|
||||
"PiP generation \(generation) never became possible \(hostDiagnosticTag)"
|
||||
)
|
||||
return .failed(.notPossible)
|
||||
}
|
||||
@@ -262,7 +276,8 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
|
||||
}
|
||||
FlowDiagnostics.log(
|
||||
"PiP startAndWait failed: \(failure) possible=\(pipController?.isPictureInPicturePossible == true) "
|
||||
+ "requests=\(startRequestCount)"
|
||||
+ "active=\(pipController?.isPictureInPictureActive == true) "
|
||||
+ "requests=\(startRequestCount) \(systemFailureDiagnosticTag)"
|
||||
)
|
||||
return .failed(failure)
|
||||
}
|
||||
@@ -403,6 +418,26 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
|
||||
return isHostInWindowHierarchy
|
||||
}
|
||||
|
||||
private var hostDiagnosticTag: String {
|
||||
guard let hostView else {
|
||||
return "hostAttached=0 hostWindow=0 hostBounds=nil hostAlpha=nil"
|
||||
}
|
||||
return String(
|
||||
format: "hostAttached=1 hostWindow=%d hostBounds=%.1fx%.1f hostAlpha=%.3f",
|
||||
hostView.window == nil ? 0 : 1,
|
||||
hostView.bounds.width,
|
||||
hostView.bounds.height,
|
||||
hostView.alpha
|
||||
)
|
||||
}
|
||||
|
||||
private var systemFailureDiagnosticTag: String {
|
||||
guard let error = lastSystemStartFailure as NSError? else {
|
||||
return "systemError=nil"
|
||||
}
|
||||
return "systemErrorDomain=\(error.domain) systemErrorCode=\(error.code)"
|
||||
}
|
||||
|
||||
private func configureControllerIfNeeded() {
|
||||
guard pipController == nil else { return }
|
||||
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
|
||||
@@ -604,7 +639,7 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
|
||||
let phoneInset = CGFloat(22)
|
||||
let phoneRect = canvas.insetBy(dx: phoneInset, dy: phoneInset)
|
||||
let phonePath = UIBezierPath(roundedRect: phoneRect, cornerRadius: 28)
|
||||
context.setStrokeColor(UIColor(red: 0.898, green: 0.906, blue: 0.922, alpha: 1).cgColor)
|
||||
context.setStrokeColor(UIColor(OSGColor.pictureInPictureOutline).cgColor)
|
||||
context.setLineWidth(2.5)
|
||||
context.addPath(phonePath.cgPath)
|
||||
context.strokePath()
|
||||
@@ -639,11 +674,11 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
|
||||
private func drawLogoCard(in context: CGContext, rect: CGRect, tuckProgress: CGFloat) {
|
||||
let cardPath = UIBezierPath(roundedRect: rect, cornerRadius: 16)
|
||||
|
||||
context.setFillColor(UIColor(red: 0.20, green: 0.78, blue: 0.55, alpha: 1).cgColor)
|
||||
context.setFillColor(UIColor(OSGColor.pictureInPictureAccent).cgColor)
|
||||
context.addPath(cardPath.cgPath)
|
||||
context.fillPath()
|
||||
|
||||
context.setStrokeColor(UIColor(red: 0.20, green: 0.78, blue: 0.55, alpha: 1).cgColor)
|
||||
context.setStrokeColor(UIColor(OSGColor.pictureInPictureAccent).cgColor)
|
||||
context.setLineWidth(1.5)
|
||||
context.addPath(cardPath.cgPath)
|
||||
context.strokePath()
|
||||
@@ -751,6 +786,7 @@ extension FlowPictureInPictureController: @preconcurrency AVPictureInPictureCont
|
||||
guard pictureInPictureController === pipController else { return }
|
||||
isPictureInPictureActive = true
|
||||
lastSystemStartFailure = nil
|
||||
FlowDiagnostics.log("PiP delegate didStart generation=\(generation)")
|
||||
releaseAudioSessionForLowPowerPiP()
|
||||
}
|
||||
|
||||
@@ -772,7 +808,11 @@ extension FlowPictureInPictureController: @preconcurrency AVPictureInPictureCont
|
||||
// Sample-buffer fallback may need warm-up retries. VideoCall PiP uses
|
||||
// the automatic-inline path plus the bounded startAndWait fallback.
|
||||
lastSystemStartFailure = error
|
||||
FlowDiagnostics.log("PiP start attempt failed (will retry): \(error.localizedDescription)")
|
||||
let error = error as NSError
|
||||
FlowDiagnostics.log(
|
||||
"PiP start attempt failed (will retry): "
|
||||
+ "domain=\(error.domain) code=\(error.code)"
|
||||
)
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
|
||||
@@ -302,6 +302,14 @@ final class FlowSessionManager: ObservableObject {
|
||||
)
|
||||
guard AppGroup.isAvailable else {
|
||||
debug("cannot start flow session: App Group unavailable")
|
||||
FlowDiagnostics.persistStartupFailure(
|
||||
reason: "appGroupUnavailable",
|
||||
context: startupFailureContext(
|
||||
trigger: reason,
|
||||
attemptCount: 0,
|
||||
elapsed: 0
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -396,20 +404,35 @@ final class FlowSessionManager: ObservableObject {
|
||||
reason: String = "unspecified",
|
||||
startCapture: Bool = false
|
||||
) {
|
||||
OSGDiag.log(
|
||||
FlowDiagnostics.log(
|
||||
"activateOnForeground reason=\(reason) startCapture=\(startCapture) "
|
||||
+ "onboardingDone=\(ProviderConfig.shared.hasCompletedOnboarding) "
|
||||
+ "\(OSGDiag.memoryTag())",
|
||||
category: "flow"
|
||||
+ "\(OSGDiag.memoryTag())"
|
||||
)
|
||||
guard AppGroup.isAvailable else {
|
||||
OSGDiag.log("activateOnForeground aborted reason=appGroupUnavailable", category: "flow")
|
||||
FlowDiagnostics.log("activateOnForeground aborted reason=appGroupUnavailable")
|
||||
FlowDiagnostics.persistStartupFailure(
|
||||
reason: "appGroupUnavailable",
|
||||
context: startupFailureContext(
|
||||
trigger: "activateOnForeground:\(reason)",
|
||||
attemptCount: 0,
|
||||
elapsed: 0
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
guard AppPermissions.flowRequirementsMet else {
|
||||
OSGDiag.log("activateOnForeground aborted reason=permissions", category: "flow")
|
||||
FlowDiagnostics.log("activateOnForeground aborted reason=permissions")
|
||||
sessionWarning = permissionWarningMessage()
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
FlowDiagnostics.persistStartupFailure(
|
||||
reason: "permissionsMissing",
|
||||
context: startupFailureContext(
|
||||
trigger: "activateOnForeground:\(reason)",
|
||||
attemptCount: 0,
|
||||
elapsed: 0
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -422,10 +445,30 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
/// User-requested recovery always discards the rejected AVKit generation.
|
||||
func retryPiPRecovery() {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
guard AppGroup.isAvailable else {
|
||||
FlowDiagnostics.log("manual PiP recovery aborted reason=appGroupUnavailable")
|
||||
FlowDiagnostics.persistStartupFailure(
|
||||
reason: "appGroupUnavailable",
|
||||
context: startupFailureContext(
|
||||
trigger: FlowPiPActivationTrigger.manualRetry.rawValue,
|
||||
attemptCount: 0,
|
||||
elapsed: 0
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
guard AppPermissions.flowRequirementsMet else {
|
||||
sessionWarning = permissionWarningMessage()
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
FlowDiagnostics.log("manual PiP recovery aborted reason=permissions")
|
||||
FlowDiagnostics.persistStartupFailure(
|
||||
reason: "permissionsMissing",
|
||||
context: startupFailureContext(
|
||||
trigger: FlowPiPActivationTrigger.manualRetry.rawValue,
|
||||
attemptCount: 0,
|
||||
elapsed: 0
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
wantsActiveSession = true
|
||||
@@ -933,6 +976,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
guard !Task.isCancelled else { return }
|
||||
traceState("startSessionAsync.begin", extra: "trigger=\(trigger.rawValue)")
|
||||
sessionWarning = nil
|
||||
let recoveryStartedAt = Date()
|
||||
|
||||
guard AppPermissions.flowRequirementsMet else {
|
||||
sessionWarning = permissionWarningMessage()
|
||||
@@ -940,11 +984,20 @@ final class FlowSessionManager: ObservableObject {
|
||||
traceState("startSessionAsync.blocked", extra: "reason=permissions")
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
isColdStartHandoff = false
|
||||
FlowDiagnostics.persistStartupFailure(
|
||||
reason: "permissionsMissing",
|
||||
context: startupFailureContext(
|
||||
trigger: trigger.rawValue,
|
||||
attemptCount: 0,
|
||||
elapsed: Date().timeIntervalSince(recoveryStartedAt)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(FlowPiPRecoveryPolicy.totalBudget)
|
||||
var lastFailure: FlowPiPStartFailure = .timedOut
|
||||
var attemptsPerformed = 0
|
||||
|
||||
for attempt in 1...FlowPiPRecoveryPolicy.maxAttempts {
|
||||
guard FlowPiPRecoveryPolicy.canContinue(
|
||||
@@ -984,6 +1037,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
let remaining = deadline.timeIntervalSinceNow
|
||||
guard remaining > 0 else { break }
|
||||
attemptsPerformed = attempt
|
||||
pipLifecycleState = isActive
|
||||
? .recovering(attempt: attempt, total: FlowPiPRecoveryPolicy.maxAttempts)
|
||||
: .preparing(attempt: attempt, total: FlowPiPRecoveryPolicy.maxAttempts)
|
||||
@@ -1001,6 +1055,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
remainingBudget: deadline.timeIntervalSinceNow
|
||||
)
|
||||
)
|
||||
let recoveryElapsed = FlowPiPRecoveryPolicy.totalBudget
|
||||
- max(0, deadline.timeIntervalSinceNow)
|
||||
guard FlowPiPRecoveryPolicy.canContinue(
|
||||
operation: operation,
|
||||
currentOperation: pipRecoveryOperation,
|
||||
@@ -1029,7 +1085,9 @@ final class FlowSessionManager: ObservableObject {
|
||||
lastFailure = failure
|
||||
traceState(
|
||||
"startSessionAsync.failed",
|
||||
extra: "reason=pipUnavailable trigger=\(trigger.rawValue) attempt=\(attempt) failure=\(failure)"
|
||||
extra: "reason=pipUnavailable trigger=\(trigger.rawValue) "
|
||||
+ "attempt=\(attempt) failure=\(failure) "
|
||||
+ "elapsed=\(String(format: "%.2f", recoveryElapsed))"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1040,6 +1098,14 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
refreshHostReady()
|
||||
debug("PiP keep-alive failed after bounded recovery: \(lastFailure)")
|
||||
FlowDiagnostics.persistStartupFailure(
|
||||
reason: String(describing: lastFailure),
|
||||
context: startupFailureContext(
|
||||
trigger: trigger.rawValue,
|
||||
attemptCount: attemptsPerformed,
|
||||
elapsed: Date().timeIntervalSince(recoveryStartedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func activateFlowSessionAfterPiPProof(duration: TimeInterval?) {
|
||||
@@ -3539,6 +3605,33 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowDiagnostics.log(message)
|
||||
}
|
||||
|
||||
private func startupFailureContext(
|
||||
trigger: String,
|
||||
attemptCount: Int,
|
||||
elapsed: TimeInterval
|
||||
) -> [String: String] {
|
||||
let memory = OSGDiag.memorySnapshot()
|
||||
return [
|
||||
"trigger": trigger,
|
||||
"attemptCount": "\(attemptCount)",
|
||||
"elapsedSeconds": String(format: "%.3f", elapsed),
|
||||
"appForeground": isAppForeground ? "true" : "false",
|
||||
"sessionActive": isActive ? "true" : "false",
|
||||
"sessionIntent": wantsActiveSession ? "true" : "false",
|
||||
"pipLifecycleState": String(describing: pipLifecycleState),
|
||||
"pipGeneration": "\(pipController.generation)",
|
||||
"pipActive": pipController.isPictureInPictureActive ? "true" : "false",
|
||||
"hostReady": FlowSessionBridge.isHostReady() ? "true" : "false",
|
||||
"bridgeSessionActive": FlowSessionBridge.isSessionActive() ? "true" : "false",
|
||||
"heartbeatStalenessSeconds": FlowSessionBridge.heartbeatStaleness()
|
||||
.map { String(format: "%.3f", $0) } ?? "nil",
|
||||
"microphonePermission": String(describing: AppPermissions.micStatus),
|
||||
"speechPermission": String(describing: AppPermissions.speechStatus),
|
||||
"rssMB": String(format: "%.1f", memory.rssMB),
|
||||
"physicalFootprintMB": String(format: "%.1f", memory.physFootprintMB)
|
||||
]
|
||||
}
|
||||
|
||||
private static func safeErrorLogMetadata(_ error: Error) -> String {
|
||||
"errorCategory=\(String(reflecting: type(of: error))) "
|
||||
+ "errorBytes=\(error.localizedDescription.utf8.count)"
|
||||
|
||||
@@ -67,6 +67,7 @@ struct AIAgentSkillsView: View {
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
.accessibilityLabel(Text("skills.add"))
|
||||
}
|
||||
}
|
||||
@@ -141,6 +142,7 @@ struct AIAgentSkillsView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.cardElevation()
|
||||
.accessibilityIdentifier("skills.clipboard.guide")
|
||||
}
|
||||
|
||||
@@ -367,6 +369,7 @@ struct AIAgentSkillsView: View {
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.clipShape(skillCardShape)
|
||||
.cardElevation()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@@ -600,7 +603,10 @@ private struct SkillDetailView: View {
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.surfaceCard()
|
||||
.background(
|
||||
palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,11 +737,15 @@ private struct SkillEditorSheet: View {
|
||||
Form {
|
||||
Section("skills.editor.name") {
|
||||
TextField("skills.editor.namePlaceholder", text: $name)
|
||||
.settingsListRow()
|
||||
.cardListRow(elevated: false)
|
||||
}
|
||||
Section("skills.editor.summary") {
|
||||
TextField("skills.editor.summaryPlaceholder", text: $summary, axis: .vertical)
|
||||
.lineLimit(6...12)
|
||||
.frame(minHeight: 108, alignment: .top)
|
||||
.settingsListRow(alignment: .topLeading)
|
||||
.cardListRow(elevated: false)
|
||||
}
|
||||
Section("skills.editor.icon") {
|
||||
Button {
|
||||
@@ -743,11 +753,12 @@ private struct SkillEditorSheet: View {
|
||||
} label: {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 44, height: 44)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
palette.accentMuted,
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -764,11 +775,16 @@ private struct SkillEditorSheet: View {
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.settingsListRow()
|
||||
.cardListRow(elevated: false)
|
||||
}
|
||||
Section {
|
||||
TextEditor(text: $prompt)
|
||||
.font(.body.monospaced())
|
||||
.frame(minHeight: 180)
|
||||
.padding(Spacing.md)
|
||||
.cardListRow(elevated: false)
|
||||
} header: {
|
||||
HStack {
|
||||
Text("skills.editor.prompt")
|
||||
@@ -782,20 +798,33 @@ private struct SkillEditorSheet: View {
|
||||
}
|
||||
}
|
||||
Section {
|
||||
TextField("skills.editor.linkPlaceholder", text: $shortcutLink)
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
TextField("skills.editor.shortcutNamePlaceholder", text: $shortcutName)
|
||||
if isLookingUp {
|
||||
Text("skills.editor.lookingUp")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
} else if let lookupMessage {
|
||||
Text(lookupMessage)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
VStack(spacing: 0) {
|
||||
TextField("skills.editor.linkPlaceholder", text: $shortcutLink)
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.settingsListRow()
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
TextField("skills.editor.shortcutNamePlaceholder", text: $shortcutName)
|
||||
.settingsListRow()
|
||||
|
||||
if isLookingUp {
|
||||
Divider().background(palette.divider)
|
||||
Text("skills.editor.lookingUp")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.settingsListRow()
|
||||
} else if let lookupMessage {
|
||||
Divider().background(palette.divider)
|
||||
Text(lookupMessage)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.settingsListRow()
|
||||
}
|
||||
}
|
||||
.cardListRow(elevated: false)
|
||||
} header: {
|
||||
Text("skills.editor.shortcut")
|
||||
} footer: {
|
||||
@@ -803,6 +832,8 @@ private struct SkillEditorSheet: View {
|
||||
}
|
||||
Section {
|
||||
Toggle("skills.editor.thinking", isOn: $thinkingEnabled)
|
||||
.settingsListRow()
|
||||
.cardListRow(elevated: false)
|
||||
} footer: {
|
||||
Text("skills.editor.thinkingHint")
|
||||
}
|
||||
@@ -811,6 +842,8 @@ private struct SkillEditorSheet: View {
|
||||
Button("common.delete", role: .destructive) {
|
||||
confirmDelete = true
|
||||
}
|
||||
.settingsListRow()
|
||||
.cardListRow(elevated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -819,6 +852,7 @@ private struct SkillEditorSheet: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("common.cancel") { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.save") {
|
||||
@@ -830,6 +864,7 @@ private struct SkillEditorSheet: View {
|
||||
}
|
||||
}
|
||||
.disabled(!canSave)
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSymbolPicker) {
|
||||
@@ -970,12 +1005,12 @@ private struct SkillSymbolPicker: View {
|
||||
} label: {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(
|
||||
symbol == selection ? palette.textOnAccent : palette.textPrimary
|
||||
)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(
|
||||
symbol == selection ? palette.accent : palette.surfaceElevated,
|
||||
symbol == selection
|
||||
? palette.textPrimary.opacity(0.12)
|
||||
: palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
)
|
||||
}
|
||||
@@ -993,6 +1028,7 @@ private struct SkillSymbolPicker: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.done") { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
)
|
||||
.background(Palette.light.background)
|
||||
}
|
||||
.background(Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea())
|
||||
.background(OSGColor.demoBackground.ignoresSafeArea())
|
||||
.environment(\.locale, language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans"))
|
||||
.preferredColorScheme(.light)
|
||||
.task { await runTimeline() }
|
||||
@@ -48,7 +48,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
VStack(spacing: 10) {
|
||||
Text("技能布局预览")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
HStack(spacing: 16) {
|
||||
Button {
|
||||
count = max(1, count - 1)
|
||||
@@ -58,7 +58,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
}
|
||||
Text("\(count) 个")
|
||||
.font(.title2.monospacedDigit().weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
.frame(minWidth: 72)
|
||||
Button {
|
||||
count = min(AIClipboardSkillCatalog.catalog.count, count + 1)
|
||||
@@ -67,14 +67,14 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
.font(.system(size: 28))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.green)
|
||||
.foregroundStyle(OSGColor.brandAccent)
|
||||
HStack(spacing: 8) {
|
||||
ForEach([3, 8, AIClipboardSkillCatalog.catalog.count], id: \.self) { n in
|
||||
Button("\(n)") {
|
||||
count = n
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(count == n ? .green : .gray)
|
||||
.tint(count == n ? OSGColor.brandAccent : OSGColor.demoSelectionInactive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ struct AIKeyboardDemoView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
|
||||
OSGColor.demoBackground.ignoresSafeArea()
|
||||
// Both scenes sit on the bottom band so what's-new crop matches Ext chrome.
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
@@ -152,7 +152,8 @@ private enum AccountAppleAuthorizationPayload {
|
||||
let identityToken = String(data: identityTokenData, encoding: .utf8),
|
||||
let authorizationCode = String(data: authorizationCodeData, encoding: .utf8),
|
||||
!identityToken.isEmpty,
|
||||
!authorizationCode.isEmpty
|
||||
!authorizationCode.isEmpty,
|
||||
!credential.user.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,16 +13,22 @@ import SwiftUI
|
||||
struct AccountCenterUITestHarness: View {
|
||||
@StateObject private var coordinator: AccountSessionCoordinator
|
||||
private let startsSignIn: Bool
|
||||
private let screenshotLanguage: AppUILanguage?
|
||||
|
||||
init() {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
let startsSignIn = ProcessInfo.processInfo.arguments.contains(
|
||||
"--account-signin-loading"
|
||||
)
|
||||
let staysSignedOut = arguments.contains("--account-signed-out")
|
||||
screenshotLanguage = arguments.contains("--screenshot-lang=en")
|
||||
? .english
|
||||
: arguments.contains("--screenshot-lang=zh") ? .chinese : nil
|
||||
let service = AccountCenterUITestService(
|
||||
shouldFailSnapshot: ProcessInfo.processInfo.arguments.contains(
|
||||
shouldFailSnapshot: arguments.contains(
|
||||
"--account-snapshot-failure"
|
||||
),
|
||||
startsSignedOut: startsSignIn,
|
||||
startsSignedOut: startsSignIn || staysSignedOut,
|
||||
signInDelay: startsSignIn ? .seconds(5) : .zero
|
||||
)
|
||||
self.startsSignIn = startsSignIn
|
||||
@@ -46,6 +52,7 @@ struct AccountCenterUITestHarness: View {
|
||||
.environmentObject(coordinator)
|
||||
}
|
||||
}
|
||||
.environment(\.locale, screenshotLanguage?.swiftUILocale ?? Locale.current)
|
||||
.task {
|
||||
await coordinator.restoreIfNeeded()
|
||||
if startsSignIn {
|
||||
@@ -53,7 +60,8 @@ struct AccountCenterUITestHarness: View {
|
||||
with: AppleAuthorizationPayload(
|
||||
identityToken: "ui-test-identity",
|
||||
authorizationCode: "ui-test-authorization",
|
||||
nonce: "ui-test-nonce"
|
||||
nonce: "ui-test-nonce",
|
||||
userIdentifier: "ui-test-apple-user"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,11 +21,17 @@ struct AccountCenterView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// AuthenticationServices uses translucent system chrome on
|
||||
// iOS 26. Keep its underlying surface uniform without covering
|
||||
// the account content that remains visible below the system sheet.
|
||||
palette.background.ignoresSafeArea()
|
||||
sessionContent
|
||||
}
|
||||
.background(palette.background)
|
||||
.navigationTitle("account.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(palette.background, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.task(id: coordinator.accountID) {
|
||||
guard let accountID = coordinator.accountID else {
|
||||
coordinator.creditPurchases.reset()
|
||||
@@ -42,18 +48,6 @@ struct AccountCenterView: View {
|
||||
.onDisappear {
|
||||
coordinator.creditPurchases.dismissSuccessMessage()
|
||||
}
|
||||
.alert(
|
||||
"account.error.title",
|
||||
isPresented: operationErrorBinding
|
||||
) {
|
||||
Button("common.done") {
|
||||
coordinator.dismissOperationError()
|
||||
}
|
||||
} message: {
|
||||
if let key = coordinator.operationErrorKey {
|
||||
Text(LocalizedStringKey(key))
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
"account.profile.editTitle",
|
||||
isPresented: $showProfileEditor
|
||||
@@ -62,12 +56,14 @@ struct AccountCenterView: View {
|
||||
.textInputAutocapitalization(.words)
|
||||
.autocorrectionDisabled()
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
.tint(palette.textPrimary)
|
||||
Button("account.profile.save") {
|
||||
if case let .signedIn(session) = coordinator.sessionPhase {
|
||||
saveDisplayName(session: session)
|
||||
}
|
||||
}
|
||||
.disabled(!canSaveCurrentDisplayName)
|
||||
.tint(palette.textPrimary)
|
||||
} message: {
|
||||
Text("account.profile.editMessage")
|
||||
}
|
||||
@@ -132,6 +128,8 @@ struct AccountCenterView: View {
|
||||
.surfaceCard()
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
}
|
||||
|
||||
private func signedInContent(session: AccountSession) -> some View {
|
||||
@@ -143,6 +141,8 @@ struct AccountCenterView: View {
|
||||
}
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.refreshable {
|
||||
async let accountRefresh: Void = coordinator.refreshAccountData(force: true)
|
||||
async let catalogRefresh: Void =
|
||||
@@ -173,6 +173,16 @@ struct AccountCenterView: View {
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
if let errorKey = coordinator.accountRefreshErrorKey {
|
||||
AccountStateCard(
|
||||
systemImage: "exclamationmark.triangle",
|
||||
titleKey: errorKey,
|
||||
actionKey: "account.retry"
|
||||
) {
|
||||
Task { await coordinator.refreshAccountData(force: true) }
|
||||
}
|
||||
}
|
||||
|
||||
CardSection("account.storekit.section") {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
AccountCreditPurchaseSection(
|
||||
@@ -257,7 +267,7 @@ struct AccountCenterView: View {
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(palette.accent)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(palette.surfaceElevated, in: Circle())
|
||||
}
|
||||
@@ -267,6 +277,7 @@ struct AccountCenterView: View {
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.background(palette.surface, in: shape)
|
||||
.cardElevation()
|
||||
.accessibilityIdentifier("account.summary")
|
||||
}
|
||||
|
||||
@@ -378,7 +389,7 @@ struct AccountCenterView: View {
|
||||
|
||||
if coordinator.operation == .deletingAccount {
|
||||
ProgressView("account.delete.loading")
|
||||
.tint(palette.accent)
|
||||
.tint(palette.textPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
@@ -393,22 +404,16 @@ struct AccountCenterView: View {
|
||||
Button("common.cancel") {
|
||||
showDeleteReauthentication = false
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbarBackground(palette.background, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.presentationDetents([.medium])
|
||||
.presentationBackground(palette.background)
|
||||
}
|
||||
|
||||
private var operationErrorBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { coordinator.operationErrorKey != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
coordinator.dismissOperationError()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountAvatarView: View {
|
||||
@@ -416,28 +421,20 @@ struct AccountAvatarView: View {
|
||||
let displayName: String?
|
||||
let size: CGFloat
|
||||
|
||||
private static let gradients: [[Color]] = [
|
||||
[.indigo, .blue],
|
||||
[.purple, .pink],
|
||||
[.teal, .cyan],
|
||||
[.orange, .red],
|
||||
[.mint, .green]
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
Text(initials)
|
||||
.font(.system(size: size * 0.34, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
.frame(width: size, height: size)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: Self.gradients[gradientIndex],
|
||||
colors: OSGColor.accountAvatarGradients[gradientIndex],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
in: Circle()
|
||||
)
|
||||
.overlay(Circle().stroke(.white.opacity(0.2), lineWidth: 0.5))
|
||||
.overlay(Circle().stroke(OSGColor.accountAvatarOutline, lineWidth: 0.5))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
@@ -453,7 +450,7 @@ struct AccountAvatarView: View {
|
||||
let hash = accountID.uuidString.unicodeScalars.reduce(UInt32(2_166_136_261)) {
|
||||
($0 ^ $1.value) &* 16_777_619
|
||||
}
|
||||
return Int(hash % UInt32(Self.gradients.count))
|
||||
return Int(hash % UInt32(OSGColor.accountAvatarGradients.count))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,13 +575,13 @@ private struct AccountCreditPurchaseSection: View {
|
||||
if purchasingProductID == option.productID {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(.white)
|
||||
.tint(OSGColor.fixedLightContent)
|
||||
} else {
|
||||
Text("account.storekit.purchase")
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minWidth: 54, minHeight: 34)
|
||||
.background(
|
||||
@@ -717,22 +714,18 @@ struct AccountCreditProgress: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(palette.surfaceElevated)
|
||||
HStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(palette.success)
|
||||
.frame(width: proxy.size.width * remainingFraction)
|
||||
Rectangle()
|
||||
.fill(palette.textTertiary.opacity(0.55))
|
||||
.frame(width: proxy.size.width * usedFraction)
|
||||
}
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
ProgressTrack(
|
||||
segments: [
|
||||
ProgressTrackSegment(
|
||||
fraction: remainingFraction,
|
||||
color: palette.success
|
||||
),
|
||||
ProgressTrackSegment(
|
||||
fraction: usedFraction,
|
||||
color: palette.textTertiary.opacity(0.32)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if showsLabels {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
@@ -787,7 +780,7 @@ struct AccountInvitationButton: View {
|
||||
} label: {
|
||||
Label("account.referral.inviteTitle", systemImage: "person.badge.plus")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 38)
|
||||
.background(
|
||||
|
||||
@@ -245,14 +245,14 @@ struct AppleAuthorizationPayload: Equatable, Sendable {
|
||||
let authorizationCode: String
|
||||
let nonce: String
|
||||
let displayName: String?
|
||||
let userIdentifier: String?
|
||||
let userIdentifier: String
|
||||
|
||||
init(
|
||||
identityToken: String,
|
||||
authorizationCode: String,
|
||||
nonce: String,
|
||||
displayName: String? = nil,
|
||||
userIdentifier: String? = nil
|
||||
userIdentifier: String
|
||||
) {
|
||||
self.identityToken = identityToken
|
||||
self.authorizationCode = authorizationCode
|
||||
@@ -265,6 +265,7 @@ struct AppleAuthorizationPayload: Equatable, Sendable {
|
||||
enum AccountAppleCredentialState: Equatable, Sendable {
|
||||
case authorized
|
||||
case revoked
|
||||
case reauthenticationRequired
|
||||
case unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -68,50 +68,48 @@ struct AccountPurchaseHistoryView: View {
|
||||
private func purchaseList(
|
||||
_ records: [AccountCreditPurchaseRecord]
|
||||
) -> some View {
|
||||
List {
|
||||
ForEach(records) { record in
|
||||
purchaseRow(record)
|
||||
.listRowBackground(palette.surface)
|
||||
.listRowInsets(
|
||||
EdgeInsets(
|
||||
top: 0,
|
||||
leading: Spacing.md,
|
||||
bottom: 0,
|
||||
trailing: Spacing.md
|
||||
)
|
||||
)
|
||||
.listRowSeparatorTint(palette.divider)
|
||||
.task {
|
||||
guard record.id == records.last?.id else { return }
|
||||
await manager.loadNextPurchaseHistoryPage(accountID: accountID)
|
||||
}
|
||||
}
|
||||
ScrollView {
|
||||
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
ForEach(records) { record in
|
||||
purchaseRow(record)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.surfaceCard()
|
||||
.task {
|
||||
guard record.id == records.last?.id else { return }
|
||||
await manager.loadNextPurchaseHistoryPage(accountID: accountID)
|
||||
}
|
||||
}
|
||||
|
||||
if manager.isLoadingMoreHistory {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Spacer()
|
||||
}
|
||||
.listRowBackground(palette.surface)
|
||||
} else if let errorKey = manager.historyLoadMoreErrorKey {
|
||||
Button {
|
||||
Task {
|
||||
await manager.loadNextPurchaseHistoryPage(accountID: accountID)
|
||||
if manager.isLoadingMoreHistory {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Spacer()
|
||||
}
|
||||
} label: {
|
||||
Text(LocalizedStringKey(errorKey))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.settingsListRow()
|
||||
.surfaceCard()
|
||||
} else if let errorKey = manager.historyLoadMoreErrorKey {
|
||||
Button {
|
||||
Task {
|
||||
await manager.loadNextPurchaseHistoryPage(accountID: accountID)
|
||||
}
|
||||
} label: {
|
||||
Text(LocalizedStringKey(errorKey))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.settingsListRow()
|
||||
.surfaceCard()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(palette.surface)
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.top, Spacing.md)
|
||||
.padding(.bottom, Spacing.xxl)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.scrollClipDisabled()
|
||||
.background(palette.background)
|
||||
.accessibilityIdentifier("account.purchaseHistory.list")
|
||||
.refreshable {
|
||||
|
||||
@@ -31,15 +31,6 @@ struct AccountRewardsCard: View {
|
||||
guard coordinator.isSignedIn else { return }
|
||||
await coordinator.refreshAccountData()
|
||||
}
|
||||
.alert("account.error.title", isPresented: operationErrorBinding) {
|
||||
Button("common.done") {
|
||||
coordinator.dismissOperationError()
|
||||
}
|
||||
} message: {
|
||||
if let key = coordinator.operationErrorKey {
|
||||
Text(LocalizedStringKey(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var signedOutContent: some View {
|
||||
@@ -59,10 +50,10 @@ struct AccountRewardsCard: View {
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("home.accountRewards.signedOut.title")
|
||||
Text("account.signedOut.title")
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("home.accountRewards.signedOut.summary")
|
||||
Text("account.signedOut.body")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -189,16 +180,6 @@ struct AccountRewardsCard: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var operationErrorBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { coordinator.operationErrorKey != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
coordinator.dismissOperationError()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountReferralLinkView: View {
|
||||
|
||||
@@ -58,8 +58,10 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
private var didAttemptRestore = false
|
||||
private var accountRefreshTask: Task<Void, Never>?
|
||||
private var accountRefreshRequestID: UUID?
|
||||
private var pendingForcedAccountRefresh = false
|
||||
private var sessionEventsTask: Task<Void, Never>?
|
||||
private var appleCredentialRevocationCancellable: AnyCancellable?
|
||||
private var sessionRevision: UInt64 = 0
|
||||
|
||||
init(
|
||||
dependencies: AccountDependencies,
|
||||
@@ -70,6 +72,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
UserDefaultsReferralProfileStore(),
|
||||
accountRefreshInterval: TimeInterval = 10 * 60,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
notificationCenter: NotificationCenter = .default,
|
||||
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||
onAccountAuthenticated: @escaping (UUID) async -> Void = { _ in },
|
||||
onAccountSignedOut: @escaping () async -> Void = {},
|
||||
@@ -102,11 +105,13 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
await self?.handleSessionEvent(event)
|
||||
}
|
||||
}
|
||||
appleCredentialRevocationCancellable = NotificationCenter.default
|
||||
appleCredentialRevocationCancellable = notificationCenter
|
||||
.publisher(for: ASAuthorizationAppleIDProvider.credentialRevokedNotification)
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.handleAppleCredentialRevocation()
|
||||
await self?.handleAppleCredentialRevocation(
|
||||
errorKey: "account.error.sessionExpired"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,6 +147,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
return
|
||||
}
|
||||
await onAccountAuthenticated(session.accountID)
|
||||
advanceSessionRevision()
|
||||
sessionPhase = .signedIn(session)
|
||||
creditPurchases.startSession(accountID: session.accountID)
|
||||
referralProfile.startSession(accountID: session.accountID)
|
||||
@@ -167,11 +173,19 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
}
|
||||
|
||||
func validateAppleCredentialState() async {
|
||||
guard isSignedIn,
|
||||
await sessionService.appleCredentialState() == .revoked else {
|
||||
guard isSignedIn else { return }
|
||||
switch await sessionService.appleCredentialState() {
|
||||
case .authorized, .unknown:
|
||||
return
|
||||
case .revoked:
|
||||
await handleAppleCredentialRevocation(
|
||||
errorKey: "account.error.sessionExpired"
|
||||
)
|
||||
case .reauthenticationRequired:
|
||||
await handleAppleCredentialRevocation(
|
||||
errorKey: "account.error.appleReauthenticationRequired"
|
||||
)
|
||||
}
|
||||
await handleAppleCredentialRevocation()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -208,6 +222,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
do {
|
||||
let session = try await sessionService.signIn(with: payload)
|
||||
await onAccountAuthenticated(session.accountID)
|
||||
advanceSessionRevision()
|
||||
sessionPhase = .signedIn(session)
|
||||
creditPurchases.startSession(accountID: session.accountID)
|
||||
referralProfile.startSession(accountID: session.accountID)
|
||||
@@ -229,6 +244,9 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
return
|
||||
}
|
||||
if let accountRefreshTask {
|
||||
if force {
|
||||
pendingForcedAccountRefresh = true
|
||||
}
|
||||
await accountRefreshTask.value
|
||||
return
|
||||
}
|
||||
@@ -257,16 +275,27 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
accountRefreshTask = task
|
||||
await task.value
|
||||
finishAccountRefresh(requestID: requestID)
|
||||
let shouldRunForcedRefresh = pendingForcedAccountRefresh
|
||||
pendingForcedAccountRefresh = false
|
||||
if shouldRunForcedRefresh, self.accountID != nil {
|
||||
await refreshAccountData(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
func updateDisplayName(_ displayName: String) async -> Bool {
|
||||
guard operation == nil, isSignedIn else { return false }
|
||||
guard operation == nil, let expectedAccountID = accountID else { return false }
|
||||
let expectedRevision = sessionRevision
|
||||
operation = .updatingProfile
|
||||
operationErrorKey = nil
|
||||
defer { operation = nil }
|
||||
|
||||
do {
|
||||
let account = try await centerService.updateDisplayName(displayName)
|
||||
guard sessionRevision == expectedRevision,
|
||||
accountID == expectedAccountID,
|
||||
account.accountID == expectedAccountID else {
|
||||
return false
|
||||
}
|
||||
sessionPhase = .signedIn(account)
|
||||
if case let .loaded(snapshot) = snapshotPhase {
|
||||
snapshotPhase = .loaded(
|
||||
@@ -279,6 +308,10 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
guard sessionRevision == expectedRevision,
|
||||
accountID == expectedAccountID else {
|
||||
return false
|
||||
}
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.updateProfile"
|
||||
@@ -288,14 +321,24 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
}
|
||||
|
||||
func prepareManagedGateway() async -> Bool {
|
||||
guard operation == nil, isSignedIn else { return false }
|
||||
guard operation == nil, let expectedAccountID = accountID else { return false }
|
||||
let expectedRevision = sessionRevision
|
||||
operation = .preparingManagedGateway
|
||||
operationErrorKey = nil
|
||||
defer { operation = nil }
|
||||
do {
|
||||
try await sessionService.prepareManagedGateway()
|
||||
guard sessionRevision == expectedRevision,
|
||||
accountID == expectedAccountID else {
|
||||
await sessionService.clearManagedGateway()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
guard sessionRevision == expectedRevision,
|
||||
accountID == expectedAccountID else {
|
||||
return false
|
||||
}
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.managedGateway"
|
||||
@@ -318,6 +361,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
do {
|
||||
try await sessionService.signOut()
|
||||
await onAccountSignedOut()
|
||||
advanceSessionRevision()
|
||||
creditPurchases.endSession()
|
||||
clearAccountRefreshState()
|
||||
referralProfile.endSession()
|
||||
@@ -342,6 +386,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
try await sessionService.deleteAccount(with: payload)
|
||||
await onAccountDeleted()
|
||||
await onAccountSignedOut()
|
||||
advanceSessionRevision()
|
||||
creditPurchases.endSession()
|
||||
clearAccountRefreshState()
|
||||
referralProfile.endSession(removeCache: true)
|
||||
@@ -412,6 +457,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
accountRefreshTask?.cancel()
|
||||
accountRefreshTask = nil
|
||||
accountRefreshRequestID = nil
|
||||
pendingForcedAccountRefresh = false
|
||||
lastAccountRefreshAt = nil
|
||||
isRefreshingAccountData = false
|
||||
accountRefreshErrorKey = nil
|
||||
@@ -427,26 +473,48 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
private func handleSessionEvent(_ event: AccountSessionEvent) async {
|
||||
switch event {
|
||||
case .expired:
|
||||
await transitionToExpiredSession()
|
||||
await expireSession(
|
||||
errorKey: "account.error.sessionExpired",
|
||||
signsOutService: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAppleCredentialRevocation() async {
|
||||
guard isSignedIn else { return }
|
||||
try? await sessionService.signOut()
|
||||
await transitionToExpiredSession()
|
||||
private func handleAppleCredentialRevocation(errorKey: String) async {
|
||||
guard operation != .signingOut,
|
||||
operation != .deletingAccount else {
|
||||
return
|
||||
}
|
||||
await expireSession(errorKey: errorKey, signsOutService: true)
|
||||
}
|
||||
|
||||
private func transitionToExpiredSession() async {
|
||||
private func expireSession(
|
||||
errorKey: String,
|
||||
signsOutService: Bool
|
||||
) async {
|
||||
guard isSignedIn else { return }
|
||||
advanceSessionRevision()
|
||||
if signsOutService {
|
||||
do {
|
||||
try await sessionService.signOut()
|
||||
} catch {
|
||||
operationErrorKey = "account.error.signOut"
|
||||
return
|
||||
}
|
||||
}
|
||||
await sessionService.clearManagedGateway()
|
||||
await onAccountSignedOut()
|
||||
creditPurchases.endSession()
|
||||
clearAccountRefreshState()
|
||||
referralProfile.endSession()
|
||||
operation = nil
|
||||
sessionPhase = .signedOut
|
||||
snapshotPhase = .idle
|
||||
operationErrorKey = "account.error.sessionExpired"
|
||||
operationErrorKey = errorKey
|
||||
}
|
||||
|
||||
private func advanceSessionRevision() {
|
||||
sessionRevision &+= 1
|
||||
}
|
||||
|
||||
private func redeemPendingReferralIfNeeded() async {
|
||||
|
||||
@@ -186,7 +186,7 @@ private struct SystemAppleCredentialStateChecker: AppleCredentialStateChecking {
|
||||
case .revoked, .notFound:
|
||||
continuation.resume(returning: .revoked)
|
||||
case .transferred:
|
||||
continuation.resume(returning: .unknown)
|
||||
continuation.resume(returning: .reauthenticationRequired)
|
||||
@unknown default:
|
||||
continuation.resume(returning: .unknown)
|
||||
}
|
||||
@@ -324,6 +324,15 @@ private actor LiveAccountService:
|
||||
+ "appAttest=\(evidence.appAttest == nil ? 0 : 1)",
|
||||
category: "account"
|
||||
)
|
||||
do {
|
||||
try await appleUserIdentifiers.saveAppleUserIdentifier(payload.userIdentifier)
|
||||
} catch {
|
||||
OSGDiag.log(
|
||||
"signIn failed stage=apple-user-identifier",
|
||||
category: "account"
|
||||
)
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
let session: OSGKeyboardHostSupport.AccountSession
|
||||
do {
|
||||
session = try await apiClient.signInWithApple(
|
||||
@@ -337,33 +346,43 @@ private actor LiveAccountService:
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
try? await appleUserIdentifiers.clearAppleUserIdentifier()
|
||||
OSGDiag.log(
|
||||
"signIn failed stage=session error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
throw error
|
||||
}
|
||||
if let userIdentifier = payload.userIdentifier,
|
||||
!userIdentifier.isEmpty {
|
||||
try? await appleUserIdentifiers.saveAppleUserIdentifier(userIdentifier)
|
||||
}
|
||||
let account: OSGAccount
|
||||
let account: OSGAccount?
|
||||
do {
|
||||
account = try await apiClient.account()
|
||||
} catch let error as AccountAPIError {
|
||||
switch error {
|
||||
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
||||
try? await appleUserIdentifiers.clearAppleUserIdentifier()
|
||||
await invalidateManagedGatewaySession()
|
||||
throw error
|
||||
default:
|
||||
OSGDiag.log(
|
||||
"signIn account fallback error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
account = nil
|
||||
}
|
||||
} catch {
|
||||
OSGDiag.log(
|
||||
"signIn failed stage=account error=\(AccountDiagnostic.code(for: error))",
|
||||
"signIn account fallback error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
throw error
|
||||
account = nil
|
||||
}
|
||||
configuration.setManagedGatewayAccountSessionAvailable(true)
|
||||
await synchronizeManagedGrant()
|
||||
OSGDiag.log("signIn completed", category: "account")
|
||||
return uiSession(
|
||||
session,
|
||||
createdAtEpochSeconds: account.createdAtEpochSeconds,
|
||||
displayName: account.displayName
|
||||
createdAtEpochSeconds: account?.createdAtEpochSeconds ?? 0,
|
||||
displayName: account?.displayName ?? payload.displayName
|
||||
)
|
||||
}
|
||||
|
||||
@@ -375,7 +394,6 @@ private actor LiveAccountService:
|
||||
try await apiClient.logout()
|
||||
} catch {
|
||||
try? await grants.clearGrant()
|
||||
try? await appleUserIdentifiers.clearAppleUserIdentifier()
|
||||
throw error
|
||||
}
|
||||
try? await grants.clearGrant()
|
||||
@@ -396,10 +414,15 @@ private actor LiveAccountService:
|
||||
}
|
||||
|
||||
func appleCredentialState() async -> AccountAppleCredentialState {
|
||||
guard let userIdentifier = try? await appleUserIdentifiers.loadAppleUserIdentifier(),
|
||||
!userIdentifier.isEmpty else {
|
||||
let userIdentifier: String?
|
||||
do {
|
||||
userIdentifier = try await appleUserIdentifiers.loadAppleUserIdentifier()
|
||||
} catch {
|
||||
return .unknown
|
||||
}
|
||||
guard let userIdentifier, !userIdentifier.isEmpty else {
|
||||
return .reauthenticationRequired
|
||||
}
|
||||
return await appleCredentialChecker.state(for: userIdentifier)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ struct ClipboardHistoryDemoView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
|
||||
OSGColor.demoBackground.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
keyboardChrome
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
// (backgrounds do not need 120 Hz) — roughly halves GPU vs. uncapped TimelineView.
|
||||
// - `accessibilityReduceMotion` → static frame at phase = 0 (Apple HIG).
|
||||
// - Palette is the same near-black base used across the rest of the app
|
||||
// (`Color(red: 0.06, green: 0.06, blue: 0.07)`) with cool-warm accents that
|
||||
// (`OSGColor.demoBackground`) with cool-warm accents that
|
||||
// sit in the "premium, calm, late-night" register — fits the Typeless vibe.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
@@ -108,43 +109,13 @@ struct AnimatedMeshPalette: Sendable {
|
||||
var colors: [Color]
|
||||
|
||||
/// Deep indigo / amber / teal. Default — works for the dark home screen.
|
||||
static let aurora = AnimatedMeshPalette(colors: [
|
||||
Color(red: 0.06, green: 0.07, blue: 0.13), // top-left
|
||||
Color(red: 0.10, green: 0.08, blue: 0.20), // top-mid
|
||||
Color(red: 0.05, green: 0.10, blue: 0.18), // top-right
|
||||
Color(red: 0.12, green: 0.09, blue: 0.16), // left-mid
|
||||
Color(red: 0.18, green: 0.11, blue: 0.22), // center ← deepest violet
|
||||
Color(red: 0.08, green: 0.12, blue: 0.20), // right-mid
|
||||
Color(red: 0.16, green: 0.10, blue: 0.10), // bottom-left (amber hint)
|
||||
Color(red: 0.10, green: 0.08, blue: 0.18), // bottom-mid
|
||||
Color(red: 0.05, green: 0.09, blue: 0.14) // bottom-right
|
||||
])
|
||||
static let aurora = AnimatedMeshPalette(colors: OSGColor.meshAurora)
|
||||
|
||||
/// Cooler / more "Apple" feel — slate blue, steel, ice.
|
||||
static let polar = AnimatedMeshPalette(colors: [
|
||||
Color(red: 0.05, green: 0.07, blue: 0.10),
|
||||
Color(red: 0.09, green: 0.12, blue: 0.18),
|
||||
Color(red: 0.07, green: 0.09, blue: 0.14),
|
||||
Color(red: 0.10, green: 0.13, blue: 0.18),
|
||||
Color(red: 0.13, green: 0.16, blue: 0.22),
|
||||
Color(red: 0.08, green: 0.10, blue: 0.16),
|
||||
Color(red: 0.06, green: 0.10, blue: 0.16),
|
||||
Color(red: 0.09, green: 0.11, blue: 0.18),
|
||||
Color(red: 0.05, green: 0.07, blue: 0.12)
|
||||
])
|
||||
static let polar = AnimatedMeshPalette(colors: OSGColor.meshPolar)
|
||||
|
||||
/// Warm "candlelight" — fits evening writing session vibes.
|
||||
static let ember = AnimatedMeshPalette(colors: [
|
||||
Color(red: 0.08, green: 0.06, blue: 0.05),
|
||||
Color(red: 0.14, green: 0.09, blue: 0.06),
|
||||
Color(red: 0.10, green: 0.07, blue: 0.06),
|
||||
Color(red: 0.16, green: 0.10, blue: 0.07),
|
||||
Color(red: 0.22, green: 0.13, blue: 0.08),
|
||||
Color(red: 0.12, green: 0.08, blue: 0.07),
|
||||
Color(red: 0.10, green: 0.06, blue: 0.05),
|
||||
Color(red: 0.15, green: 0.09, blue: 0.06),
|
||||
Color(red: 0.08, green: 0.06, blue: 0.05)
|
||||
])
|
||||
static let ember = AnimatedMeshPalette(colors: OSGColor.meshEmber)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -26,5 +26,6 @@ struct PrivacyInfoCard: View {
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.cardElevation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,5 +37,6 @@ struct WideCard<Content: View>: View {
|
||||
content()
|
||||
.padding(padding)
|
||||
.background(palette.surface, in: shape)
|
||||
.cardElevation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ struct EditDemoView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
|
||||
OSGColor.demoBackground.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
Group {
|
||||
|
||||
@@ -75,7 +75,7 @@ struct EnginePickerSection<ConfigurationRows: View>: View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(TypeStyle.body)
|
||||
.font(.system(size: 15, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
|
||||
Text(subtitle)
|
||||
.font(TypeStyle.caption2)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// FlowDiagnosticsSettingsView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Local-only export and deletion controls for Flow startup failure reports.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct FlowDiagnosticsSettingsView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
|
||||
@State private var reportURLs: [URL] = []
|
||||
@State private var exportURL: URL?
|
||||
@State private var showsClearConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
CardSection("settings.diagnostics.section") {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(reportCountText)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(reportStatusText)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.settingsListRow()
|
||||
|
||||
if let exportURL {
|
||||
Divider().background(palette.divider)
|
||||
|
||||
ShareLink(item: exportURL) {
|
||||
actionRow(
|
||||
title: "settings.diagnostics.export",
|
||||
systemImage: "square.and.arrow.up"
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button(role: .destructive) {
|
||||
showsClearConfirmation = true
|
||||
} label: {
|
||||
actionRow(
|
||||
title: "settings.diagnostics.clear",
|
||||
systemImage: "trash"
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
CardSection("settings.diagnostics.privacy.section") {
|
||||
Text("settings.diagnostics.privacy.body")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.surfaceCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.diagnostics.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: refreshReports)
|
||||
.alert("settings.diagnostics.clear.title", isPresented: $showsClearConfirmation) {
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button("settings.diagnostics.clear.confirm", role: .destructive) {
|
||||
FlowFailureLogStore.shared.deleteAllReports()
|
||||
refreshReports()
|
||||
}
|
||||
} message: {
|
||||
Text("settings.diagnostics.clear.message")
|
||||
}
|
||||
}
|
||||
|
||||
private var reportCountText: String {
|
||||
String(
|
||||
format: AppL10n.string(
|
||||
"settings.diagnostics.count",
|
||||
language: config.uiLanguage
|
||||
),
|
||||
reportURLs.count
|
||||
)
|
||||
}
|
||||
|
||||
private var reportStatusText: String {
|
||||
guard let latest = reportURLs.first,
|
||||
let modifiedAt = try? latest.resourceValues(
|
||||
forKeys: [.contentModificationDateKey]
|
||||
).contentModificationDate else {
|
||||
return AppL10n.string(
|
||||
"settings.diagnostics.empty",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
}
|
||||
let format = AppL10n.string(
|
||||
"settings.diagnostics.latest",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
return String(
|
||||
format: format,
|
||||
modifiedAt.formatted(date: .abbreviated, time: .shortened)
|
||||
)
|
||||
}
|
||||
|
||||
private func actionRow(
|
||||
title: LocalizedStringKey,
|
||||
systemImage: String
|
||||
) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: systemImage)
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 22)
|
||||
Text(title)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.sm)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private func refreshReports() {
|
||||
reportURLs = FlowFailureLogStore.shared.reportURLs()
|
||||
exportURL = FlowFailureLogStore.shared.makeExportURL()
|
||||
}
|
||||
}
|
||||
@@ -47,22 +47,23 @@ struct HistoryView: View {
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
.accessibilityLabel("history.clear.button")
|
||||
.confirmationDialog(
|
||||
"history.clear.title",
|
||||
isPresented: $showClearConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("history.clear.confirm", role: .destructive) {
|
||||
store.clearAll()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("history.clear.message")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"history.clear.title",
|
||||
isPresented: $showClearConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("history.clear.confirm", role: .destructive) {
|
||||
store.clearAll()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("history.clear.message")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"history.clearDay.title",
|
||||
isPresented: $showDeleteDayConfirmation,
|
||||
@@ -82,37 +83,37 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
// MARK: - Card list
|
||||
|
||||
private var list: some View {
|
||||
List {
|
||||
ForEach(store.groupedByDay, id: \.day) { group in
|
||||
Section {
|
||||
ForEach(group.items) { entry in
|
||||
historyRow(entry)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.listRowBackground(palette.surface)
|
||||
.listRowSeparatorTint(palette.divider)
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
ForEach(store.groupedByDay, id: \.day) { group in
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
daySectionHeader(day: group.day)
|
||||
|
||||
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
ForEach(group.items) { entry in
|
||||
historyRow(entry)
|
||||
.surfaceCard()
|
||||
.contextMenu {
|
||||
Button("common.delete", role: .destructive) {
|
||||
delete(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
delete(items: group.items, at: offsets)
|
||||
}
|
||||
} header: {
|
||||
daySectionHeader(day: group.day)
|
||||
}
|
||||
.listSectionMargins(.horizontal, Spacing.lg)
|
||||
}
|
||||
.tabBarScrollBottomPadding()
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
|
||||
.scrollContentBackground(.hidden)
|
||||
.scrollClipDisabled()
|
||||
.background(palette.background)
|
||||
.contentMargins(.top, Spacing.md, for: .scrollContent)
|
||||
.tabBarListScrollBottomMargin()
|
||||
}
|
||||
|
||||
/// Date label + per-day delete, flush with the section card's left/right edges
|
||||
/// (Settings section labels share the same edge; system List headers inset further).
|
||||
/// (CardPageContent gives labels and cards the same horizontal guide).
|
||||
private func daySectionHeader(day: Date) -> some View {
|
||||
HStack(alignment: .center, spacing: Spacing.sm) {
|
||||
Text(Self.dayFormatter.string(from: day))
|
||||
@@ -134,9 +135,8 @@ struct HistoryView: View {
|
||||
.accessibilityLabel("history.clearDay.button")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// Cancel the default List section-header content inset so the label
|
||||
// lines up with the card's left edge (rows use leading: 0).
|
||||
.padding(.horizontal, -SettingsListMetrics.rowHorizontalPadding)
|
||||
// CardPageContent owns horizontal padding, so no row-specific
|
||||
// compensation is needed to keep the label flush with the cards.
|
||||
.textCase(nil)
|
||||
}
|
||||
|
||||
@@ -181,9 +181,7 @@ struct HistoryView: View {
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
private func delete(items: [SpeechHistoryEntry], at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
store.delete(id: items[index].id)
|
||||
}
|
||||
private func delete(_ entry: SpeechHistoryEntry) {
|
||||
store.delete(id: entry.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +352,23 @@ struct HomeView: View {
|
||||
compact: Bool = false
|
||||
) -> some View {
|
||||
VStack(spacing: CardLayoutMetrics.sectionSpacing) {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("--home-dictionary-screenshot") {
|
||||
dictionaryCard
|
||||
} else {
|
||||
standardHomeContentSections(layout: layout, compact: compact)
|
||||
}
|
||||
#else
|
||||
standardHomeContentSections(layout: layout, compact: compact)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func standardHomeContentSections(
|
||||
layout: UsageStatsClusterLayout,
|
||||
compact: Bool
|
||||
) -> some View {
|
||||
AccountRewardsCard {
|
||||
path.append(HomeRoute.account)
|
||||
}
|
||||
@@ -375,7 +392,6 @@ struct HomeView: View {
|
||||
)
|
||||
|
||||
dictionaryCard
|
||||
}
|
||||
}
|
||||
|
||||
private var dictionaryCard: some View {
|
||||
@@ -496,14 +512,18 @@ struct HomeView: View {
|
||||
pendingDictionarySuggestion = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(palette.textPrimary)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button("home.card.dictionary.confirm.add") {
|
||||
Button {
|
||||
addSuggestedTerm(suggestion)
|
||||
} label: {
|
||||
Text("home.card.dictionary.confirm.add")
|
||||
.foregroundStyle(palette.background)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(palette.accent)
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
@@ -576,9 +596,11 @@ struct HomeView: View {
|
||||
private var headerGradientColors: [Color] {
|
||||
// 云端引擎未配置(缺 API Key)时不算就绪,保持中性灰渐变。
|
||||
if sessionIsLive, !needsAPIKeySetup {
|
||||
let leadingOpacity = colorScheme == .dark ? 0.18 : 0.28
|
||||
let trailingOpacity = colorScheme == .dark ? 0.06 : 0.10
|
||||
return [
|
||||
palette.accent.opacity(0.28),
|
||||
palette.accent.opacity(0.10),
|
||||
palette.accent.opacity(leadingOpacity),
|
||||
palette.accent.opacity(trailingOpacity),
|
||||
palette.background.opacity(0)
|
||||
]
|
||||
}
|
||||
@@ -601,7 +623,11 @@ struct HomeView: View {
|
||||
.renderingMode(.template)
|
||||
.scaledToFit()
|
||||
.frame(width: logoWidth, height: logoHeight)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
|
||||
.foregroundStyle(
|
||||
colorScheme == .dark
|
||||
? OSGColor.fixedLightContent
|
||||
: OSGColor.fixedDarkContent
|
||||
)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -769,6 +795,7 @@ struct HomeView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.cardElevation()
|
||||
}
|
||||
|
||||
private var flowStatusColor: Color {
|
||||
|
||||
@@ -96,7 +96,8 @@ struct KeyboardPreviewSheet: View {
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
.background(palette.formSurface, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
.cardElevation()
|
||||
}
|
||||
|
||||
private var controls: some View {
|
||||
|
||||
@@ -217,7 +217,7 @@ struct KeyboardPreviewStub: View {
|
||||
case .idle:
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.system(size: 32, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
case .recording:
|
||||
HStack(spacing: 3) {
|
||||
ForEach(0..<12, id: \.self) { i in
|
||||
|
||||
@@ -19,6 +19,7 @@ enum AnalyticsFirstOpenAttribution {
|
||||
|
||||
struct MainAppRoot: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
// Singleton is owned by `ProviderConfig.shared`, not by this view —
|
||||
// `@ObservedObject` keeps subscriptions correct across Settings replay.
|
||||
@@ -64,13 +65,27 @@ struct MainAppRoot: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
ZStack {
|
||||
palette.background
|
||||
.ignoresSafeArea()
|
||||
mainContent
|
||||
}
|
||||
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
||||
.environmentObject(flowManager)
|
||||
.environmentObject(accountSession)
|
||||
.environmentObject(analytics)
|
||||
.alert(
|
||||
"account.error.title",
|
||||
isPresented: accountOperationErrorBinding
|
||||
) {
|
||||
Button("common.done") {
|
||||
accountSession.dismissOperationError()
|
||||
}
|
||||
} message: {
|
||||
if let key = accountSession.operationErrorKey {
|
||||
Text(LocalizedStringKey(key))
|
||||
}
|
||||
}
|
||||
.background {
|
||||
FlowPiPHostView { view in
|
||||
flowManager.attachPiPHostView(view)
|
||||
@@ -209,6 +224,7 @@ struct MainAppRoot: View {
|
||||
// launch. The coordinator no-ops after a terminal restore, so
|
||||
// this only retries a previously interrupted attempt.
|
||||
await accountSession.restoreIfNeeded()
|
||||
await accountSession.validateAppleCredentialState()
|
||||
}
|
||||
if config.hasCompletedOnboarding {
|
||||
activateForegroundServices(reason: "scenePhase.active")
|
||||
@@ -217,13 +233,21 @@ struct MainAppRoot: View {
|
||||
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
||||
}
|
||||
}
|
||||
Task {
|
||||
await accountSession.validateAppleCredentialState()
|
||||
await AppCloudSync.shared.pullAllIfEnabled()
|
||||
}
|
||||
Task { await AppCloudSync.shared.pullAllIfEnabled() }
|
||||
}
|
||||
}
|
||||
|
||||
private var accountOperationErrorBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { accountSession.operationErrorKey != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
accountSession.dismissOperationError()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func refreshOfficialSkillCatalog(reason: String) {
|
||||
Task {
|
||||
let outcome = await OfficialSkillCatalogRefreshService.shared.refreshIfNeeded(
|
||||
|
||||
@@ -67,6 +67,19 @@ private extension ManagedGatewayOOBEFeature {
|
||||
}
|
||||
}
|
||||
|
||||
var editorSystemImage: String {
|
||||
switch self {
|
||||
case .voiceInput:
|
||||
return "message"
|
||||
case .clipboardTranslate:
|
||||
return "translate"
|
||||
case .clipboardReply:
|
||||
return "arrowshape.turn.up.left"
|
||||
case .askAI:
|
||||
return "sparkles.2"
|
||||
}
|
||||
}
|
||||
|
||||
var next: ManagedGatewayOOBEFeature? {
|
||||
switch self {
|
||||
case .voiceInput:
|
||||
@@ -81,9 +94,18 @@ private extension ManagedGatewayOOBEFeature {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private enum OOBEPreviewDestination {
|
||||
case practice(ManagedGatewayOOBEFeature)
|
||||
case loginReward
|
||||
case complete
|
||||
}
|
||||
#endif
|
||||
|
||||
struct OnboardingExperienceView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
@EnvironmentObject private var accountSession: AccountSessionCoordinator
|
||||
|
||||
@@ -104,11 +126,15 @@ struct OnboardingExperienceView: View {
|
||||
@State private var isPreparingManagedPractice = false
|
||||
@State private var managedPracticeReady = false
|
||||
@State private var managedPracticeFailed = false
|
||||
@State private var practiceFeature: ManagedGatewayOOBEFeature = .voiceInput
|
||||
@State private var practiceFeature = Self.initialPracticeFeature
|
||||
@State private var practiceSessionID: UUID?
|
||||
@State private var completedPracticeFeatures: Set<ManagedGatewayOOBEFeature> = []
|
||||
@State private var didCopyPracticeSample = false
|
||||
@State private var didRefreshLoginReward = false
|
||||
@State private var showsKeyboardSwitchIcon = false
|
||||
@State private var showsLoginRewardIcon = false
|
||||
@State private var showsCompleteIcon = false
|
||||
@State private var previewPageIndex = 0
|
||||
@FocusState private var keyboardSwitchFieldFocused: Bool
|
||||
@FocusState private var practiceFieldFocused: Bool
|
||||
|
||||
@@ -117,9 +143,122 @@ struct OnboardingExperienceView: View {
|
||||
"onboarding.experience.oobe.completedFeatures.v1"
|
||||
|
||||
private var currentStep: OnboardingExperienceStep {
|
||||
OnboardingExperienceStep(rawValue: config.onboardingPage) ?? .introduction
|
||||
#if DEBUG
|
||||
switch activePreviewDestination {
|
||||
case .practice:
|
||||
return .practice
|
||||
case .loginReward:
|
||||
return .loginReward
|
||||
case .complete:
|
||||
return .complete
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
#endif
|
||||
return OnboardingExperienceStep(rawValue: config.onboardingPage) ?? .introduction
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private var activePreviewDestination: OOBEPreviewDestination? {
|
||||
guard Self.previewsAllScreensFromArguments else {
|
||||
return Self.previewDestination
|
||||
}
|
||||
return Self.allPreviewDestinations[
|
||||
min(previewPageIndex, Self.allPreviewDestinations.count - 1)
|
||||
]
|
||||
}
|
||||
#endif
|
||||
|
||||
private var isPreviewMode: Bool {
|
||||
#if DEBUG
|
||||
Self.isPreviewEnabled
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private var previewLanguageOverride: AppUILanguage? {
|
||||
#if DEBUG
|
||||
isPreviewMode ? Self.previewLanguage : nil
|
||||
#else
|
||||
nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private var previewsAllScreens: Bool {
|
||||
#if DEBUG
|
||||
Self.previewsAllScreensFromArguments
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private static var initialPracticeFeature: ManagedGatewayOOBEFeature {
|
||||
#if DEBUG
|
||||
if case let .practice(feature) = previewDestination {
|
||||
return feature
|
||||
}
|
||||
#endif
|
||||
return .voiceInput
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static var isPreviewEnabled: Bool {
|
||||
previewDestination != nil
|
||||
}
|
||||
|
||||
static var previewLanguage: AppUILanguage {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
if arguments.contains("--screenshot-lang=en") {
|
||||
return .english
|
||||
}
|
||||
if arguments.contains("--screenshot-lang=zh") {
|
||||
return .chinese
|
||||
}
|
||||
return ProviderConfig.shared.uiLanguage
|
||||
}
|
||||
|
||||
private static var previewsAllScreensFromArguments: Bool {
|
||||
ProcessInfo.processInfo.arguments.contains("--oobe-preview=all")
|
||||
}
|
||||
|
||||
private static let allPreviewDestinations: [OOBEPreviewDestination] = [
|
||||
.practice(.voiceInput),
|
||||
.practice(.clipboardTranslate),
|
||||
.practice(.clipboardReply),
|
||||
.practice(.askAI),
|
||||
.loginReward,
|
||||
.complete
|
||||
]
|
||||
|
||||
private static var previewDestination: OOBEPreviewDestination? {
|
||||
let prefix = "--oobe-preview="
|
||||
guard let argument = ProcessInfo.processInfo.arguments.first(
|
||||
where: { $0.hasPrefix(prefix) }
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
switch String(argument.dropFirst(prefix.count)) {
|
||||
case "all":
|
||||
return .practice(.voiceInput)
|
||||
case "voice":
|
||||
return .practice(.voiceInput)
|
||||
case "translate":
|
||||
return .practice(.clipboardTranslate)
|
||||
case "reply":
|
||||
return .practice(.clipboardReply)
|
||||
case "ask-ai":
|
||||
return .practice(.askAI)
|
||||
case "login":
|
||||
return .loginReward
|
||||
case "complete":
|
||||
return .complete
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
palette.background.ignoresSafeArea()
|
||||
@@ -137,6 +276,7 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
guard !isPreviewMode else { return }
|
||||
migrateLegacyProgressIfNeeded()
|
||||
applyPrivacySafeDefaultsIfNeeded()
|
||||
refreshState()
|
||||
@@ -147,9 +287,11 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
guard !isPreviewMode else { return }
|
||||
endPractice()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard !isPreviewMode else { return }
|
||||
guard phase == .active else { return }
|
||||
refreshState()
|
||||
if currentStep == .keyboard, hasOpenedKeyboardSettings {
|
||||
@@ -164,6 +306,7 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: currentStep) { previous, current in
|
||||
guard !isPreviewMode else { return }
|
||||
if previous == .keyboardSwitch {
|
||||
keyboardSwitchFieldFocused = false
|
||||
}
|
||||
@@ -182,15 +325,18 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: accountSession.isSignedIn) { _, isSignedIn in
|
||||
guard !isPreviewMode else { return }
|
||||
guard isSignedIn, currentStep == .loginReward else { return }
|
||||
didRefreshLoginReward = false
|
||||
refreshLoginRewardIfNeeded()
|
||||
}
|
||||
.onChange(of: config.hasAcknowledgedCloudSharing) { _, acknowledged in
|
||||
guard !isPreviewMode else { return }
|
||||
guard acknowledged, currentStep == .practice else { return }
|
||||
prepareManagedPractice()
|
||||
}
|
||||
.task(id: currentStep) {
|
||||
guard !isPreviewMode else { return }
|
||||
switch currentStep {
|
||||
case .keyboardSwitch:
|
||||
await monitorKeyboardVerification()
|
||||
@@ -248,17 +394,23 @@ struct OnboardingExperienceView: View {
|
||||
|
||||
Image("osglogo")
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: 174, maxHeight: 50)
|
||||
.frame(maxWidth: 122, maxHeight: 35)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text("onboarding.experience.intro.eyebrow")
|
||||
.font(TypeStyle.caption)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(palette.accent)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(height: 28)
|
||||
.background(palette.accentMuted, in: Capsule())
|
||||
.background(palette.surfaceElevated, in: Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
}
|
||||
.padding(.top, Spacing.hero)
|
||||
|
||||
Text("onboarding.experience.intro.title")
|
||||
@@ -275,18 +427,18 @@ struct OnboardingExperienceView: View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
promiseRow(
|
||||
icon: "waveform",
|
||||
title: "onboarding.experience.privacy.local.title",
|
||||
detail: "onboarding.experience.privacy.local.body"
|
||||
title: "onboarding.experience.promise.voice.title",
|
||||
detail: "onboarding.experience.promise.voice.body"
|
||||
)
|
||||
promiseRow(
|
||||
icon: "network",
|
||||
title: "onboarding.experience.privacy.cloud.title",
|
||||
detail: "onboarding.experience.privacy.cloud.body"
|
||||
icon: "sparkles",
|
||||
title: "onboarding.experience.promise.agent.title",
|
||||
detail: "onboarding.experience.promise.agent.body"
|
||||
)
|
||||
promiseRow(
|
||||
icon: "keyboard",
|
||||
title: "onboarding.experience.privacy.anywhere.title",
|
||||
detail: "onboarding.experience.privacy.anywhere.body"
|
||||
icon: "key",
|
||||
title: "onboarding.experience.promise.privacy.title",
|
||||
detail: "onboarding.experience.promise.privacy.body"
|
||||
)
|
||||
}
|
||||
.padding(.top, Spacing.xxl)
|
||||
@@ -316,9 +468,9 @@ struct OnboardingExperienceView: View {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 19, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
.background(palette.surfaceMuted, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
@@ -334,6 +486,7 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.cardElevation()
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
@@ -390,9 +543,9 @@ struct OnboardingExperienceView: View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
.background(palette.surfaceMuted, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
@@ -412,6 +565,7 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.cardElevation()
|
||||
}
|
||||
|
||||
// MARK: - Keyboard setup
|
||||
@@ -465,8 +619,13 @@ struct OnboardingExperienceView: View {
|
||||
) {
|
||||
VStack(spacing: Spacing.md) {
|
||||
Image(systemName: "globe")
|
||||
.font(.system(size: 34, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.font(.system(size: 40, weight: .ultraLight))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolEffect(
|
||||
.drawOn,
|
||||
isActive: !reduceMotion && !showsKeyboardSwitchIcon
|
||||
)
|
||||
.symbolEffectsRemoved(reduceMotion)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text("onboarding.experience.keyboardSwitch.instruction")
|
||||
@@ -481,6 +640,7 @@ struct OnboardingExperienceView: View {
|
||||
palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.cardElevation()
|
||||
.padding(.top, Spacing.xxl)
|
||||
|
||||
TextField(
|
||||
@@ -497,16 +657,9 @@ struct OnboardingExperienceView: View {
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, minHeight: 72, alignment: .topLeading)
|
||||
.background(
|
||||
palette.surface,
|
||||
palette.formSurface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(
|
||||
keyboardReady ? palette.accent : palette.dividerStrong,
|
||||
lineWidth: keyboardReady ? 1.5 : 0.5
|
||||
)
|
||||
)
|
||||
.accessibilityIdentifier("onboarding.keyboardSwitch.textField")
|
||||
.padding(.top, Spacing.lg)
|
||||
|
||||
@@ -537,6 +690,20 @@ struct OnboardingExperienceView: View {
|
||||
.padding(.top, Spacing.sm)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
guard !reduceMotion else { return }
|
||||
showsKeyboardSwitchIcon = false
|
||||
do {
|
||||
// 等待页面首帧完成,再绘制出现输入法切换图标。
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
showsKeyboardSwitchIcon = true
|
||||
} catch {
|
||||
showsKeyboardSwitchIcon = false
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
showsKeyboardSwitchIcon = false
|
||||
}
|
||||
}
|
||||
|
||||
private var keyboardVerificationStatus: LocalizedStringKey {
|
||||
@@ -634,11 +801,13 @@ struct OnboardingExperienceView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var practiceContent: some View {
|
||||
if !config.hasAcknowledgedCloudSharing {
|
||||
if isPreviewMode {
|
||||
practiceFeatureContent
|
||||
} else if !config.hasAcknowledgedCloudSharing {
|
||||
VStack(spacing: Spacing.md) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 34, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
Text("onboarding.experience.practice.cloudBody")
|
||||
.font(TypeStyle.caption)
|
||||
@@ -687,31 +856,38 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
.practiceSetupCard(palette: palette)
|
||||
} else {
|
||||
VStack(spacing: Spacing.md) {
|
||||
if practiceFeature == .voiceInput {
|
||||
practiceVoiceSample
|
||||
} else if practiceFeature == .clipboardTranslate
|
||||
|| practiceFeature == .clipboardReply {
|
||||
practiceClipboardSample
|
||||
}
|
||||
practiceEditor
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if managedPracticeReady {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(palette.accent)
|
||||
}
|
||||
Text("onboarding.experience.practice.waiting")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
practiceFeatureContent
|
||||
}
|
||||
}
|
||||
|
||||
Button("onboarding.experience.practice.skip") {
|
||||
goToLoginReward()
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
private var practiceFeatureContent: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
if practiceFeature == .voiceInput {
|
||||
practiceVoiceSample
|
||||
} else if practiceFeature == .clipboardTranslate
|
||||
|| practiceFeature == .clipboardReply {
|
||||
practiceClipboardSample
|
||||
} else if practiceFeature == .askAI {
|
||||
practiceAskAIPrompt
|
||||
}
|
||||
practiceEditor
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if managedPracticeReady || isPreviewMode {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(palette.accent)
|
||||
}
|
||||
Text("onboarding.experience.practice.waiting")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Button("onboarding.experience.practice.skip") {
|
||||
guard !isPreviewMode else { return }
|
||||
goToLoginReward()
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,8 +904,10 @@ struct OnboardingExperienceView: View {
|
||||
private var practiceEditor: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: practiceFeature == .askAI ? "sparkles" : "message.fill")
|
||||
.foregroundStyle(palette.accent)
|
||||
Image(systemName: practiceFeature.editorSystemImage)
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.accessibilityHidden(true)
|
||||
Text(practiceFeature.progressKey)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
@@ -749,7 +927,8 @@ struct OnboardingExperienceView: View {
|
||||
.accessibilityIdentifier("onboarding.practice.textEditor")
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 172, maxHeight: 240)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.background(palette.formSurface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.cardElevation()
|
||||
}
|
||||
|
||||
private var practiceVoiceSample: some View {
|
||||
@@ -771,7 +950,7 @@ struct OnboardingExperienceView: View {
|
||||
|
||||
private var practiceClipboardSample: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
Text("onboarding.experience.practice.sample")
|
||||
Text(LocalizedStringKey(practiceClipboardSampleLocalizationKey))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -806,9 +985,29 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.cardElevation()
|
||||
}
|
||||
|
||||
private var practiceAskAIPrompt: some View {
|
||||
Text("onboarding.experience.practice.askAIPrompt")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: Radius.large))
|
||||
}
|
||||
|
||||
private var practiceClipboardSampleLocalizationKey: String {
|
||||
practiceFeature == .clipboardTranslate
|
||||
? "onboarding.experience.practice.translateSample"
|
||||
: "onboarding.experience.practice.replySample"
|
||||
}
|
||||
|
||||
private var practiceTitle: LocalizedStringKey {
|
||||
if isPreviewMode {
|
||||
return practiceFeature.titleKey
|
||||
}
|
||||
if !permissionsReady {
|
||||
return "onboarding.experience.practice.permissionsTitle"
|
||||
}
|
||||
@@ -828,6 +1027,9 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
|
||||
private var practiceSubtitle: LocalizedStringKey {
|
||||
if isPreviewMode {
|
||||
return practiceFeature.subtitleKey
|
||||
}
|
||||
if !permissionsReady {
|
||||
return "onboarding.experience.practice.permissionsSubtitle"
|
||||
}
|
||||
@@ -855,9 +1057,14 @@ struct OnboardingExperienceView: View {
|
||||
onBack: goBack
|
||||
) {
|
||||
VStack(spacing: Spacing.lg) {
|
||||
Image(systemName: accountSession.isSignedIn ? "checkmark.seal.fill" : "gift.fill")
|
||||
.font(.system(size: 42, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
Image(systemName: "gift.circle")
|
||||
.font(.system(size: 50, weight: .ultraLight))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolEffect(
|
||||
.drawOn,
|
||||
isActive: !reduceMotion && !showsLoginRewardIcon
|
||||
)
|
||||
.symbolEffectsRemoved(reduceMotion)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(
|
||||
@@ -907,9 +1114,24 @@ struct OnboardingExperienceView: View {
|
||||
palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
)
|
||||
.cardElevation()
|
||||
.padding(.top, Spacing.xxl)
|
||||
.accessibilityIdentifier("onboarding.loginReward.card")
|
||||
}
|
||||
.task {
|
||||
guard !reduceMotion else { return }
|
||||
showsLoginRewardIcon = false
|
||||
do {
|
||||
// 等待页面首帧完成后,仅播放一次礼物图标绘制动画。
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
showsLoginRewardIcon = true
|
||||
} catch {
|
||||
showsLoginRewardIcon = false
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
showsLoginRewardIcon = false
|
||||
}
|
||||
}
|
||||
|
||||
private var loginRewardBalance: Int64? {
|
||||
@@ -926,14 +1148,15 @@ struct OnboardingExperienceView: View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: Spacing.xxl)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(palette.accentMuted)
|
||||
.frame(width: 108, height: 108)
|
||||
Image(systemName: completedAllPracticeFeatures ? "checkmark" : "sparkles")
|
||||
.font(.system(size: 44, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
Image(systemName: "checkmark.circle")
|
||||
.font(.system(size: 72, weight: .ultraLight))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolEffect(
|
||||
.drawOn,
|
||||
isActive: !reduceMotion && !showsCompleteIcon
|
||||
)
|
||||
.symbolEffectsRemoved(reduceMotion)
|
||||
.frame(width: 108, height: 108)
|
||||
|
||||
Text(
|
||||
completedAllPracticeFeatures
|
||||
@@ -970,20 +1193,35 @@ struct OnboardingExperienceView: View {
|
||||
.frame(maxWidth: 560)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.task {
|
||||
guard !reduceMotion else { return }
|
||||
showsCompleteIcon = false
|
||||
do {
|
||||
// 完成页每次进入时只绘制一次主图标。
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
showsCompleteIcon = true
|
||||
} catch {
|
||||
showsCompleteIcon = false
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
showsCompleteIcon = false
|
||||
}
|
||||
}
|
||||
|
||||
private func capability(_ icon: String, _ title: LocalizedStringKey) -> some View {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
Text(title)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.aspectRatio(1.42, contentMode: .fit)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large))
|
||||
.cardElevation()
|
||||
}
|
||||
|
||||
// MARK: - Bottom action
|
||||
@@ -1005,6 +1243,9 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
|
||||
private var primaryActionDisabled: Bool {
|
||||
if previewsAllScreens {
|
||||
return false
|
||||
}
|
||||
if isRequestingPermissions || (currentStep == .keyboardSwitch && !keyboardReady) {
|
||||
return true
|
||||
}
|
||||
@@ -1048,6 +1289,9 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
|
||||
private func performPrimaryAction() {
|
||||
if advancePreviewIfNeeded() {
|
||||
return
|
||||
}
|
||||
switch currentStep {
|
||||
case .introduction:
|
||||
goForward()
|
||||
@@ -1072,6 +1316,26 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func advancePreviewIfNeeded() -> Bool {
|
||||
#if DEBUG
|
||||
guard previewsAllScreens else { return false }
|
||||
guard previewPageIndex < Self.allPreviewDestinations.count - 1 else {
|
||||
return true
|
||||
}
|
||||
withAnimation(Motion.soft) {
|
||||
previewPageIndex += 1
|
||||
if case let .practice(feature) = activePreviewDestination {
|
||||
practiceFeature = feature
|
||||
practiceText = ""
|
||||
didCopyPracticeSample = false
|
||||
}
|
||||
}
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - State and actions
|
||||
|
||||
private var permissionsReady: Bool {
|
||||
@@ -1269,8 +1533,16 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
|
||||
private func copyPracticeSample() {
|
||||
let sample = AppL10n.string(
|
||||
practiceClipboardSampleLocalizationKey,
|
||||
language: previewLanguageOverride
|
||||
)
|
||||
if isPreviewMode {
|
||||
UIPasteboard.general.string = sample
|
||||
didCopyPracticeSample = true
|
||||
return
|
||||
}
|
||||
guard let sessionID = practiceSessionID else { return }
|
||||
let sample = AppL10n.string("onboarding.experience.practice.sample")
|
||||
guard KeyboardSetupBridge.seedOOBEClipboardMaterial(
|
||||
sample,
|
||||
sessionID: sessionID
|
||||
@@ -1451,6 +1723,32 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
struct OOBEPreviewHarness: View {
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@StateObject private var flowManager = FlowSessionManager()
|
||||
@StateObject private var accountSession: AccountSessionCoordinator
|
||||
|
||||
init() {
|
||||
_accountSession = StateObject(
|
||||
wrappedValue: AccountSessionCoordinator(
|
||||
dependencies: LiveAccountDependencyFactory.make()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ThemedRoot {
|
||||
OnboardingExperienceView(config: config)
|
||||
.environmentObject(flowManager)
|
||||
.environmentObject(accountSession)
|
||||
}
|
||||
.environment(\.locale, OnboardingExperienceView.previewLanguage.swiftUILocale)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private extension View {
|
||||
func practiceSetupCard(palette: ThemePalette) -> some View {
|
||||
frame(maxWidth: .infinity, minHeight: 172)
|
||||
@@ -1459,6 +1757,7 @@ private extension View {
|
||||
palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.cardElevation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1556,5 +1855,6 @@ private struct KeyboardSettingsPreview: View {
|
||||
.padding(Spacing.md)
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.cardElevation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,14 @@ struct PersonalDictionaryEntrySheet: View {
|
||||
TextField("settings.personalDictionary.add.field", text: $term)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.tint(palette.accent)
|
||||
.tint(palette.textPrimary)
|
||||
.focused($termFocused)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.settingsListRow()
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(termFocused ? palette.accent : palette.divider, lineWidth: termFocused ? 1 : 0.5)
|
||||
.background(
|
||||
palette.formSurface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
|
||||
Text("settings.personalDictionary.add.footer")
|
||||
@@ -56,11 +55,12 @@ struct PersonalDictionaryEntrySheet: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("common.cancel") { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.save") { save() }
|
||||
.disabled(trimmedTerm.isEmpty)
|
||||
.tint(palette.accent)
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
|
||||
@@ -45,6 +45,7 @@ struct PersonalDictionaryView: View {
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
.accessibilityLabel(AppL10n.string("settings.personalDictionary.clearAll"))
|
||||
.confirmationDialog(
|
||||
AppL10n.string("settings.personalDictionary.clearAll.confirmTitle"),
|
||||
@@ -67,6 +68,7 @@ struct PersonalDictionaryView: View {
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
.accessibilityLabel(AppL10n.string("settings.personalDictionary.add.title"))
|
||||
}
|
||||
}
|
||||
@@ -94,36 +96,40 @@ struct PersonalDictionaryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
// MARK: - Card list
|
||||
|
||||
private var list: some View {
|
||||
List {
|
||||
ForEach(filteredSections, id: \.0) { _, items in
|
||||
Section {
|
||||
ForEach(items) { entry in
|
||||
entryRow(entry)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.listRowBackground(palette.surface)
|
||||
.listRowSeparatorTint(palette.divider)
|
||||
}
|
||||
.onDelete { offsets in
|
||||
delete(items: items, at: offsets)
|
||||
ScrollView {
|
||||
CardPageContent(topPadding: Spacing.lg) {
|
||||
ForEach(filteredSections, id: \.0) { category, items in
|
||||
CardSection(
|
||||
title: SharedL10n.string(category.labelKey, language: config.uiLanguage)
|
||||
) {
|
||||
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
ForEach(items) { entry in
|
||||
entryRow(entry)
|
||||
.surfaceCard()
|
||||
.contextMenu {
|
||||
Button("common.delete", role: .destructive) {
|
||||
delete(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.tabBarScrollBottomPadding()
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
|
||||
.scrollContentBackground(.hidden)
|
||||
.scrollClipDisabled()
|
||||
.background(palette.background)
|
||||
// 让搜索栏与首个词条之间留出呼吸空间,视觉更透气。
|
||||
.contentMargins(.top, Spacing.lg, for: .scrollContent)
|
||||
// CardPageContent keeps the search results and their section labels
|
||||
// on the same horizontal guide while preserving top breathing room.
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "settings.personalDictionary.search.prompt"
|
||||
)
|
||||
.tabBarListScrollBottomMargin()
|
||||
}
|
||||
|
||||
private func entryRow(_ entry: PersonalDictionary.Entry) -> some View {
|
||||
@@ -254,12 +260,6 @@ struct PersonalDictionaryView: View {
|
||||
persist()
|
||||
}
|
||||
|
||||
private func delete(items: [PersonalDictionary.Entry], at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
delete(items[index])
|
||||
}
|
||||
}
|
||||
|
||||
private func clearAll() {
|
||||
dictionary.recordClearAll()
|
||||
generatingAliasEntryIDs = []
|
||||
|
||||
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
@MainActor
|
||||
struct PolishStylesView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject private var history = SpeechHistoryStore.shared
|
||||
|
||||
@@ -51,6 +52,7 @@ struct PolishStylesView: View {
|
||||
}
|
||||
.tabBarScrollBottomPadding()
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
.background(palette.background)
|
||||
.navigationTitle("polishStyles.title")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
@@ -61,6 +63,7 @@ struct PolishStylesView: View {
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
.disabled(catalog.entries.count >= PolishStyleLimits.maximumUserPacks)
|
||||
.accessibilityLabel(Text("polishStyles.add"))
|
||||
}
|
||||
@@ -111,6 +114,15 @@ struct PolishStylesView: View {
|
||||
let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks
|
||||
let isActionAvailable = corpus.isReady && !reachedLimit
|
||||
let canGenerate = isActionAvailable && !isGeneratingLearnedStyle
|
||||
let completedCharacterCount = min(corpus.effectiveCharacterCount, required)
|
||||
let learnedFraction = required > 0
|
||||
? Double(completedCharacterCount) / Double(required)
|
||||
: 0
|
||||
let progressDescription = AppL10n.format(
|
||||
"polishStyles.learn.progress",
|
||||
Int64(corpus.effectiveCharacterCount),
|
||||
Int64(required)
|
||||
)
|
||||
|
||||
return VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
@@ -134,20 +146,19 @@ struct PolishStylesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
ProgressView(
|
||||
value: Double(min(corpus.effectiveCharacterCount, required)),
|
||||
total: Double(required)
|
||||
ProgressTrack(
|
||||
segments: [
|
||||
ProgressTrackSegment(
|
||||
fraction: learnedFraction,
|
||||
color: palette.accent
|
||||
)
|
||||
]
|
||||
)
|
||||
.tint(palette.accent)
|
||||
.accessibilityLabel(Text("polishStyles.learn.title"))
|
||||
.accessibilityValue(Text(progressDescription))
|
||||
|
||||
HStack {
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"polishStyles.learn.progress",
|
||||
Int64(corpus.effectiveCharacterCount),
|
||||
Int64(required)
|
||||
)
|
||||
)
|
||||
Text(progressDescription)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
|
||||
@@ -224,6 +235,19 @@ struct PolishStylesView: View {
|
||||
|
||||
private func packCard(_ pack: PolishStylePack) -> some View {
|
||||
let isSelected = pack.id == activeID
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
let selectedFillColors = colorScheme == .dark
|
||||
? [
|
||||
OSGColor.selectedCardFillLeadingDark,
|
||||
OSGColor.selectedCardFillTrailingDark
|
||||
]
|
||||
: [
|
||||
OSGColor.selectedCardFillLeadingLight,
|
||||
OSGColor.selectedCardFillTrailingLight
|
||||
]
|
||||
let selectedStroke = colorScheme == .dark
|
||||
? OSGColor.selectedCardStrokeDark
|
||||
: OSGColor.selectedCardStrokeLight
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
Button {
|
||||
activate(pack)
|
||||
@@ -263,11 +287,25 @@ struct PolishStylesView: View {
|
||||
CatalogCardChrome.checkIcon(palette: palette)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
isSelected ? palette.accentMuted : palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.background {
|
||||
shape
|
||||
.fill(palette.surface)
|
||||
.overlay {
|
||||
if isSelected {
|
||||
shape
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: selectedFillColors,
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay(shape.stroke(selectedStroke, lineWidth: 0.75))
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(shape)
|
||||
.cardElevation(accented: isSelected)
|
||||
.contextMenu {
|
||||
Button("polishStyles.duplicate") {
|
||||
duplicate(pack)
|
||||
@@ -423,7 +461,7 @@ private struct PolishStylePromptDetailSheet: View {
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.surfaceCard()
|
||||
.surfaceCard(elevated: false)
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
@@ -432,6 +470,7 @@ private struct PolishStylePromptDetailSheet: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.done") { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,9 +506,13 @@ private struct PolishStyleEditorSheet: View {
|
||||
Form {
|
||||
Section("polishStyles.editor.name") {
|
||||
TextField("polishStyles.editor.namePlaceholder", text: $name)
|
||||
.settingsListRow()
|
||||
.cardListRow(elevated: false)
|
||||
}
|
||||
Section {
|
||||
Toggle("polishStyles.editor.allowsAddedEmoji", isOn: $allowsAddedEmoji)
|
||||
.settingsListRow()
|
||||
.cardListRow(elevated: false)
|
||||
} footer: {
|
||||
Text("polishStyles.editor.allowsAddedEmoji.hint")
|
||||
}
|
||||
@@ -477,6 +520,8 @@ private struct PolishStyleEditorSheet: View {
|
||||
TextEditor(text: $prompt)
|
||||
.font(.body.monospaced())
|
||||
.frame(minHeight: 320)
|
||||
.padding(Spacing.md)
|
||||
.cardListRow(elevated: false)
|
||||
.onChange(of: prompt) { _, newValue in
|
||||
// Paste-only custom prompts that declare emoji opt-in
|
||||
// should flip the toggle so post-processing keeps them.
|
||||
@@ -505,6 +550,7 @@ private struct PolishStyleEditorSheet: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("common.cancel") { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.save") {
|
||||
@@ -525,6 +571,7 @@ private struct PolishStyleEditorSheet: View {
|
||||
|| prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| prompt.count > PolishStyleLimits.maximumPromptCharacters
|
||||
)
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// ReleaseNotesScreenshotHarness.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Deterministic DEBUG hosts for capturing real production cards used by the
|
||||
// remote What's New page. These hosts seed only simulator-local fixture data.
|
||||
|
||||
#if DEBUG
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct PolishStylesScreenshotHarness: View {
|
||||
private let language: AppUILanguage
|
||||
|
||||
init() {
|
||||
language = ReleaseNotesScreenshotFixture.language
|
||||
ProviderConfig.shared.uiLanguage = language
|
||||
ReleaseNotesScreenshotFixture.seedReadyStyleCorpus(language: language)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ThemedRoot {
|
||||
PolishStylesView()
|
||||
}
|
||||
.environment(\.locale, language.swiftUILocale)
|
||||
.preferredColorScheme(.light)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct HomeDictionaryScreenshotHarness: View {
|
||||
@StateObject private var flowManager = FlowSessionManager()
|
||||
private let language: AppUILanguage
|
||||
|
||||
init() {
|
||||
language = ReleaseNotesScreenshotFixture.language
|
||||
ProviderConfig.shared.uiLanguage = language
|
||||
ReleaseNotesScreenshotFixture.seedFrequentTerms(language: language)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ThemedRoot {
|
||||
HomeView()
|
||||
.environmentObject(flowManager)
|
||||
}
|
||||
.environment(\.locale, language.swiftUILocale)
|
||||
.preferredColorScheme(.light)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum ReleaseNotesScreenshotFixture {
|
||||
static var language: AppUILanguage {
|
||||
ProcessInfo.processInfo.arguments.contains("--screenshot-lang=en")
|
||||
? .english
|
||||
: .chinese
|
||||
}
|
||||
|
||||
static func seedReadyStyleCorpus(language: AppUILanguage) {
|
||||
let history = SpeechHistoryStore.shared
|
||||
history.clearAll()
|
||||
let token = language == .chinese ? "自然表达习惯" : "NaturalVoice"
|
||||
let sample = String(String(repeating: token, count: 500).prefix(2_500))
|
||||
history.append(
|
||||
text: sample,
|
||||
prePolishText: sample,
|
||||
engineMode: "local",
|
||||
source: .dictation
|
||||
)
|
||||
history.reloadFromDisk()
|
||||
}
|
||||
|
||||
static func seedFrequentTerms(language: AppUILanguage) {
|
||||
let store = FrequentTermStore()
|
||||
store.clear()
|
||||
let terms = language == .chinese
|
||||
? ["少数派", "工作流", "语音键盘"]
|
||||
: ["VoiceFlow", "PromptKit", "OSGAgent"]
|
||||
for term in terms {
|
||||
for _ in 0..<4 {
|
||||
store.recordCommittedText(term)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -48,7 +48,7 @@ struct ReleaseNotesSheet: View {
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -70,6 +70,7 @@ struct ReleaseNotesSheet: View {
|
||||
Button(AppL10n.string("common.done", language: language)) {
|
||||
dismiss()
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,10 +93,6 @@ struct SettingsCredentialRow: View {
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 38)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private func iconButton(systemName: String, label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
|
||||
@@ -157,8 +153,8 @@ struct SettingsModelPickerRow: View {
|
||||
.onDisappear { invalidateRequest() }
|
||||
}
|
||||
|
||||
/// Editable model id + trailing menu chevron in one well (same chrome as
|
||||
/// Mac `MacPickerFieldBox`). Chevron is overlaid so it stays inside the border.
|
||||
/// Editable model id + trailing menu chevron in one well. Chevron is
|
||||
/// overlaid so it stays inside the field surface.
|
||||
private var comboField: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
let chevronWidth: CGFloat = 28
|
||||
@@ -173,7 +169,6 @@ struct SettingsModelPickerRow: View {
|
||||
.padding(.trailing, chevronWidth + Spacing.sm)
|
||||
.frame(maxWidth: .infinity, minHeight: controlHeight, alignment: .leading)
|
||||
.background(palette.surfaceElevated, in: shape)
|
||||
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
|
||||
|
||||
Menu {
|
||||
if models.isEmpty {
|
||||
|
||||
@@ -24,6 +24,7 @@ private enum SettingsRoute: Hashable {
|
||||
case general
|
||||
case aiAgent
|
||||
case clipboard
|
||||
case diagnostics
|
||||
case about
|
||||
}
|
||||
|
||||
@@ -144,6 +145,8 @@ struct SettingsView: View {
|
||||
AIAgentSettingsView(config: config)
|
||||
case .clipboard:
|
||||
ClipboardSettingsView(config: config)
|
||||
case .diagnostics:
|
||||
FlowDiagnosticsSettingsView()
|
||||
case .about:
|
||||
AboutSettingsView(config: config)
|
||||
}
|
||||
@@ -314,6 +317,10 @@ struct SettingsView: View {
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
settingsRouteButton(.diagnostics, title: "settings.diagnostics.title")
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
// Opens the remote release-notes sheet (same as post-upgrade prompt).
|
||||
SettingsVersionRow()
|
||||
}
|
||||
@@ -356,9 +363,11 @@ struct SettingsView: View {
|
||||
|
||||
private struct SettingsSheetDismissButton: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
var body: some View {
|
||||
Button("common.done") { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,112 +12,163 @@ struct TypingInputSettingsView: View {
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject private var configuration = TypingInputConfiguration.shared
|
||||
@ObservedObject private var deployment = RimeDeploymentController.shared
|
||||
@State private var showClearHabitsConfirmation = false
|
||||
|
||||
private var isDeploying: Bool { deployment.isDeploying }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Picker(
|
||||
selection: $configuration.schema
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: CardLayoutMetrics.sectionSpacing) {
|
||||
settingsSection(
|
||||
title: AppL10n.string(
|
||||
"settings.typingInput.schema.section",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
) {
|
||||
ForEach(TypingInputSchema.allCases) { schema in
|
||||
Text(AppL10n.string(schema.labelKey, language: config.uiLanguage))
|
||||
.font(TypeStyle.body)
|
||||
.tag(schema)
|
||||
}
|
||||
} label: {
|
||||
Text(AppL10n.string("settings.typingInput.schema.picker", language: config.uiLanguage))
|
||||
.font(TypeStyle.body)
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
} header: {
|
||||
Text(AppL10n.string("settings.typingInput.schema.section", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(PinyinFuzzyPair.allCases) { pair in
|
||||
Toggle(
|
||||
isOn: Binding(
|
||||
get: { configuration.fuzzyPairs.contains(pair) },
|
||||
set: { enabled in
|
||||
configuration.setFuzzyPair(pair, enabled: enabled)
|
||||
deployUpdatedSchemas()
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text(pair.displayName)
|
||||
.font(TypeStyle.body)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(AppL10n.string("settings.typingInput.fuzzy.section", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
} footer: {
|
||||
Text(AppL10n.string("settings.typingInput.fuzzy.footer", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text(AppL10n.string("settings.typingInput.resources.status", language: config.uiLanguage))
|
||||
.font(TypeStyle.body)
|
||||
Spacer()
|
||||
if isDeploying {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text(statusText)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(
|
||||
hasDeploymentError ? palette.danger : palette.textSecondary
|
||||
)
|
||||
}
|
||||
schemaOptionsCard
|
||||
}
|
||||
|
||||
Button(AppL10n.string("settings.typingInput.resources.redeploy", language: config.uiLanguage)) {
|
||||
deployUpdatedSchemas()
|
||||
settingsSection(
|
||||
title: AppL10n.string(
|
||||
"settings.typingInput.fuzzy.section",
|
||||
language: config.uiLanguage
|
||||
),
|
||||
footer: AppL10n.string(
|
||||
"settings.typingInput.fuzzy.footer",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
) {
|
||||
fuzzyPairsCard
|
||||
}
|
||||
.font(TypeStyle.body)
|
||||
.disabled(isDeploying)
|
||||
} header: {
|
||||
Text(AppL10n.string("settings.typingInput.resources.section", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(AppL10n.string("settings.typingInput.habits.clear", language: config.uiLanguage)) {
|
||||
showClearHabitsConfirmation = true
|
||||
settingsSection(
|
||||
title: AppL10n.string(
|
||||
"settings.typingInput.resources.section",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
) {
|
||||
resourcesCard
|
||||
}
|
||||
.font(TypeStyle.body)
|
||||
.disabled(isDeploying)
|
||||
.foregroundStyle(palette.danger)
|
||||
} footer: {
|
||||
Text(AppL10n.string("settings.typingInput.habits.footer", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.top, Spacing.md)
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
|
||||
.scrollContentBackground(.hidden)
|
||||
.scrollClipDisabled()
|
||||
.background(palette.background)
|
||||
.navigationTitle(AppL10n.string("settings.typingInput.title", language: config.uiLanguage))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.confirmationDialog(
|
||||
AppL10n.string("settings.typingInput.habits.clear.title", language: config.uiLanguage),
|
||||
isPresented: $showClearHabitsConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(
|
||||
AppL10n.string("settings.typingInput.habits.clear.confirm", language: config.uiLanguage),
|
||||
role: .destructive
|
||||
) {
|
||||
deployment.clearTypingHabits()
|
||||
}
|
||||
|
||||
private var schemaOptionsCard: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(TypingInputSchema.allCases) { schema in
|
||||
schemaOptionRow(schema)
|
||||
|
||||
if schema != TypingInputSchema.allCases.last {
|
||||
Divider().background(palette.divider)
|
||||
}
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
private func schemaOptionRow(_ schema: TypingInputSchema) -> some View {
|
||||
let isSelected = configuration.schema == schema
|
||||
return Button {
|
||||
guard !isSelected else { return }
|
||||
configuration.schema = schema
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(AppL10n.string(schema.labelKey, language: config.uiLanguage))
|
||||
.font(.system(size: 15, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityValue(isSelected ? "selected" : "notSelected")
|
||||
}
|
||||
|
||||
private var fuzzyPairsCard: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(PinyinFuzzyPair.allCases) { pair in
|
||||
Toggle(
|
||||
isOn: Binding(
|
||||
get: { configuration.fuzzyPairs.contains(pair) },
|
||||
set: { enabled in
|
||||
configuration.setFuzzyPair(pair, enabled: enabled)
|
||||
deployUpdatedSchemas()
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text(pair.displayName)
|
||||
.font(TypeStyle.body)
|
||||
}
|
||||
.settingsListRow()
|
||||
|
||||
if pair != PinyinFuzzyPair.allCases.last {
|
||||
Divider().background(palette.divider)
|
||||
}
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
private var resourcesCard: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(AppL10n.string("settings.typingInput.resources.status", language: config.uiLanguage))
|
||||
.font(TypeStyle.body)
|
||||
Spacer()
|
||||
if isDeploying {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text(statusText)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(
|
||||
hasDeploymentError ? palette.danger : palette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
.settingsListRow()
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button(AppL10n.string("settings.typingInput.resources.redeploy", language: config.uiLanguage)) {
|
||||
deployUpdatedSchemas()
|
||||
}
|
||||
.font(TypeStyle.body)
|
||||
.settingsListRow()
|
||||
.disabled(isDeploying)
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
private func settingsSection<Content: View>(
|
||||
title: String? = nil,
|
||||
footer: String? = nil,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
if let title {
|
||||
Text(title)
|
||||
.cardSectionLabel()
|
||||
}
|
||||
content()
|
||||
if let footer {
|
||||
Text(footer)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Button(AppL10n.string("common.cancel", language: config.uiLanguage), role: .cancel) {}
|
||||
} message: {
|
||||
Text(AppL10n.string("settings.typingInput.habits.clear.message", language: config.uiLanguage))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,14 +38,14 @@
|
||||
"onboarding.polish.localHint" = "Fill in an API key to enable AI polish. Without a key, raw ASR text is inserted.";
|
||||
"onboarding.experience.intro.eyebrow" = "AI AGENT VOICE KEYBOARD";
|
||||
"onboarding.experience.intro.title" = "Speak. It’s written.";
|
||||
"onboarding.experience.intro.subtitle" = "More than dictation. OSGKeyboard understands, writes, translates, and edits—then its AI Agent helps with what comes next.";
|
||||
"onboarding.experience.intro.subtitle" = "The best voice keyboard, with your best personal AI Agent.";
|
||||
"onboarding.experience.intro.action" = "Get Started";
|
||||
"onboarding.experience.privacy.local.title" = "On-device transcription available";
|
||||
"onboarding.experience.privacy.local.body" = "With the local engine, speech recognition stays on your device.";
|
||||
"onboarding.experience.privacy.cloud.title" = "Clear cloud boundaries";
|
||||
"onboarding.experience.privacy.cloud.body" = "Audio or text is sent only when you choose cloud recognition or an AI feature, and only to the configured service.";
|
||||
"onboarding.experience.privacy.anywhere.title" = "Your AI Agent, in any app";
|
||||
"onboarding.experience.privacy.anywhere.body" = "Dictate, translate, edit, and act from chat, email, notes, or code.";
|
||||
"onboarding.experience.promise.voice.title" = "Smart voice keyboard";
|
||||
"onboarding.experience.promise.voice.body" = "Type 6× faster with multiple polishing styles.";
|
||||
"onboarding.experience.promise.agent.title" = "Personal AI Agent";
|
||||
"onboarding.experience.promise.agent.body" = "Translate, summarize, and learn your tone to write smart replies.";
|
||||
"onboarding.experience.promise.privacy.title" = "Privacy protected";
|
||||
"onboarding.experience.promise.privacy.body" = "Bring your own AI API key and stay in control of your privacy.";
|
||||
"onboarding.experience.permissions.title" = "Let us hear you";
|
||||
"onboarding.experience.permissions.subtitle" = "Voice input needs microphone and speech recognition access. Two system prompts will appear next.";
|
||||
"onboarding.experience.permissions.systemHint" = "These permissions are used only when you start voice input.";
|
||||
@@ -68,7 +68,8 @@
|
||||
"onboarding.experience.keyboardSwitch.checkSettings" = "Check Full Access in Settings";
|
||||
"onboarding.experience.keyboardSwitch.waitingAction" = "Waiting for Verification";
|
||||
"onboarding.experience.practice.skip" = "Skip for Now";
|
||||
"onboarding.experience.practice.sample" = "I’ll arrive tomorrow afternoon and message you when I’m there.";
|
||||
"onboarding.experience.practice.translateSample" = "我明天下午到,到了以后给你消息。";
|
||||
"onboarding.experience.practice.replySample" = "I’ll arrive tomorrow afternoon and message you when I’m there.";
|
||||
"onboarding.experience.practice.editorTitle" = "New Message";
|
||||
"onboarding.experience.practice.permissionsTitle" = "Enable voice access";
|
||||
"onboarding.experience.practice.permissionsSubtitle" = "Go back to finish permission setup, then try your first voice input.";
|
||||
@@ -95,17 +96,18 @@
|
||||
"onboarding.experience.practice.readAloud" = "Read aloud";
|
||||
"onboarding.experience.practice.voiceSample" = "Today is Thursday. Where are you planning to go for fun?";
|
||||
"onboarding.experience.practice.translateTitle" = "Try Translate";
|
||||
"onboarding.experience.practice.translateSubtitle" = "Copy the sample below, tap Translate in OSGKeyboard, then insert the result into the field.";
|
||||
"onboarding.experience.practice.translateSubtitle" = "Copy the Chinese sample, translate it into English in OSGKeyboard, then insert the result.";
|
||||
"onboarding.experience.practice.replyTitle" = "Try Reply";
|
||||
"onboarding.experience.practice.replySubtitle" = "Use the same sample, tap Reply in the keyboard, then insert the generated response.";
|
||||
"onboarding.experience.practice.replySubtitle" = "Copy the sample below, tap Reply in the keyboard, then insert the generated response.";
|
||||
"onboarding.experience.practice.askAITitle" = "Ask AI";
|
||||
"onboarding.experience.practice.askAISubtitle" = "Hold the keyboard microphone and ask a question. Tap it again to stop recording and wait for the answer, then insert the result.";
|
||||
"onboarding.experience.practice.askAIPrompt" = "Try asking: “What’s in the news today?”";
|
||||
"onboarding.experience.practice.copyAction" = "Copy Sample";
|
||||
"onboarding.experience.practice.copied" = "Copied. Switch to OSGKeyboard.";
|
||||
"onboarding.experience.practice.waiting" = "Waiting for you to complete this step in the keyboard…";
|
||||
"onboarding.experience.login.title" = "Sign in for 1,000 credits";
|
||||
"onboarding.experience.login.subtitle" = "You’ve completed all four lessons. Sign in to keep using OSG managed AI without entering an API key.";
|
||||
"onboarding.experience.login.body" = "If this device is eligible for the first-time signup reward, 1,000 credits are added automatically. Device integrity checks prevent duplicate claims.";
|
||||
"onboarding.experience.login.subtitle" = "Sign in for free credits and start using AI without an API key.";
|
||||
"onboarding.experience.login.body" = "Register for the first time to get free credits, then invite friends to earn more.";
|
||||
"onboarding.experience.login.skip" = "Not Now";
|
||||
"onboarding.experience.login.signedIn" = "Signed in. Your credits are up to date.";
|
||||
"onboarding.experience.login.balance %lld" = "Current balance: %lld credits";
|
||||
@@ -279,6 +281,18 @@
|
||||
"settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step.";
|
||||
"settings.about.title" = "About";
|
||||
"settings.version.title" = "Version";
|
||||
"settings.diagnostics.title" = "Startup Diagnostics";
|
||||
"settings.diagnostics.section" = "Saved Reports";
|
||||
"settings.diagnostics.count" = "%d startup failure reports";
|
||||
"settings.diagnostics.empty" = "No startup failures have been saved.";
|
||||
"settings.diagnostics.latest" = "Latest: %@";
|
||||
"settings.diagnostics.export" = "Export Diagnostic Logs";
|
||||
"settings.diagnostics.clear" = "Clear Diagnostic Logs";
|
||||
"settings.diagnostics.clear.title" = "Clear diagnostic logs?";
|
||||
"settings.diagnostics.clear.message" = "This permanently removes all saved Flow startup failure reports from this device.";
|
||||
"settings.diagnostics.clear.confirm" = "Clear Logs";
|
||||
"settings.diagnostics.privacy.section" = "Privacy";
|
||||
"settings.diagnostics.privacy.body" = "Reports stay on this device until you export or clear them. They include startup state, timings, error codes, and memory usage, but never audio, transcripts, clipboard content, prompts, API keys, or account identifiers.";
|
||||
"releaseNotes.title" = "What's New";
|
||||
"releaseNotes.openHint" = "View release notes for this version";
|
||||
"releaseNotes.unavailable.title" = "Cannot load page";
|
||||
@@ -833,11 +847,12 @@
|
||||
"account.signedOut.body" = "Register and invite friends to earn credits.";
|
||||
"account.signedOut.localUnaffected" = "On-device features and your own API keys work without an account.";
|
||||
"home.accountRewards.signedOut.title" = "Sign in for free credits";
|
||||
"home.accountRewards.signedOut.summary" = "Eligible new users receive 1,000 credits; after an invited friend completes qualifying usage, you both receive 1,000 credits.";
|
||||
"home.accountRewards.signedOut.summary" = "Register and invite friends to earn credits.";
|
||||
"account.signIn.apple" = "Sign in with Apple";
|
||||
"account.signIn.hint" = "Creates or opens your optional OSG account.";
|
||||
"account.signIn.loading" = "Signing in…";
|
||||
"account.error.sessionExpired" = "Your session expired. Sign in again to continue.";
|
||||
"account.error.appleReauthenticationRequired" = "Sign in with Apple again to keep your account protected.";
|
||||
"account.summary.section" = "Account";
|
||||
"account.summary.signedIn" = "Signed in";
|
||||
"account.summary.protected" = "Protected account";
|
||||
@@ -905,7 +920,7 @@
|
||||
"account.referral.friendRewardQualification" = "Your friend also receives %@ credits after qualifying usage";
|
||||
"account.referral.shareShort" = "Share";
|
||||
"account.referral.inviteTitle" = "Invite";
|
||||
"account.referral.equalRewardDescription" = "For each successful invitation, you and your friend each receive 1,000 credits.";
|
||||
"account.referral.equalRewardDescription" = "Invite friends—both receive 1,000 credits";
|
||||
"account.referral.equalRewardGeneric" = "For each successful invitation, both of you receive credits.";
|
||||
"account.referral.summaryCompact" = "Rewarded %d · Pending %d";
|
||||
"account.referral.preparingLink" = "Loading your invitation link…";
|
||||
|
||||
@@ -38,14 +38,14 @@
|
||||
"onboarding.polish.localHint" = "填写 API Key 才能开启 AI 润色;未填写时将直接插入原始识别结果。";
|
||||
"onboarding.experience.intro.eyebrow" = "AI AGENT 语音输入法";
|
||||
"onboarding.experience.intro.title" = "开口,即成文";
|
||||
"onboarding.experience.intro.subtitle" = "不只是语音输入。OSGKeyboard 能听懂、成文、翻译和编辑,也能调用 AI Agent 帮你处理下一步。";
|
||||
"onboarding.experience.intro.subtitle" = "最好的语音输入法,最好的贴身 AI Agent";
|
||||
"onboarding.experience.intro.action" = "开始设置";
|
||||
"onboarding.experience.privacy.local.title" = "支持设备端转写";
|
||||
"onboarding.experience.privacy.local.body" = "使用本地引擎时,语音识别在你的设备上完成。";
|
||||
"onboarding.experience.privacy.cloud.title" = "明确的云端边界";
|
||||
"onboarding.experience.privacy.cloud.body" = "只有选择云端识别或 AI 功能时,相应的语音或文字才会发送给已配置的服务。";
|
||||
"onboarding.experience.privacy.anywhere.title" = "AI Agent,随处可用";
|
||||
"onboarding.experience.privacy.anywhere.body" = "在聊天、邮件、笔记或代码编辑中,随时口述、翻译、编辑和处理任务。";
|
||||
"onboarding.experience.promise.voice.title" = "智能语音输入法";
|
||||
"onboarding.experience.promise.voice.body" = "6 倍输入速度,各种润色方式";
|
||||
"onboarding.experience.promise.agent.title" = "随身 AI Agent";
|
||||
"onboarding.experience.promise.agent.body" = "翻译、总结,还能学习你的语气,帮你智能回复";
|
||||
"onboarding.experience.promise.privacy.title" = "隐私有保障";
|
||||
"onboarding.experience.promise.privacy.body" = "支持自带 AI API Key,隐私尽在掌握";
|
||||
"onboarding.experience.permissions.title" = "让我们听见你";
|
||||
"onboarding.experience.permissions.subtitle" = "语音输入需要麦克风和语音识别。接下来会依次出现两个系统提示。";
|
||||
"onboarding.experience.permissions.systemHint" = "权限只在你主动使用语音输入时生效。";
|
||||
@@ -68,7 +68,8 @@
|
||||
"onboarding.experience.keyboardSwitch.checkSettings" = "返回设置检查完整访问";
|
||||
"onboarding.experience.keyboardSwitch.waitingAction" = "等待验证";
|
||||
"onboarding.experience.practice.skip" = "暂时跳过体验";
|
||||
"onboarding.experience.practice.sample" = "我明天下午到,到了以后给你消息。";
|
||||
"onboarding.experience.practice.translateSample" = "I’ll arrive tomorrow afternoon and message you when I’m there.";
|
||||
"onboarding.experience.practice.replySample" = "我明天下午到,到了以后给你消息。";
|
||||
"onboarding.experience.practice.editorTitle" = "新消息";
|
||||
"onboarding.experience.practice.permissionsTitle" = "开启语音权限";
|
||||
"onboarding.experience.practice.permissionsSubtitle" = "返回上一步完成权限设置,然后开始第一次语音输入。";
|
||||
@@ -95,17 +96,18 @@
|
||||
"onboarding.experience.practice.readAloud" = "请照着念";
|
||||
"onboarding.experience.practice.voiceSample" = "今天是礼拜四,你打算去哪里玩?";
|
||||
"onboarding.experience.practice.translateTitle" = "试试翻译";
|
||||
"onboarding.experience.practice.translateSubtitle" = "先复制下面的示例,再在 OSGKeyboard 中点击「翻译」。确认结果后插入文本框。";
|
||||
"onboarding.experience.practice.translateSubtitle" = "复制下方英文,在 OSGKeyboard 中将它翻译成中文,再把结果插入文本框。";
|
||||
"onboarding.experience.practice.replyTitle" = "试试回复";
|
||||
"onboarding.experience.practice.replySubtitle" = "使用同一段示例文字,点击键盘中的「回复」,然后插入生成的回复。";
|
||||
"onboarding.experience.practice.replySubtitle" = "复制下方示例文字,点击键盘中的「回复」,再插入生成的回复。";
|
||||
"onboarding.experience.practice.askAITitle" = "问问 AI";
|
||||
"onboarding.experience.practice.askAISubtitle" = "长按键盘麦克风,说出一个问题;再次点击结束录音并等待回答,然后将结果插入文本框。";
|
||||
"onboarding.experience.practice.askAIPrompt" = "试着问问:“今天有什么新闻?”";
|
||||
"onboarding.experience.practice.copyAction" = "复制示例文字";
|
||||
"onboarding.experience.practice.copied" = "已复制,请切换到 OSGKeyboard";
|
||||
"onboarding.experience.practice.waiting" = "等待你在键盘中完成这一步…";
|
||||
"onboarding.experience.login.title" = "登录领 1000 积分";
|
||||
"onboarding.experience.login.subtitle" = "四项体验已经完成。登录后可继续使用 OSG 托管 AI,无需填写 API Key。";
|
||||
"onboarding.experience.login.body" = "符合首次注册资格时,1000 积分会自动到账。设备完整性检查用于防止重复领取。";
|
||||
"onboarding.experience.login.subtitle" = "登录获赠积分,无需 API Key 即可开始免费使用";
|
||||
"onboarding.experience.login.body" = "首次注册,将免费获取积分,邀请好友,还可以获赠更多";
|
||||
"onboarding.experience.login.skip" = "暂不登录";
|
||||
"onboarding.experience.login.signedIn" = "登录成功,积分已刷新";
|
||||
"onboarding.experience.login.balance %lld" = "当前余额:%lld 积分";
|
||||
@@ -279,6 +281,18 @@
|
||||
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
|
||||
"settings.about.title" = "关于";
|
||||
"settings.version.title" = "版本";
|
||||
"settings.diagnostics.title" = "启动诊断";
|
||||
"settings.diagnostics.section" = "已保存报告";
|
||||
"settings.diagnostics.count" = "%d 份启动失败报告";
|
||||
"settings.diagnostics.empty" = "尚未保存启动失败报告。";
|
||||
"settings.diagnostics.latest" = "最近一次:%@";
|
||||
"settings.diagnostics.export" = "导出诊断日志";
|
||||
"settings.diagnostics.clear" = "清除诊断日志";
|
||||
"settings.diagnostics.clear.title" = "清除诊断日志?";
|
||||
"settings.diagnostics.clear.message" = "这将从本机永久删除所有 Flow 会话启动失败报告。";
|
||||
"settings.diagnostics.clear.confirm" = "清除日志";
|
||||
"settings.diagnostics.privacy.section" = "隐私";
|
||||
"settings.diagnostics.privacy.body" = "报告会保留在本机,直到你主动导出或清除。报告仅包含启动状态、耗时、错误码和内存占用,不会保存录音、转写、剪贴板内容、提示词、API Key 或账号标识。";
|
||||
"releaseNotes.title" = "更新说明";
|
||||
"releaseNotes.openHint" = "查看本版本更新说明";
|
||||
"releaseNotes.unavailable.title" = "无法加载页面";
|
||||
@@ -832,11 +846,12 @@
|
||||
"account.signedOut.body" = "注册和邀请好友,都可获赠积分";
|
||||
"account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。";
|
||||
"home.accountRewards.signedOut.title" = "登录领免费积分";
|
||||
"home.accountRewards.signedOut.summary" = "符合资格的新用户可领 1,000 积分;邀请好友完成首次有效使用,双方各得 1,000 积分。";
|
||||
"home.accountRewards.signedOut.summary" = "注册和邀请好友,都可获赠积分";
|
||||
"account.signIn.apple" = "通过 Apple 登录";
|
||||
"account.signIn.hint" = "创建或打开可选的 OSG 账号。";
|
||||
"account.signIn.loading" = "正在登录…";
|
||||
"account.error.sessionExpired" = "登录状态已过期,请重新登录后继续。";
|
||||
"account.error.appleReauthenticationRequired" = "为继续保护账号,请重新通过 Apple 登录。";
|
||||
"account.summary.section" = "账号";
|
||||
"account.summary.signedIn" = "已登录";
|
||||
"account.summary.protected" = "账号已安全保护";
|
||||
@@ -904,7 +919,7 @@
|
||||
"account.referral.friendRewardQualification" = "好友也可获得 %@ 积分,完成首次有效使用后发放";
|
||||
"account.referral.shareShort" = "分享";
|
||||
"account.referral.inviteTitle" = "邀请";
|
||||
"account.referral.equalRewardDescription" = "每成功邀请一位好友,你和好友各获得 1000 积分。";
|
||||
"account.referral.equalRewardDescription" = "邀请好友,共同获得1000积分";
|
||||
"account.referral.equalRewardGeneric" = "每成功邀请一位好友,双方均可获得积分。";
|
||||
"account.referral.summaryCompact" = "已奖励 %d · 待确认 %d";
|
||||
"account.referral.preparingLink" = "正在加载邀请链接…";
|
||||
|
||||
Reference in New Issue
Block a user