checkpoint before checking out main
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
"image-name" : "OSGKeyboard Icon2.png",
|
||||
"name" : "OSGKeyboard Icon2",
|
||||
"position" : {
|
||||
"scale" : 0.505,
|
||||
"scale" : 0.508,
|
||||
"translation-in-points" : [
|
||||
8,
|
||||
6
|
||||
|
||||
@@ -9,6 +9,10 @@ import Foundation
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
private enum SkillRoute: Hashable {
|
||||
case detail(String)
|
||||
}
|
||||
|
||||
struct AIAgentSkillsView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@@ -20,20 +24,21 @@ struct AIAgentSkillsView: View {
|
||||
@State private var pasteAccessNeedsRecovery = false
|
||||
@State private var showPasteNoTextAlert = false
|
||||
@State private var showPasteAccessSuccess = false
|
||||
@State private var path = NavigationPath()
|
||||
private var skillCardShape: RoundedRectangle {
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
NavigationStack(path: $path) {
|
||||
ScrollView {
|
||||
CardPageContent(spacing: Spacing.xl) {
|
||||
CardPageContent {
|
||||
if showsClipboardAccessGuide {
|
||||
clipboardAccessGuide
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
installedSection
|
||||
if !store.availableSkills.isEmpty {
|
||||
if !store.skillManagementAvailableSkills.isEmpty {
|
||||
uninstalledSection
|
||||
}
|
||||
}
|
||||
@@ -42,6 +47,19 @@ struct AIAgentSkillsView: View {
|
||||
.background(palette.background)
|
||||
.navigationTitle("skills.title")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationDestination(for: SkillRoute.self) { route in
|
||||
switch route {
|
||||
case .detail(let skillID):
|
||||
if let skill = store.mergedCatalog.first(where: { $0.id == skillID }) {
|
||||
skillDetailView(skill)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"skills.installed.empty",
|
||||
systemImage: "wand.and.sparkles"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
@@ -53,6 +71,7 @@ struct AIAgentSkillsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationStackTabBarVisibility(isRoot: path.isEmpty)
|
||||
.sheet(item: $editingDraft) { draft in
|
||||
SkillEditorSheet(
|
||||
draft: draft,
|
||||
@@ -94,11 +113,7 @@ struct AIAgentSkillsView: View {
|
||||
private var clipboardAccessGuide: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(alignment: .top, spacing: Spacing.sm) {
|
||||
Image(systemName: clipboardGuideIcon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(clipboardGuideTint)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(clipboardGuideTint.opacity(0.12), in: Circle())
|
||||
skillIcon(systemImage: clipboardGuideIcon)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(clipboardGuideTitle)
|
||||
@@ -126,7 +141,6 @@ struct AIAgentSkillsView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
.accessibilityIdentifier("skills.clipboard.guide")
|
||||
}
|
||||
|
||||
@@ -138,7 +152,8 @@ struct AIAgentSkillsView: View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.frame(width: 22)
|
||||
Text(titleKey)
|
||||
.font(TypeStyle.body)
|
||||
@@ -194,10 +209,6 @@ struct AIAgentSkillsView: View {
|
||||
return pasteAccessNeedsRecovery ? "exclamationmark" : "clipboard"
|
||||
}
|
||||
|
||||
private var clipboardGuideTint: Color {
|
||||
pasteAccessNeedsRecovery ? palette.warning : palette.accent
|
||||
}
|
||||
|
||||
private var clipboardGuideActionTitle: LocalizedStringKey {
|
||||
if !config.clipboardHistoryEnabled {
|
||||
return "skills.clipboard.guide.enableHistory"
|
||||
@@ -288,53 +299,36 @@ struct AIAgentSkillsView: View {
|
||||
title: AppL10n.format(
|
||||
"skills.installed.section",
|
||||
language: config.uiLanguage,
|
||||
store.enabledSkills.count
|
||||
store.skillManagementEnabledSkills.count
|
||||
)
|
||||
) {
|
||||
if store.enabledSkills.isEmpty {
|
||||
if store.skillManagementEnabledSkills.isEmpty {
|
||||
Text("skills.installed.empty")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(store.enabledSkills) { skill in
|
||||
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
ForEach(store.skillManagementEnabledSkills) { skill in
|
||||
skillListItem(skill)
|
||||
}
|
||||
}
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
.clipShape(skillCardShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var uninstalledSection: some View {
|
||||
CardSection("skills.uninstalled.section") {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(store.availableSkills) { skill in
|
||||
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
ForEach(store.skillManagementAvailableSkills) { skill in
|
||||
skillListItem(skill)
|
||||
}
|
||||
}
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
.clipShape(skillCardShape)
|
||||
}
|
||||
}
|
||||
|
||||
private func skillListItem(_ skill: AIClipboardSkill) -> some View {
|
||||
NavigationLink {
|
||||
SkillDetailView(
|
||||
store: store,
|
||||
skill: skill,
|
||||
onInstall: addOrWarn,
|
||||
onConfirmInstall: confirmShortcutInstall,
|
||||
onEdit: {
|
||||
guard let user = store.userSkill(id: skill.id) else { return }
|
||||
editingDraft = .from(user)
|
||||
}
|
||||
)
|
||||
} label: {
|
||||
NavigationLink(value: SkillRoute.detail(skill.id)) {
|
||||
skillListRow(skill)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -349,14 +343,7 @@ struct AIAgentSkillsView: View {
|
||||
|
||||
private func skillListRow(_ skill: AIClipboardSkill) -> some View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Image(systemName: skill.systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(palette.accentMuted, in: RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
))
|
||||
skillIcon(systemImage: skill.systemImage)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(skillTitle(skill))
|
||||
@@ -378,16 +365,36 @@ struct AIAgentSkillsView: View {
|
||||
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.background(palette.surface)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, 38 + Spacing.md * 2)
|
||||
}
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.clipShape(skillCardShape)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private func skillIcon(systemImage: String) -> some View {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
}
|
||||
|
||||
private func skillDetailView(_ skill: AIClipboardSkill) -> some View {
|
||||
SkillDetailView(
|
||||
store: store,
|
||||
skill: skill,
|
||||
onInstall: addOrWarn,
|
||||
onConfirmInstall: confirmShortcutInstall,
|
||||
onEdit: {
|
||||
guard let user = store.userSkill(id: skill.id) else { return }
|
||||
editingDraft = .from(user)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func skillTitle(_ skill: AIClipboardSkill) -> String {
|
||||
if let name = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty {
|
||||
@@ -505,7 +512,6 @@ private struct SkillDetailView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent(
|
||||
spacing: Spacing.md,
|
||||
topPadding: Spacing.md,
|
||||
bottomPadding: Spacing.xl
|
||||
) {
|
||||
@@ -555,9 +561,12 @@ private struct SkillDetailView: View {
|
||||
HStack(alignment: .center, spacing: Spacing.md) {
|
||||
Image(systemName: skill.systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(palette.accentMuted, in: Circle())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(headerTitle)
|
||||
.font(TypeStyle.title3)
|
||||
@@ -660,6 +669,7 @@ private struct SkillDetailView: View {
|
||||
prominent: Bool,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
Button(action: action) {
|
||||
Text(titleKey)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
@@ -667,11 +677,7 @@ private struct SkillDetailView: View {
|
||||
.foregroundStyle(prominent ? palette.textOnAccent : palette.textPrimary)
|
||||
.background(
|
||||
prominent ? palette.accent : palette.surfaceElevated,
|
||||
in: Capsule()
|
||||
)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(prominent ? Color.clear : palette.dividerStrong, lineWidth: 0.5)
|
||||
in: shape
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -25,25 +25,56 @@ struct AccountAppleAuthorizationButton: View {
|
||||
@State private var rawNonce: String?
|
||||
|
||||
var body: some View {
|
||||
SignInWithAppleButton(
|
||||
purpose == .signIn ? .signIn : .continue,
|
||||
onRequest: prepareRequest,
|
||||
onCompletion: completeAuthorization
|
||||
)
|
||||
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.accessibilityLabel(
|
||||
purpose == .signIn
|
||||
? Text("account.signIn.apple")
|
||||
: Text("account.delete.reauthenticate")
|
||||
)
|
||||
.accessibilityHint(
|
||||
purpose == .signIn
|
||||
? Text("account.signIn.hint")
|
||||
: Text("account.delete.reauthenticateHint")
|
||||
)
|
||||
Group {
|
||||
if purpose == .signIn, coordinator.operation == .signingIn {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(authorizationButtonForeground)
|
||||
Text("account.signIn.loading")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
}
|
||||
.foregroundStyle(authorizationButtonForeground)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(authorizationButtonBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("account.signIn.loading")
|
||||
} else {
|
||||
SignInWithAppleButton(
|
||||
purpose == .signIn ? .signIn : .continue,
|
||||
onRequest: prepareRequest,
|
||||
onCompletion: completeAuthorization
|
||||
)
|
||||
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.accessibilityLabel(
|
||||
purpose == .signIn
|
||||
? Text("account.signIn.apple")
|
||||
: Text("account.delete.reauthenticate")
|
||||
)
|
||||
.accessibilityHint(
|
||||
purpose == .signIn
|
||||
? Text("account.signIn.hint")
|
||||
: Text("account.delete.reauthenticateHint")
|
||||
)
|
||||
.accessibilityIdentifier(
|
||||
purpose == .signIn
|
||||
? "account.signIn.apple"
|
||||
: "account.delete.reauthenticate"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var authorizationButtonBackground: Color {
|
||||
colorScheme == .dark ? .white : .black
|
||||
}
|
||||
|
||||
private var authorizationButtonForeground: Color {
|
||||
colorScheme == .dark ? .black : .white
|
||||
}
|
||||
|
||||
private func prepareRequest(_ request: ASAuthorizationAppleIDRequest) {
|
||||
|
||||
@@ -12,13 +12,20 @@ import SwiftUI
|
||||
@MainActor
|
||||
struct AccountCenterUITestHarness: View {
|
||||
@StateObject private var coordinator: AccountSessionCoordinator
|
||||
private let startsSignIn: Bool
|
||||
|
||||
init() {
|
||||
let startsSignIn = ProcessInfo.processInfo.arguments.contains(
|
||||
"--account-signin-loading"
|
||||
)
|
||||
let service = AccountCenterUITestService(
|
||||
shouldFailSnapshot: ProcessInfo.processInfo.arguments.contains(
|
||||
"--account-snapshot-failure"
|
||||
)
|
||||
),
|
||||
startsSignedOut: startsSignIn,
|
||||
signInDelay: startsSignIn ? .seconds(5) : .zero
|
||||
)
|
||||
self.startsSignIn = startsSignIn
|
||||
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
|
||||
_coordinator = StateObject(
|
||||
wrappedValue: AccountSessionCoordinator(
|
||||
@@ -41,6 +48,15 @@ struct AccountCenterUITestHarness: View {
|
||||
}
|
||||
.task {
|
||||
await coordinator.restoreIfNeeded()
|
||||
if startsSignIn {
|
||||
await coordinator.signIn(
|
||||
with: AppleAuthorizationPayload(
|
||||
identityToken: "ui-test-identity",
|
||||
authorizationCode: "ui-test-authorization",
|
||||
nonce: "ui-test-nonce"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,17 +112,28 @@ private actor AccountCenterUITestService:
|
||||
displayName: "UI Test Account"
|
||||
)
|
||||
private let shouldFailSnapshot: Bool
|
||||
private let startsSignedOut: Bool
|
||||
private let signInDelay: Duration
|
||||
|
||||
init(shouldFailSnapshot: Bool = false) {
|
||||
init(
|
||||
shouldFailSnapshot: Bool = false,
|
||||
startsSignedOut: Bool = false,
|
||||
signInDelay: Duration = .zero
|
||||
) {
|
||||
self.shouldFailSnapshot = shouldFailSnapshot
|
||||
self.startsSignedOut = startsSignedOut
|
||||
self.signInDelay = signInDelay
|
||||
}
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
account
|
||||
startsSignedOut ? nil : account
|
||||
}
|
||||
|
||||
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
|
||||
_ = payload
|
||||
if signInDelay > .zero {
|
||||
try await Task.sleep(for: signInDelay)
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ struct AccountCenterView: View {
|
||||
}
|
||||
.navigationTitle("account.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.task(id: coordinator.accountID) {
|
||||
guard let accountID = coordinator.accountID else {
|
||||
coordinator.creditPurchases.reset()
|
||||
@@ -116,11 +115,6 @@ struct AccountCenterView: View {
|
||||
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||
.disabled(coordinator.operation != nil)
|
||||
|
||||
if coordinator.operation == .signingIn {
|
||||
ProgressView("account.signIn.loading")
|
||||
.tint(palette.accent)
|
||||
}
|
||||
|
||||
if coordinator.pendingReferralCode != nil {
|
||||
Label("account.referral.pendingAfterSignIn", systemImage: "link")
|
||||
.font(.footnote)
|
||||
@@ -273,7 +267,6 @@ struct AccountCenterView: View {
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.background(palette.surface, in: shape)
|
||||
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
|
||||
.accessibilityIdentifier("account.summary")
|
||||
}
|
||||
|
||||
@@ -954,9 +947,12 @@ private struct AccountActionRow: View {
|
||||
let showsProgress: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Label(titleKey, systemImage: systemImage)
|
||||
.font(.body)
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.frame(width: 20)
|
||||
Text(titleKey)
|
||||
.font(TypeStyle.body)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
if showsProgress {
|
||||
ProgressView()
|
||||
|
||||
@@ -8,6 +8,10 @@ import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct AccountPurchaseHistoryView: View {
|
||||
private enum Layout {
|
||||
static let purchaseRowMinHeight: CGFloat = 64
|
||||
}
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject var manager: AccountCreditPurchaseManager
|
||||
@@ -56,7 +60,6 @@ struct AccountPurchaseHistoryView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("account.purchaseHistory.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.task {
|
||||
await manager.loadPurchaseHistory(accountID: accountID)
|
||||
}
|
||||
@@ -69,6 +72,14 @@ struct AccountPurchaseHistoryView: View {
|
||||
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 }
|
||||
@@ -111,8 +122,8 @@ struct AccountPurchaseHistoryView: View {
|
||||
private func purchaseRow(
|
||||
_ record: AccountCreditPurchaseRecord
|
||||
) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
HStack(alignment: .center, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: Spacing.xxs) {
|
||||
Text("+")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
@@ -121,27 +132,32 @@ struct AccountPurchaseHistoryView: View {
|
||||
.font(TypeStyle.bodyEmph.monospacedDigit())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.storekit.credits")
|
||||
.font(TypeStyle.caption)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
Text(purchaseDateText(record.purchasedAt))
|
||||
.font(TypeStyle.caption)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
VStack(alignment: .trailing, spacing: Spacing.xxs) {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(balanceAfterText(record.balanceAfter))
|
||||
.font(TypeStyle.caption.monospacedDigit())
|
||||
.font(TypeStyle.body.monospacedDigit())
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Text("account.purchaseHistory.status.credited")
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.font(TypeStyle.caption2.weight(.semibold))
|
||||
.foregroundStyle(palette.success)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: Layout.purchaseRowMinHeight,
|
||||
alignment: .center
|
||||
)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("account.purchaseHistory.row.\(record.transactionID)")
|
||||
}
|
||||
|
||||
private func purchaseDateText(_ date: Date) -> String {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
// AccountRewardsCard.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Compact Home account surface. Signed-in users see the same credit progress
|
||||
// and invitation controls formerly shown in Settings; signed-out users see
|
||||
// the concrete free-credit paths before authenticating.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct AccountRewardsCard: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
|
||||
let onOpenAccount: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch coordinator.sessionPhase {
|
||||
case .restoring:
|
||||
loadingContent("account.settings.restoring")
|
||||
case .signedOut:
|
||||
signedOutContent
|
||||
case .signedIn:
|
||||
signedInContent
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
.task(id: coordinator.accountID) {
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .center, spacing: Spacing.md) {
|
||||
Image(systemName: "gift")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("home.accountRewards.signedOut.title")
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("home.accountRewards.signedOut.summary")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||
.disabled(coordinator.operation != nil)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
}
|
||||
|
||||
private var signedInContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
switch coordinator.snapshotPhase {
|
||||
case let .loaded(snapshot):
|
||||
creditSummary(snapshot)
|
||||
case .failed:
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text("account.error.load")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Button("account.retry") {
|
||||
Task { await coordinator.refreshAccountData(force: true) }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .idle, .loading:
|
||||
loadingContent("account.loading.center")
|
||||
}
|
||||
|
||||
Divider().background(palette.divider)
|
||||
AccountReferralLinkView(viewModel: coordinator.referralProfile)
|
||||
}
|
||||
}
|
||||
|
||||
private func creditSummary(_ snapshot: AccountCenterSnapshot) -> some View {
|
||||
let totalCredits = creditTotal(snapshot)
|
||||
return Button(action: onOpenAccount) {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(creditRemainingText(snapshot.credits.balance))
|
||||
.font(TypeStyle.title3.monospacedDigit())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.credits.balance")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
VStack(alignment: .trailing, spacing: Spacing.xxs) {
|
||||
Text(creditUsedText(snapshot.credits.usedCredits))
|
||||
Text(creditTotalText(totalCredits))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(minWidth: 32, minHeight: 32)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
AccountCreditProgress(
|
||||
remaining: snapshot.credits.balance,
|
||||
used: snapshot.credits.usedCredits,
|
||||
showsLabels: false
|
||||
)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(Spacing.md)
|
||||
}
|
||||
|
||||
private func loadingContent(_ key: LocalizedStringKey) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Text(key)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
private func creditTotal(_ snapshot: AccountCenterSnapshot) -> Int64 {
|
||||
let remaining = max(snapshot.credits.balance, 0)
|
||||
let used = max(snapshot.credits.usedCredits, 0)
|
||||
let (total, overflow) = remaining.addingReportingOverflow(used)
|
||||
return overflow ? Int64.max : total
|
||||
}
|
||||
|
||||
private func creditRemainingText(_ remaining: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.credits.remainingCompact",
|
||||
language: config.uiLanguage,
|
||||
max(remaining, 0).formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
private func creditUsedText(_ used: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.credits.usedCompact",
|
||||
language: config.uiLanguage,
|
||||
max(used, 0).formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
private func creditTotalText(_ total: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.credits.totalValueCompact",
|
||||
language: config.uiLanguage,
|
||||
total.formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
private var operationErrorBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { coordinator.operationErrorKey != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
coordinator.dismissOperationError()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountReferralLinkView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject var viewModel: ReferralProfileViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch viewModel.state {
|
||||
case .idle:
|
||||
retryRow(
|
||||
messageKey: "account.referral.loadLink",
|
||||
actionKey: "account.referral.loadLink"
|
||||
)
|
||||
case .loading:
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(palette.accent)
|
||||
Text("account.referral.loadingLink")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
case .failed(let messageKey):
|
||||
retryRow(messageKey: messageKey, actionKey: "account.retry")
|
||||
case .loaded(let profile):
|
||||
loadedContent(profile)
|
||||
}
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityIdentifier("account.referral.profile")
|
||||
}
|
||||
|
||||
private func loadedContent(_ profile: ReferralProfile) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Text("account.referral.equalRewardDescription")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
AccountInvitationButton(invitationURL: profile.code.inviteURL)
|
||||
}
|
||||
|
||||
if viewModel.isRefreshing {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
Text("account.referral.refreshingLink")
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
} else if let refreshErrorKey = viewModel.refreshErrorKey {
|
||||
retryRow(messageKey: refreshErrorKey, actionKey: "account.retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func retryRow(
|
||||
messageKey: String,
|
||||
actionKey: LocalizedStringKey
|
||||
) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(LocalizedStringKey(messageKey))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Button(actionKey) {
|
||||
Task { await viewModel.refresh() }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,13 +139,21 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
await redeemPendingReferralIfNeeded()
|
||||
await refreshAccountData(force: true)
|
||||
} catch {
|
||||
await sessionService.clearManagedGateway()
|
||||
await onAccountSignedOut()
|
||||
sessionPhase = .signedOut
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.restore"
|
||||
)
|
||||
if error as? AccountIntegrationError == .unavailable {
|
||||
await sessionService.clearManagedGateway()
|
||||
await onAccountSignedOut()
|
||||
sessionPhase = .signedOut
|
||||
} else {
|
||||
// Keychain protection and transport availability can be
|
||||
// transient during launch. Keep the restoring state and allow
|
||||
// the next foreground activation to retry instead of turning
|
||||
// a retained, valid session into a signed-out session.
|
||||
didAttemptRestore = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,26 +213,47 @@ private actor LiveAccountService:
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
guard let cachedSession = try await apiClient.currentSession() else {
|
||||
OSGDiag.log("session restore status=not-found", category: "account")
|
||||
await invalidateManagedGatewaySession()
|
||||
return nil
|
||||
}
|
||||
OSGDiag.log("session restore status=keychain-found", category: "account")
|
||||
let account: OSGAccount?
|
||||
do {
|
||||
account = try await apiClient.account()
|
||||
} catch let error as AccountAPIError {
|
||||
switch error {
|
||||
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
||||
OSGDiag.log(
|
||||
"session restore status=rejected "
|
||||
+ "error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
await invalidateManagedGatewaySession()
|
||||
return nil
|
||||
default:
|
||||
OSGDiag.log(
|
||||
"session restore status=cached-fallback "
|
||||
+ "error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
account = nil
|
||||
}
|
||||
} catch {
|
||||
OSGDiag.log(
|
||||
"session restore status=cached-fallback "
|
||||
+ "error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
account = nil
|
||||
}
|
||||
let session = try await apiClient.currentSession() ?? cachedSession
|
||||
configuration.setManagedGatewayAccountSessionAvailable(true)
|
||||
await synchronizeManagedGrant()
|
||||
OSGDiag.log(
|
||||
"session restore status=restored accountValidated=\(account == nil ? 0 : 1)",
|
||||
category: "account"
|
||||
)
|
||||
return uiSession(
|
||||
session,
|
||||
createdAtEpochSeconds: account?.createdAtEpochSeconds ?? 0,
|
||||
|
||||
@@ -174,7 +174,20 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.skillTipText = "Skill failed"
|
||||
case .skills:
|
||||
AIKeyboardView.debugPreviewSkills = AIClipboardSkillCatalog.catalog
|
||||
// Keep the pagination assertion independent from production catalog
|
||||
// ordering: Navigate is guaranteed to appear after one swipe on both
|
||||
// four- and five-item pages.
|
||||
let navigate = AIClipboardSkillCatalog.catalog.first {
|
||||
$0.id == AIClipboardSkillCatalog.navigateID
|
||||
}
|
||||
let leading = AIClipboardSkillCatalog.catalog
|
||||
.filter { $0.id != AIClipboardSkillCatalog.navigateID }
|
||||
.prefix(5)
|
||||
var previewSkills = Array(leading)
|
||||
if let navigate {
|
||||
previewSkills.append(navigate)
|
||||
}
|
||||
AIKeyboardView.debugPreviewSkills = previewSkills
|
||||
state.undoAvailable = true
|
||||
state.editAvailable = true
|
||||
case .search:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Observes usage + dictionary counts and feeds the shared
|
||||
// `UsageStatsCluster` (phone stacked / iPad split). Optional `header`
|
||||
// (e.g. glass preview field) sits on the 7-day chart card.
|
||||
// (e.g. glass preview field) sits on the monthly calendar card.
|
||||
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
@@ -12,6 +12,9 @@ import SwiftUI
|
||||
struct HomeUsageStatsSection<Header: View>: View {
|
||||
let layout: UsageStatsClusterLayout
|
||||
var compact: Bool = false
|
||||
var content: UsageStatsClusterContent = .all
|
||||
var onOpenHistory: (() -> Void)?
|
||||
var onOpenDictionary: (() -> Void)?
|
||||
@ViewBuilder var header: () -> Header
|
||||
|
||||
@ObservedObject private var stats = UsageStatisticsStore.shared
|
||||
@@ -23,12 +26,15 @@ struct HomeUsageStatsSection<Header: View>: View {
|
||||
UsageStatsCluster(
|
||||
layout: layout,
|
||||
language: config.uiLanguage,
|
||||
points: stats.last7Days,
|
||||
points: stats.currentMonth,
|
||||
dictationCharacterCount: stats.dictationCharacterCount,
|
||||
dictationDurationSeconds: stats.dictationDurationSeconds,
|
||||
translationCharacterCount: stats.translationCharacterCount,
|
||||
dictionaryTermCount: dictionaryCount,
|
||||
compact: compact,
|
||||
content: content,
|
||||
onOpenHistory: onOpenHistory,
|
||||
onOpenDictionary: onOpenDictionary,
|
||||
header: header
|
||||
)
|
||||
.onAppear(perform: refreshDictionaryCount)
|
||||
@@ -49,8 +55,21 @@ struct HomeUsageStatsSection<Header: View>: View {
|
||||
}
|
||||
|
||||
extension HomeUsageStatsSection where Header == EmptyView {
|
||||
init(layout: UsageStatsClusterLayout, compact: Bool = false) {
|
||||
self.init(layout: layout, compact: compact, header: { EmptyView() })
|
||||
init(
|
||||
layout: UsageStatsClusterLayout,
|
||||
compact: Bool = false,
|
||||
content: UsageStatsClusterContent = .all,
|
||||
onOpenHistory: (() -> Void)? = nil,
|
||||
onOpenDictionary: (() -> Void)? = nil
|
||||
) {
|
||||
self.init(
|
||||
layout: layout,
|
||||
compact: compact,
|
||||
content: content,
|
||||
onOpenHistory: onOpenHistory,
|
||||
onOpenDictionary: onOpenDictionary,
|
||||
header: { EmptyView() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// OSGAnimatedMeshBackground.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// A slowly-flowing MeshGradient background (iOS 18+, deployment target iOS 26).
|
||||
// System-native Metal acceleration, no third-party dependencies, 0 KB of assets.
|
||||
//
|
||||
// Design notes:
|
||||
// - 3×3 grid keeps render cost low; corner points stay fixed (no clipping at edges).
|
||||
// - Only the 4 inner control points + 4 mid-edge points drift; this avoids the
|
||||
// gradient "snapping" at the edges of the parent view.
|
||||
// - `TimelineView(.animation(minimumInterval: 1.0/30.0))` caps redraw at 30 fps
|
||||
// (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
|
||||
// sit in the "premium, calm, late-night" register — fits the Typeless vibe.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
struct OSGAnimatedMeshBackground: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// Animation period in seconds for a full color cycle. Longer = calmer.
|
||||
var period: Double = 14.0
|
||||
|
||||
/// Optional palette override. Defaults to a deep indigo / amber / teal set.
|
||||
var palette: AnimatedMeshPalette = .aurora
|
||||
|
||||
/// Pause the animation at the current frame. Useful for App Store
|
||||
/// screenshot capture or for "preview" hosts that want a still image.
|
||||
var paused: Bool = false
|
||||
|
||||
var body: some View {
|
||||
if reduceMotion || paused {
|
||||
MeshGradient(
|
||||
width: 3,
|
||||
height: 3,
|
||||
points: Self.staticPoints,
|
||||
colors: palette.colors,
|
||||
smoothsColors: true,
|
||||
colorSpace: .perceptual
|
||||
)
|
||||
} else {
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: false)) { context in
|
||||
let phase = context.date.timeIntervalSinceReferenceDate / period
|
||||
MeshGradient(
|
||||
width: 3,
|
||||
height: 3,
|
||||
points: Self.animatedPoints(phase: phase),
|
||||
colors: Self.animatedColors(base: palette.colors, phase: phase),
|
||||
smoothsColors: true,
|
||||
colorSpace: .perceptual
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geometry
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
private extension OSGAnimatedMeshBackground {
|
||||
/// 9 SIMD2 control points for a 3×3 mesh, top-left → bottom-right, row-major.
|
||||
/// Corner points (0, 3, 6, 8) stay fixed; the rest drift on a slow sinusoid.
|
||||
static let staticPoints: [SIMD2<Float>] = [
|
||||
SIMD2(0.0, 0.0), SIMD2(0.5, 0.0), SIMD2(1.0, 0.0),
|
||||
SIMD2(0.0, 0.5), SIMD2(0.5, 0.5), SIMD2(1.0, 0.5),
|
||||
SIMD2(0.0, 1.0), SIMD2(0.5, 1.0), SIMD2(1.0, 1.0)
|
||||
]
|
||||
|
||||
static func animatedPoints(phase: Double) -> [SIMD2<Float>] {
|
||||
// Use distinct incommensurable frequencies so the loop never visibly
|
||||
// repeats inside a normal viewing session.
|
||||
let p = Float(phase)
|
||||
let cos1 = cos(p * 0.27) * 0.18
|
||||
let cos2 = cos(p * 0.41 + 1.7) * 0.16
|
||||
let sin1 = sin(p * 0.33 + 0.4) * 0.18
|
||||
let sin2 = sin(p * 0.51 + 2.1) * 0.14
|
||||
return [
|
||||
SIMD2(0.0, 0.0), // top-left (fixed)
|
||||
SIMD2(0.5 + cos1, 0.0), // top-mid
|
||||
SIMD2(1.0, 0.0), // top-right (fixed)
|
||||
SIMD2(0.0, 0.5 + sin1), // left-mid
|
||||
SIMD2(0.5 + cos2, 0.5 + sin2), // center
|
||||
SIMD2(1.0, 0.5 + cos1 * 0.7), // right-mid
|
||||
SIMD2(0.0, 1.0), // bottom-left (fixed)
|
||||
SIMD2(0.5 + sin2, 1.0), // bottom-mid
|
||||
SIMD2(1.0, 1.0) // bottom-right (fixed)
|
||||
]
|
||||
}
|
||||
|
||||
/// Slowly cycle each color's hue by a tiny amount so the gradient looks
|
||||
/// alive without ever crossing into "screensaver" territory.
|
||||
static func animatedColors(base: [Color], phase: Double) -> [Color] {
|
||||
base.enumerated().map { index, color in
|
||||
let shift = cos(phase + Double(index) * 0.31) * 0.025
|
||||
return color.shiftedHue(by: shift)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Palettes
|
||||
|
||||
/// Named color sets. Each is 9 colors arranged to read as a smooth
|
||||
/// "calm cloud" — low saturation, low brightness, no obvious focal point.
|
||||
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
|
||||
])
|
||||
|
||||
/// 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)
|
||||
])
|
||||
|
||||
/// 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)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension Color {
|
||||
/// Shift the color's hue by a small amount (-1.0 ... 1.0 wraps the wheel).
|
||||
/// Used purely for subtle gradient breathing — keep `amount` ≤ 0.05.
|
||||
func shiftedHue(by amount: Double) -> Color {
|
||||
var hue: CGFloat = 0
|
||||
var saturation: CGFloat = 0
|
||||
var brightness: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
UIColor(self).getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
|
||||
hue += CGFloat(amount)
|
||||
hue = hue.truncatingRemainder(dividingBy: 1.0)
|
||||
if hue < 0 { hue += 1 }
|
||||
return Color(hue: Double(hue), saturation: Double(saturation), brightness: Double(brightness), opacity: Double(alpha))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Aurora (dark)") {
|
||||
ZStack {
|
||||
OSGAnimatedMeshBackground(palette: .aurora)
|
||||
Text("aurora")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Polar (dark)") {
|
||||
ZStack {
|
||||
OSGAnimatedMeshBackground(palette: .polar)
|
||||
Text("polar")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Ember (dark)") {
|
||||
ZStack {
|
||||
OSGAnimatedMeshBackground(palette: .ember)
|
||||
Text("ember")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@@ -26,9 +26,5 @@ struct PrivacyInfoCard: View {
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
// TabBarVisibility.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Push 进 NavigationStack 子页时隐藏底部系统 tab 栏。
|
||||
// 子页用 `hidesTabBarWhenPushed()` 声明,根页面保持原生 TabView 导航可见。
|
||||
// NavigationStack 根路径统一驱动底部系统 tab 栏显隐。
|
||||
// 显隐状态在 push / pop 开始时更新,使原生 Dock 与页面过渡同步。
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Modifiers
|
||||
|
||||
private struct NavigationStackTabBarVisibilityModifier: ViewModifier {
|
||||
let isRoot: Bool
|
||||
|
||||
@State private var visibility: Visibility = .visible
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.toolbarVisibility(visibility, for: .tabBar)
|
||||
.onAppear {
|
||||
visibility = isRoot ? .visible : .hidden
|
||||
}
|
||||
.onChange(of: isRoot) { _, newValue in
|
||||
withAnimation(Motion.soft) {
|
||||
visibility = newValue ? .visible : .hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Marks this view as a pushed detail screen so the native tab bar hides.
|
||||
func hidesTabBarWhenPushed() -> some View {
|
||||
toolbar(.hidden, for: .tabBar)
|
||||
/// Keeps the native tab dock visible only at a NavigationStack root.
|
||||
func navigationStackTabBarVisibility(isRoot: Bool) -> some View {
|
||||
modifier(NavigationStackTabBarVisibilityModifier(isRoot: isRoot))
|
||||
}
|
||||
|
||||
/// Adds ordinary footer breathing room; native TabView owns tab-bar clearance.
|
||||
|
||||
@@ -37,8 +37,5 @@ struct WideCard<Content: View>: View {
|
||||
content()
|
||||
.padding(padding)
|
||||
.background(palette.surface, in: shape)
|
||||
.overlay(
|
||||
shape.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,5 @@ struct HelpFeedbackView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.link.support")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ struct HistoryView: View {
|
||||
.background(palette.background)
|
||||
.navigationTitle("history.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.toolbar {
|
||||
if !store.entries.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@@ -105,7 +104,7 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listSectionSpacing(Spacing.lg)
|
||||
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.contentMargins(.top, Spacing.md, for: .scrollContent)
|
||||
|
||||
+246
-201
@@ -1,10 +1,11 @@
|
||||
// HomeView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Home: logo, transient Flow connection status, usage stats, history +
|
||||
// dictionary entry card. History/dictionary
|
||||
// open via push (system back) rather than bottom-tab destinations.
|
||||
// Home: logo, transient Flow connection status, usage stats, account rewards,
|
||||
// and dictionary suggestions. History/dictionary/account destinations open
|
||||
// via push (system back) rather than bottom-tab destinations.
|
||||
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
@@ -12,6 +13,7 @@ import UIKit
|
||||
private enum HomeRoute: Hashable {
|
||||
case history
|
||||
case dictionary
|
||||
case account
|
||||
}
|
||||
|
||||
enum FlowHomePiPStatusDescriptor: Equatable {
|
||||
@@ -97,22 +99,44 @@ enum FlowHomePiPStatusPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
enum HomeServiceSetupPolicy {
|
||||
static func apiKeyMessageKey(
|
||||
isLocalEngine: Bool,
|
||||
isASRConfigured: Bool
|
||||
) -> String {
|
||||
if isLocalEngine || isASRConfigured {
|
||||
return "home.setup.polishKeyMissing"
|
||||
}
|
||||
return "home.setup.cloudIncomplete"
|
||||
}
|
||||
|
||||
static func apiKeyDeepLink(
|
||||
isLocalEngine: Bool,
|
||||
isASRConfigured: Bool
|
||||
) -> SettingsDeepLink {
|
||||
if isLocalEngine || isASRConfigured {
|
||||
return .textPolish
|
||||
}
|
||||
return .speechRecognition
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject private var speechHistory = SpeechHistoryStore.shared
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
@State private var micStatus = AppPermissions.micStatus
|
||||
@State private var speechStatus = AppPermissions.speechStatus
|
||||
@State private var path = NavigationPath()
|
||||
@State private var dictionarySuggestions: [RimeFrequentTerm] = []
|
||||
@State private var pendingDictionarySuggestion: RimeFrequentTerm?
|
||||
@State private var dictionarySuggestions: [FrequentTerm] = []
|
||||
@State private var pendingDictionarySuggestion: FrequentTerm?
|
||||
|
||||
private let appGroupStore = AppGroupStore()
|
||||
private let rimeFrequentTermStore = RimeFrequentTermStore()
|
||||
private let dictionaryEntryService = PersonalDictionaryEntryService()
|
||||
private let frequentTermStore = FrequentTermStore()
|
||||
|
||||
private var usesWideLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
@@ -130,6 +154,22 @@ struct HomeView: View {
|
||||
return !config.isConfigured
|
||||
}
|
||||
|
||||
private var apiKeySetupMessageKey: LocalizedStringKey {
|
||||
LocalizedStringKey(
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: config.isLocalEngine,
|
||||
isASRConfigured: config.isASRConfigured
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var apiKeySettingsDeepLink: SettingsDeepLink {
|
||||
HomeServiceSetupPolicy.apiKeyDeepLink(
|
||||
isLocalEngine: config.isLocalEngine,
|
||||
isASRConfigured: config.isASRConfigured
|
||||
)
|
||||
}
|
||||
|
||||
private var needsPermissionSetup: Bool {
|
||||
micStatus != .granted || speechStatus != .granted
|
||||
}
|
||||
@@ -188,9 +228,12 @@ struct HomeView: View {
|
||||
HistoryView()
|
||||
case .dictionary:
|
||||
PersonalDictionaryView()
|
||||
case .account:
|
||||
AccountCenterView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationStackTabBarVisibility(isRoot: path.isEmpty)
|
||||
.onAppear {
|
||||
refreshPermissionStatuses()
|
||||
refreshDictionarySuggestions()
|
||||
@@ -211,32 +254,6 @@ struct HomeView: View {
|
||||
guard count == 0 else { return }
|
||||
refreshDictionarySuggestions()
|
||||
}
|
||||
.alert(
|
||||
pendingDictionarySuggestion.map {
|
||||
AppL10n.format("home.card.dictionary.confirm.title", $0.term)
|
||||
} ?? "",
|
||||
isPresented: Binding(
|
||||
get: { pendingDictionarySuggestion != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
pendingDictionarySuggestion = nil
|
||||
}
|
||||
}
|
||||
),
|
||||
presenting: pendingDictionarySuggestion
|
||||
) { suggestion in
|
||||
Button("home.card.dictionary.confirm.add") {
|
||||
addSuggestedTerm(suggestion)
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: { suggestion in
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"home.card.dictionary.confirm.message",
|
||||
suggestion.commitCount
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phone layout
|
||||
@@ -247,7 +264,6 @@ struct HomeView: View {
|
||||
let isCompact = geo.size.height < 700
|
||||
let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl
|
||||
let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl
|
||||
let extrasBottomPadding = isCompact ? Spacing.sm : Spacing.lg
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
sessionHeaderGradient(height: gradientHeight)
|
||||
@@ -265,14 +281,10 @@ struct HomeView: View {
|
||||
if showsFlowConnectionCard {
|
||||
scrollStatusFooter
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, extrasBottomPadding)
|
||||
.padding(.bottom, CardLayoutMetrics.sectionSpacing)
|
||||
}
|
||||
|
||||
HomeUsageStatsSection(layout: .stacked, compact: isCompact)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, Spacing.md)
|
||||
|
||||
homeLibrarySection
|
||||
homeContentSections(layout: .stacked, compact: isCompact)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, Spacing.xl)
|
||||
}
|
||||
@@ -288,6 +300,9 @@ struct HomeView: View {
|
||||
private var scrollStatusFooter: some View {
|
||||
setupGuidanceCard {
|
||||
flowStatusFooter
|
||||
if needsAPIKeySetup {
|
||||
apiKeySetupGuidance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,16 +310,14 @@ struct HomeView: View {
|
||||
|
||||
private var wideBody: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: Spacing.lg) {
|
||||
VStack(alignment: .leading, spacing: CardLayoutMetrics.sectionSpacing) {
|
||||
wideHeroHeader
|
||||
|
||||
if showsFlowConnectionCard {
|
||||
scrollStatusFooter
|
||||
}
|
||||
|
||||
HomeUsageStatsSection(layout: .split)
|
||||
|
||||
homeLibrarySection
|
||||
homeContentSections(layout: .split)
|
||||
}
|
||||
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
|
||||
.padding(.top, Spacing.sm)
|
||||
@@ -329,47 +342,38 @@ struct HomeView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - History / dictionary cards
|
||||
// MARK: - Usage, account, and dictionary
|
||||
|
||||
/// Two independent cards; each header is an accent icon + uppercase label
|
||||
/// and the body grows with its rows.
|
||||
private var homeLibrarySection: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
dictionaryCard
|
||||
historyCard
|
||||
}
|
||||
}
|
||||
|
||||
private var historyCard: some View {
|
||||
let entries = Array(speechHistory.entries.prefix(Self.libraryPreviewLimit))
|
||||
return homeLibraryCard(
|
||||
titleKey: "history.title",
|
||||
systemImage: "clock.arrow.circlepath",
|
||||
route: .history
|
||||
) {
|
||||
if entries.isEmpty {
|
||||
libraryEmptyLine("home.card.history.empty")
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 { libraryRowDivider }
|
||||
HStack(alignment: .firstTextBaseline, spacing: Spacing.sm) {
|
||||
Text(entry.text)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Text(Self.previewTimeFormatter.string(from: entry.createdAt))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
}
|
||||
/// Keep the Home information hierarchy stable on both phone and iPad:
|
||||
/// account rewards → four metrics → calendar → personal dictionary.
|
||||
private func homeContentSections(
|
||||
layout: UsageStatsClusterLayout,
|
||||
compact: Bool = false
|
||||
) -> some View {
|
||||
VStack(spacing: CardLayoutMetrics.sectionSpacing) {
|
||||
AccountRewardsCard {
|
||||
path.append(HomeRoute.account)
|
||||
}
|
||||
|
||||
HomeUsageStatsSection(
|
||||
layout: layout,
|
||||
compact: compact,
|
||||
content: .metrics,
|
||||
onOpenHistory: {
|
||||
path.append(HomeRoute.history)
|
||||
},
|
||||
onOpenDictionary: {
|
||||
path.append(HomeRoute.dictionary)
|
||||
}
|
||||
)
|
||||
|
||||
HomeUsageStatsSection(
|
||||
layout: layout,
|
||||
compact: compact,
|
||||
content: .calendar
|
||||
)
|
||||
|
||||
dictionaryCard
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,8 +385,8 @@ struct HomeView: View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
Text("settings.personalDictionary.title")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(0.6)
|
||||
@@ -398,50 +402,71 @@ struct HomeView: View {
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if dictionarySuggestions.isEmpty {
|
||||
libraryEmptyLine("home.card.dictionary.smart.empty")
|
||||
Text("home.card.dictionary.smart.empty")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, minHeight: 54, alignment: .center)
|
||||
} else {
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.adaptive(minimum: 88, maximum: 160), spacing: Spacing.sm)
|
||||
],
|
||||
alignment: .leading,
|
||||
spacing: Spacing.sm
|
||||
) {
|
||||
ForEach(dictionarySuggestions) { suggestion in
|
||||
Button {
|
||||
pendingDictionarySuggestion = suggestion
|
||||
} label: {
|
||||
Text(suggestion.term)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(maxWidth: .infinity, minHeight: 38)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
palette.accent.opacity(0.16),
|
||||
palette.accent.opacity(0.07)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
in: Capsule()
|
||||
)
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(palette.accent.opacity(0.22), lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(
|
||||
AppL10n.format(
|
||||
"home.card.dictionary.smart.accessibilityHint",
|
||||
suggestion.commitCount
|
||||
VStack(spacing: Spacing.sm) {
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(
|
||||
.adaptive(minimum: 88, maximum: 160),
|
||||
spacing: CardLayoutMetrics.compactItemSpacing
|
||||
)
|
||||
)
|
||||
],
|
||||
alignment: .leading,
|
||||
spacing: CardLayoutMetrics.compactItemSpacing
|
||||
) {
|
||||
ForEach(dictionarySuggestions) { suggestion in
|
||||
Button {
|
||||
pendingDictionarySuggestion = suggestion
|
||||
} label: {
|
||||
Text(suggestion.term)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(maxWidth: .infinity, minHeight: 38)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.popover(
|
||||
isPresented: Binding(
|
||||
get: {
|
||||
pendingDictionarySuggestion?.id == suggestion.id
|
||||
},
|
||||
set: { isPresented in
|
||||
if !isPresented,
|
||||
pendingDictionarySuggestion?.id == suggestion.id {
|
||||
pendingDictionarySuggestion = nil
|
||||
}
|
||||
}
|
||||
),
|
||||
attachmentAnchor: .rect(.bounds),
|
||||
arrowEdge: .bottom
|
||||
) {
|
||||
dictionarySuggestionConfirmation(suggestion)
|
||||
.presentationCompactAdaptation(.popover)
|
||||
}
|
||||
.accessibilityHint(
|
||||
AppL10n.format(
|
||||
"home.card.dictionary.smart.accessibilityHint",
|
||||
suggestion.commitCount
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text("home.card.dictionary.smart.addHint")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,55 +475,39 @@ struct HomeView: View {
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
/// Card shell: accent icon + label top-left, chevron trailing, custom body.
|
||||
private func homeLibraryCard<Content: View>(
|
||||
titleKey: LocalizedStringKey,
|
||||
systemImage: String,
|
||||
route: HomeRoute,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
Button {
|
||||
path.append(route)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text(titleKey)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(0.6)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
content()
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.surfaceCard()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
private func dictionarySuggestionConfirmation(_ suggestion: FrequentTerm) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
Text(AppL10n.format("home.card.dictionary.confirm.title", suggestion.term))
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
private var libraryRowDivider: some View {
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
|
||||
private func libraryEmptyLine(_ key: LocalizedStringKey) -> some View {
|
||||
Text(key)
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"home.card.dictionary.confirm.message",
|
||||
suggestion.commitCount
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Button("common.cancel") {
|
||||
pendingDictionarySuggestion = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button("home.card.dictionary.confirm.add") {
|
||||
addSuggestedTerm(suggestion)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(palette.accent)
|
||||
}
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.frame(width: 300)
|
||||
.background(palette.surface)
|
||||
}
|
||||
|
||||
private func refreshPermissionStatuses() {
|
||||
@@ -508,32 +517,29 @@ struct HomeView: View {
|
||||
|
||||
private func refreshDictionarySuggestions() {
|
||||
let dictionary = appGroupStore.personalDictionary
|
||||
let suggestions = rimeFrequentTermStore.suggestions(
|
||||
let suggestions = frequentTermStore.suggestions(
|
||||
excludingPersonalTerms: Set(dictionary.entries.map(\.term)),
|
||||
limit: Self.dictionarySuggestionLimit
|
||||
)
|
||||
dictionarySuggestions = suggestions.count >= Self.minimumDictionarySuggestionCount
|
||||
? suggestions
|
||||
let visibleSuggestions = Array(suggestions.prefix(Self.dictionarySuggestionLimit))
|
||||
dictionarySuggestions = visibleSuggestions.count >= Self.minimumDictionarySuggestionCount
|
||||
? visibleSuggestions
|
||||
: []
|
||||
}
|
||||
|
||||
private func addSuggestedTerm(_ suggestion: RimeFrequentTerm) {
|
||||
var dictionary = appGroupStore.personalDictionary
|
||||
guard let saved = dictionary.upsertManual(term: suggestion.term),
|
||||
let index = dictionary.entries.firstIndex(where: { $0.id == saved.id }) else {
|
||||
private func addSuggestedTerm(_ suggestion: FrequentTerm) {
|
||||
guard let saved = dictionaryEntryService.saveEntry(
|
||||
term: suggestion.term,
|
||||
source: .history,
|
||||
minimumUsageCount: suggestion.commitCount
|
||||
) else {
|
||||
return
|
||||
}
|
||||
dictionary.entries[index].usageCount = max(
|
||||
dictionary.entries[index].usageCount,
|
||||
suggestion.commitCount
|
||||
)
|
||||
dictionary.version += 1
|
||||
appGroupStore.setPersonalDictionary(dictionary)
|
||||
pendingDictionarySuggestion = nil
|
||||
refreshDictionarySuggestions()
|
||||
|
||||
Task {
|
||||
try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary)
|
||||
await dictionaryEntryService.finishSaving(saved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +554,11 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func openSettings(_ deepLink: SettingsDeepLink) {
|
||||
SettingsDeepLink.setPending(deepLink)
|
||||
NotificationCenter.default.post(name: .osgOpenSettingsDeepLink, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Top gradient
|
||||
|
||||
private func sessionHeaderGradient(height: CGFloat) -> some View {
|
||||
@@ -651,6 +662,8 @@ struct HomeView: View {
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, Spacing.xs)
|
||||
} else if needsAPIKeySetup {
|
||||
// The explanatory copy and both setup paths sit below this
|
||||
// compact status row so they remain readable on narrow phones.
|
||||
EmptyView()
|
||||
} else if canRetryPiP {
|
||||
Button {
|
||||
@@ -701,6 +714,51 @@ struct HomeView: View {
|
||||
.animation(Motion.soft, value: flowManager.isActive)
|
||||
}
|
||||
|
||||
private var apiKeySetupGuidance: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text(apiKeySetupMessageKey)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text("home.setup.credits.alternative")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Button {
|
||||
openSettings(apiKeySettingsDeepLink)
|
||||
} label: {
|
||||
Label("home.setup.byok.configure", systemImage: "key")
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 34)
|
||||
.background(
|
||||
palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
openSettings(.aiService)
|
||||
} label: {
|
||||
Label("home.setup.credits.open", systemImage: "sparkles")
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 34)
|
||||
.background(
|
||||
palette.accent,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.font(TypeStyle.body)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupGuidanceCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
content()
|
||||
@@ -708,10 +766,6 @@ struct HomeView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private var flowStatusColor: Color {
|
||||
@@ -750,15 +804,6 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows shown inside the history / dictionary preview cards.
|
||||
private static let libraryPreviewLimit = 3
|
||||
private static let minimumDictionarySuggestionCount = 3
|
||||
private static let dictionarySuggestionLimit = 5
|
||||
|
||||
private static let previewTimeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
private static let dictionarySuggestionLimit = 6
|
||||
}
|
||||
|
||||
@@ -97,10 +97,6 @@ struct KeyboardPreviewSheet: View {
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private var controls: some View {
|
||||
|
||||
@@ -66,7 +66,6 @@ struct KeyboardPreviewStub: View {
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(palette.surface, in: Circle())
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -84,7 +83,6 @@ struct KeyboardPreviewStub: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
|
||||
.background(palette.surfaceElevated, in: Capsule())
|
||||
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("preview.modeChip.cycle"))
|
||||
@@ -101,7 +99,6 @@ struct KeyboardPreviewStub: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
|
||||
.background(palette.surfaceElevated, in: Capsule())
|
||||
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("preview.localeChip.cycle"))
|
||||
|
||||
@@ -201,6 +201,12 @@ struct MainAppRoot: View {
|
||||
return
|
||||
}
|
||||
refreshOfficialSkillCatalog(reason: "scenePhase.active")
|
||||
Task {
|
||||
// A protected Keychain can be temporarily unavailable during
|
||||
// launch. The coordinator no-ops after a terminal restore, so
|
||||
// this only retries a previously interrupted attempt.
|
||||
await accountSession.restoreIfNeeded()
|
||||
}
|
||||
if config.hasCompletedOnboarding {
|
||||
activateForegroundServices(reason: "scenePhase.active")
|
||||
AIHintRefreshService.refreshIfNeeded(reason: "scenePhase.active")
|
||||
|
||||
@@ -15,14 +15,18 @@ struct MainTabView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if usesSplitLayout {
|
||||
MainSplitView(selection: $tab)
|
||||
} else {
|
||||
phoneTabLayout
|
||||
ZStack {
|
||||
palette.background
|
||||
.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
if usesSplitLayout {
|
||||
MainSplitView(selection: $tab)
|
||||
} else {
|
||||
phoneTabLayout
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
|
||||
tab = .settings
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// OSGAnimatedBackgroundPreviewView.swift
|
||||
// OSGKeyboard · Main App (DEBUG-only)
|
||||
//
|
||||
// Standalone preview host for `OSGAnimatedMeshBackground`.
|
||||
// Open from anywhere with `OSGAnimatedBackgroundPreviewView()`.
|
||||
//
|
||||
// Tapping the screen swaps the palette; long-press pauses/resumes the animation
|
||||
// so you can capture a still frame for App Store screenshots.
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct OSGAnimatedBackgroundPreviewView: View {
|
||||
private enum PaletteChoice: String, CaseIterable, Identifiable {
|
||||
case aurora, polar, ember
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .aurora: return "Aurora"
|
||||
case .polar: return "Polar"
|
||||
case .ember: return "Ember"
|
||||
}
|
||||
}
|
||||
var palette: AnimatedMeshPalette {
|
||||
switch self {
|
||||
case .aurora: return .aurora
|
||||
case .polar: return .polar
|
||||
case .ember: return .ember
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var choice: PaletteChoice = .aurora
|
||||
@State private var paused: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OSGAnimatedMeshBackground(palette: choice.palette, paused: paused)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Spacer()
|
||||
Text(choice.label)
|
||||
.font(.system(size: 56, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
Text(paused ? "paused — long-press to resume" : "tap to switch · long-press to pause")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
Spacer().frame(height: 12)
|
||||
paletteDots
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
}
|
||||
.background(Color.black)
|
||||
.preferredColorScheme(.dark)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.4)) {
|
||||
let all = PaletteChoice.allCases
|
||||
let next = all.firstIndex(of: choice).map { all[($0 + 1) % all.count] } ?? choice
|
||||
choice = next
|
||||
}
|
||||
}
|
||||
.onLongPressGesture(minimumDuration: 0.5) {
|
||||
paused.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
private var paletteDots: some View {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(PaletteChoice.allCases) { item in
|
||||
Circle()
|
||||
.fill(item == choice ? Color.white : Color.white.opacity(0.3))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Aurora · polar · ember") {
|
||||
OSGAnimatedBackgroundPreviewView()
|
||||
}
|
||||
#endif
|
||||
@@ -334,10 +334,6 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
@@ -416,10 +412,6 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Keyboard setup
|
||||
@@ -489,10 +481,6 @@ struct OnboardingExperienceView: View {
|
||||
palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.padding(.top, Spacing.xxl)
|
||||
|
||||
TextField(
|
||||
@@ -762,10 +750,6 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 172, maxHeight: 240)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(keyboardReady ? palette.accent.opacity(0.42) : palette.divider, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var practiceVoiceSample: some View {
|
||||
@@ -822,10 +806,6 @@ struct OnboardingExperienceView: View {
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private var practiceTitle: LocalizedStringKey {
|
||||
@@ -918,7 +898,7 @@ struct OnboardingExperienceView: View {
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.opacity(accountSession.isSignedIn ? 0 : 1)
|
||||
.disabled(accountSession.isSignedIn)
|
||||
.disabled(accountSession.isSignedIn || accountSession.operation != nil)
|
||||
.accessibilityHidden(accountSession.isSignedIn)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -927,10 +907,6 @@ struct OnboardingExperienceView: View {
|
||||
palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.padding(.top, Spacing.xxl)
|
||||
.accessibilityIdentifier("onboarding.loginReward.card")
|
||||
}
|
||||
@@ -1483,10 +1459,6 @@ private extension View {
|
||||
palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1584,9 +1556,5 @@ private struct KeyboardSettingsPreview: View {
|
||||
.padding(Spacing.md)
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,10 +295,6 @@ struct OnboardingView: View {
|
||||
.font(TypeStyle.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -42,7 +42,6 @@ struct OpenSourceLicensesView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.licenses.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
|
||||
private func licenseRow(_ entry: OpenSourceLicenseCatalog.Entry) -> some View {
|
||||
@@ -105,6 +104,5 @@ private struct OpenSourceLicenseDetailView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(entry.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ struct PersonalDictionaryView: View {
|
||||
@State private var generatingAliasEntryIDs: Set<UUID> = []
|
||||
|
||||
private let store = AppGroupStore()
|
||||
private let aliasGenerator = DictionaryAliasGenerator()
|
||||
private let dictionaryEntryService = PersonalDictionaryEntryService()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -37,7 +37,6 @@ struct PersonalDictionaryView: View {
|
||||
.background(palette.background)
|
||||
.navigationTitle("settings.personalDictionary.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.toolbar {
|
||||
if !dictionary.entries.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@@ -76,7 +75,11 @@ struct PersonalDictionaryView: View {
|
||||
initialTerm: editingEntry?.term ?? "",
|
||||
isEditing: editingEntry != nil
|
||||
) { term in
|
||||
saveManualEntry(term: term, editingID: editingEntry?.id)
|
||||
saveEntry(
|
||||
term: term,
|
||||
editingID: editingEntry?.id,
|
||||
source: editingEntry?.source ?? .manual
|
||||
)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
@@ -110,7 +113,7 @@ struct PersonalDictionaryView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listSectionSpacing(Spacing.lg)
|
||||
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
// 让搜索栏与首个词条之间留出呼吸空间,视觉更透气。
|
||||
@@ -138,14 +141,6 @@ struct PersonalDictionaryView: View {
|
||||
Text(SharedL10n.string(entry.source.labelKey, language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
if entry.usageCount > 1 {
|
||||
Text("·")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text("settings.personalDictionary.usageCount \(entry.usageCount)")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
if generatingAliasEntryIDs.contains(entry.id) {
|
||||
Text("·")
|
||||
.font(TypeStyle.caption2)
|
||||
@@ -231,32 +226,25 @@ struct PersonalDictionaryView: View {
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
private func saveManualEntry(term: String, editingID: UUID?) {
|
||||
let previousTerm = editingID.flatMap { id in
|
||||
dictionary.entries.first(where: { $0.id == id })?.term
|
||||
private func saveEntry(
|
||||
term: String,
|
||||
editingID: UUID?,
|
||||
source: PersonalDictionary.Entry.Source
|
||||
) {
|
||||
guard let saved = dictionaryEntryService.saveEntry(
|
||||
term: term,
|
||||
existingID: editingID,
|
||||
source: source
|
||||
) else {
|
||||
return
|
||||
}
|
||||
let termChanged = previousTerm.map {
|
||||
$0.caseInsensitiveCompare(term) != .orderedSame
|
||||
} ?? true
|
||||
|
||||
guard dictionary.upsertManual(term: term, existingID: editingID) != nil else { return }
|
||||
persist()
|
||||
|
||||
guard let saved = dictionary.entry(matchingTerm: term) else { return }
|
||||
let shouldGenerate = saved.source == .manual && (editingID == nil || termChanged)
|
||||
if shouldGenerate {
|
||||
generateAliases(for: saved.id, term: saved.term)
|
||||
dictionary = saved.dictionary
|
||||
if saved.shouldGenerateAliases {
|
||||
generatingAliasEntryIDs.insert(saved.entryID)
|
||||
}
|
||||
}
|
||||
|
||||
private func generateAliases(for entryID: UUID, term: String) {
|
||||
generatingAliasEntryIDs.insert(entryID)
|
||||
Task {
|
||||
let aliases = await aliasGenerator.generateAliases(for: term)
|
||||
generatingAliasEntryIDs.remove(entryID)
|
||||
guard !aliases.isEmpty else { return }
|
||||
dictionary.updateAliases(for: entryID, aliases: aliases)
|
||||
persist()
|
||||
dictionary = await dictionaryEntryService.finishSaving(saved)
|
||||
generatingAliasEntryIDs.remove(saved.entryID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,14 @@ struct PolishStylesView: View {
|
||||
|
||||
private let store = AppGroupStore()
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: Spacing.sm),
|
||||
GridItem(.flexible(), spacing: Spacing.sm)
|
||||
GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing),
|
||||
GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
CardPageContent(spacing: Spacing.xl) {
|
||||
CardPageContent {
|
||||
styleLearningCard
|
||||
packGridSection(
|
||||
title: "polishStyles.builtin.section",
|
||||
@@ -114,12 +114,12 @@ struct PolishStylesView: View {
|
||||
|
||||
return VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 42, height: 42)
|
||||
Image(systemName: "brain")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
palette.accentMuted,
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
|
||||
@@ -191,13 +191,6 @@ struct PolishStylesView: View {
|
||||
isActionAvailable ? palette.accent : palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(
|
||||
isActionAvailable ? Color.clear : palette.dividerStrong,
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -221,7 +214,7 @@ struct PolishStylesView: View {
|
||||
packs: [PolishStylePack]
|
||||
) -> some View {
|
||||
CardSection(title) {
|
||||
LazyVGrid(columns: columns, spacing: Spacing.sm) {
|
||||
LazyVGrid(columns: columns, spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
ForEach(packs) { pack in
|
||||
packCard(pack)
|
||||
}
|
||||
@@ -274,13 +267,6 @@ struct PolishStylesView: View {
|
||||
isSelected ? palette.accentMuted : palette.surface,
|
||||
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(
|
||||
isSelected ? palette.accent : palette.divider,
|
||||
lineWidth: isSelected ? 1.5 : 0.5
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.contextMenu {
|
||||
Button("polishStyles.duplicate") {
|
||||
|
||||
@@ -18,7 +18,6 @@ struct PrivacyPolicyView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.privacy.policy")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
|
||||
private var privacyScrollAnchor: String? {
|
||||
|
||||
@@ -30,28 +30,43 @@ struct ProviderPickerSection: View {
|
||||
var body: some View {
|
||||
// 只有右侧芯片是 Menu 的 label;标题留在行外,避免菜单弹出时
|
||||
// 把整行 label 一起隐藏,导致左侧「供应商」文字消失。
|
||||
SettingsProviderRow(title: AppL10n.string("settings.provider.supplier")) {
|
||||
HStack(spacing: Spacing.lg) {
|
||||
Text(AppL10n.string("settings.provider.supplier"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
|
||||
Spacer(minLength: Spacing.sm)
|
||||
|
||||
Menu {
|
||||
ForEach(visiblePresets, id: \.id) { provider in
|
||||
Button {
|
||||
select(provider)
|
||||
} label: {
|
||||
let name = ProviderDisplayName.name(for: provider.id)
|
||||
if provider.id == selectedProviderId {
|
||||
Label(name, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(name)
|
||||
}
|
||||
Picker("", selection: providerSelection) {
|
||||
ForEach(visiblePresets, id: \.id) { provider in
|
||||
Text(ProviderDisplayName.name(for: provider.id))
|
||||
.tag(provider.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
} label: {
|
||||
providerChip
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.settingsListRow()
|
||||
.surfaceCard(enabled: showsSurface)
|
||||
}
|
||||
|
||||
private var providerSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { selectedProviderId },
|
||||
set: { providerID in
|
||||
guard let provider = visiblePresets.first(where: { $0.id == providerID }) else {
|
||||
return
|
||||
}
|
||||
select(provider)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func select(_ provider: LLMProvider) {
|
||||
withAnimation(Motion.quick) {
|
||||
switch role {
|
||||
@@ -63,14 +78,13 @@ struct ProviderPickerSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 收起状态展示:当前所选供应商 logo + 名称 + 展开箭头。
|
||||
/// 收起状态展示:当前所选供应商名称、能力标记与展开箭头。
|
||||
private var providerChip: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
providerMark(selectedProvider)
|
||||
|
||||
Text(ProviderDisplayName.name(for: selectedProvider.id))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
if selectedProvider.supportsPersonalDictionaryCloudASR {
|
||||
personalDictionaryBadge
|
||||
@@ -79,36 +93,14 @@ struct ProviderPickerSection: View {
|
||||
streamingBadge
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func providerMark(_ provider: LLMProvider) -> some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(palette.accentMuted)
|
||||
.frame(width: 32, height: 32)
|
||||
if let asset = ProviderLogo.assetName(for: provider.id) {
|
||||
Image(asset)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundStyle(palette.accent)
|
||||
} else {
|
||||
Text(String(provider.name.prefix(1)))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 通义千问 / 智谱 GLM 等支持云端 ASR 热词 API 的提供商。
|
||||
private var personalDictionaryBadge: some View {
|
||||
Text("settings.provider.personalDictionaryBadge")
|
||||
@@ -117,6 +109,7 @@ struct ProviderPickerSection: View {
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 4)
|
||||
.background(palette.accentMuted, in: Capsule())
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
/// Bailian / Volcengine / OpenAI Realtime — utterance-level true streaming.
|
||||
@@ -127,5 +120,6 @@ struct ProviderPickerSection: View {
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 4)
|
||||
.background(palette.accentMuted, in: Capsule())
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ struct SettingsICloudSyncRow: View {
|
||||
private let store = AppGroupStore()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
VStack(spacing: 0) {
|
||||
Toggle(isOn: toggleBinding) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.appSettings.iCloudSync.title")
|
||||
@@ -35,43 +35,50 @@ struct SettingsICloudSyncRow: View {
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.disabled(isApplyingToggle)
|
||||
.settingsListRow(alignment: .leading)
|
||||
|
||||
if isEnabled {
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button {
|
||||
syncNow()
|
||||
} label: {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text(syncButtonTitleKey)
|
||||
.font(TypeStyle.caption)
|
||||
Spacer(minLength: 0)
|
||||
if isSyncingNow {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
} else if showSyncedConfirmation {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(syncButtonTitleKey)
|
||||
.font(TypeStyle.body)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
if isSyncingNow {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(
|
||||
systemName: showSyncedConfirmation
|
||||
? "checkmark"
|
||||
: "arrow.triangle.2.circlepath"
|
||||
)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
}
|
||||
|
||||
if let syncErrorMessage {
|
||||
Text(syncErrorMessage)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.danger)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
// Fill the row so the whole strip is tappable, not just
|
||||
// the caption glyphs (previously easy to miss).
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.settingsListRow(alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(palette.accent)
|
||||
.disabled(isSyncingNow || isApplyingToggle)
|
||||
.padding(.top, Spacing.xxs)
|
||||
}
|
||||
|
||||
if let syncErrorMessage {
|
||||
Text(syncErrorMessage)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, Spacing.xxs)
|
||||
}
|
||||
}
|
||||
.settingsListRow(alignment: .leading)
|
||||
.onAppear { reloadFromStore() }
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)
|
||||
|
||||
@@ -180,17 +180,13 @@ struct DefaultInputModePickerRow: View {
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Menu {
|
||||
ForEach(options, id: \.id) { option in
|
||||
Button {
|
||||
selection = DefaultInputMode(rawValue: option.id) ?? .voice
|
||||
} label: {
|
||||
if option.id == selection.rawValue {
|
||||
Label(option.label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(option.label)
|
||||
}
|
||||
Picker("", selection: defaultInputModeSelection) {
|
||||
ForEach(options, id: \.id) { option in
|
||||
Text(option.label)
|
||||
.tag(option.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(currentLabel)
|
||||
@@ -205,6 +201,13 @@ struct DefaultInputModePickerRow: View {
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
private var defaultInputModeSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { selection.rawValue },
|
||||
set: { selection = DefaultInputMode(rawValue: $0) ?? .voice }
|
||||
)
|
||||
}
|
||||
|
||||
private var currentLabel: String {
|
||||
options.first(where: { $0.id == selection.rawValue })?.label ?? "—"
|
||||
}
|
||||
@@ -248,17 +251,13 @@ struct SettingsMenuPickerRow: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
Menu {
|
||||
ForEach(options, id: \.id) { o in
|
||||
Button {
|
||||
selection = o.id
|
||||
} label: {
|
||||
if o.id == selection {
|
||||
Label(o.label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(o.label)
|
||||
}
|
||||
Picker("", selection: $selection) {
|
||||
ForEach(options, id: \.id) { option in
|
||||
Text(option.label)
|
||||
.tag(option.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(currentLabel)
|
||||
@@ -294,22 +293,13 @@ struct LocalePickerRow: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
Menu {
|
||||
ForEach(locales, id: \.id) { locale in
|
||||
Button {
|
||||
selection = locale.id
|
||||
} label: {
|
||||
// iOS Menu converts SwiftUI Label to UIAction (title + image).
|
||||
// Using Label keeps checkmark + on-device icon both visible.
|
||||
let name = label(for: locale.id)
|
||||
if locale.id == selection {
|
||||
Label(name, systemImage: "checkmark")
|
||||
} else if locale.onDevice {
|
||||
Label(name, systemImage: "iphone")
|
||||
} else {
|
||||
Text(name)
|
||||
}
|
||||
Picker("", selection: $selection) {
|
||||
ForEach(locales, id: \.id) { locale in
|
||||
Text(label(for: locale.id))
|
||||
.tag(locale.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
// On-device badge for the currently selected locale.
|
||||
|
||||
@@ -106,10 +106,6 @@ struct SettingsCredentialRow: View {
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(label)
|
||||
@@ -184,20 +180,13 @@ struct SettingsModelPickerRow: View {
|
||||
Button(AppL10n.string("settings.provider.modelsEmptyHint")) {}
|
||||
.disabled(true)
|
||||
} else {
|
||||
ForEach(models, id: \.self) { modelId in
|
||||
Button {
|
||||
invalidateRequest()
|
||||
model = modelId
|
||||
message = AppL10n.format("settings.provider.modelSelected", modelId)
|
||||
failed = false
|
||||
} label: {
|
||||
if modelId == model {
|
||||
Label(modelId, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(modelId)
|
||||
}
|
||||
Picker("", selection: modelSelection) {
|
||||
ForEach(models, id: \.self) { modelID in
|
||||
Text(modelID)
|
||||
.tag(modelID)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
@@ -211,6 +200,18 @@ struct SettingsModelPickerRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var modelSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { model },
|
||||
set: { modelID in
|
||||
invalidateRequest()
|
||||
model = modelID
|
||||
message = AppL10n.format("settings.provider.modelSelected", modelID)
|
||||
failed = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var refreshButton: some View {
|
||||
Button {
|
||||
runFetchModels()
|
||||
@@ -227,10 +228,6 @@ struct SettingsModelPickerRow: View {
|
||||
}
|
||||
.frame(width: controlHeight, height: controlHeight)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isRunning)
|
||||
@@ -345,10 +342,9 @@ struct SettingsProviderToolsRow: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: 34)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
.background(
|
||||
palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -146,18 +146,13 @@ struct AppleASRPickerRow: View {
|
||||
|
||||
var body: some View {
|
||||
Toggle(isOn: usesAppleRecognition) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "apple.logo")
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.asr.apple.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.asr.apple.subtitle")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.asr.apple.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.asr.apple.subtitle")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
@@ -213,7 +208,6 @@ struct SpeechRecognitionSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.speechRecognition.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +232,6 @@ struct TextPolishSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.textPolish.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +315,6 @@ struct GeneralSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.general.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +338,6 @@ struct AIAgentSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(AppL10n.string("settings.aiAgent.title", language: config.uiLanguage))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,34 +359,34 @@ struct ClipboardSettingsView: View {
|
||||
CardSection("settings.clipboard.section") {
|
||||
VStack(spacing: 0) {
|
||||
Toggle(isOn: $config.clipboardHistoryEnabled) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.clipboard.history.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.clipboard.history.footer")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.settingsListRow()
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Toggle(isOn: clipboardCandidateBinding) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.clipboard.candidate.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.clipboard.candidate.footer")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.disabled(!config.clipboardHistoryEnabled)
|
||||
.opacity(config.clipboardHistoryEnabled ? 1 : 0.45)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.settingsListRow()
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
@@ -406,7 +397,7 @@ struct ClipboardSettingsView: View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: Spacing.sm) {
|
||||
Text("settings.clipboard.paste.body")
|
||||
.font(.footnote)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if pasteAccessVerified {
|
||||
@@ -420,8 +411,7 @@ struct ClipboardSettingsView: View {
|
||||
.accessibilityIdentifier("settings.clipboard.paste.verified")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.settingsListRow()
|
||||
|
||||
if !pasteAccessVerified {
|
||||
Divider().background(palette.divider)
|
||||
@@ -463,24 +453,25 @@ struct ClipboardSettingsView: View {
|
||||
CardSection("settings.clipboard.storage.section") {
|
||||
VStack(spacing: 0) {
|
||||
Text("settings.clipboard.storage.body")
|
||||
.font(.footnote)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.settingsListRow()
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button(role: .destructive) {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
HStack {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text("settings.clipboard.clear.button")
|
||||
Spacer()
|
||||
.font(TypeStyle.body)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.foregroundStyle(palette.danger)
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -494,7 +485,6 @@ struct ClipboardSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(AppL10n.string("settings.clipboard.title", language: config.uiLanguage))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.confirmationDialog(
|
||||
"settings.clipboard.clear.title",
|
||||
isPresented: $showClearConfirmation,
|
||||
@@ -574,12 +564,15 @@ struct AboutSettingsView: View {
|
||||
CardSection("settings.analytics.section") {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Toggle(
|
||||
"settings.analytics.title",
|
||||
isOn: Binding(
|
||||
get: { analytics.isEnabled },
|
||||
set: { analytics.setEnabled($0) }
|
||||
)
|
||||
)
|
||||
) {
|
||||
Text("settings.analytics.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.settingsListRow()
|
||||
|
||||
@@ -660,7 +653,6 @@ struct AboutSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.about.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.onAppear {
|
||||
analytics.refreshEnabledState()
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ enum SettingsPresentation {
|
||||
|
||||
/// Routes pushed from Settings home. Value-based navigation keeps
|
||||
/// destinations out of the root view tree until push — important so
|
||||
/// `hidesTabBarWhenPushed()` preferences do not leak onto the home
|
||||
/// screen (and so we avoid NavigationLink + `dismiss` freeze cycles).
|
||||
/// destination toolbar preferences do not leak onto the home screen
|
||||
/// (and so we avoid NavigationLink + `dismiss` freeze cycles).
|
||||
private enum SettingsRoute: Hashable {
|
||||
case account
|
||||
case speechRecognition
|
||||
@@ -41,50 +41,42 @@ struct SettingsView: View {
|
||||
|
||||
// Dynamic locale list loaded from SFSpeechRecognizer on first appear.
|
||||
@State private var dynamicLocales: [(id: String, onDevice: Bool)] = []
|
||||
@State private var showResetConfirmation = false
|
||||
@State private var path = NavigationPath()
|
||||
@State private var settingsScrollTarget: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
ZStack {
|
||||
palette.background.ignoresSafeArea()
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
accountEntrySection
|
||||
dailySection
|
||||
transcriptionAndPolishSection
|
||||
if presentation == .tab {
|
||||
moreEntriesSection
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
accountEntrySection
|
||||
dailySection
|
||||
transcriptionAndPolishSection
|
||||
.id(SettingsDeepLink.aiService.rawValue)
|
||||
if presentation == .tab {
|
||||
moreEntriesSection
|
||||
}
|
||||
}
|
||||
.modifier(SettingsScrollBottomPadding(presentation: presentation))
|
||||
}
|
||||
.onChange(of: settingsScrollTarget) { _, target in
|
||||
guard target == SettingsDeepLink.aiService.rawValue else { return }
|
||||
Task { @MainActor in
|
||||
await Task.yield()
|
||||
withAnimation(Motion.soft) {
|
||||
proxy.scrollTo(SettingsDeepLink.aiService.rawValue, anchor: .top)
|
||||
}
|
||||
settingsScrollTarget = nil
|
||||
}
|
||||
}
|
||||
.modifier(SettingsScrollBottomPadding(presentation: presentation))
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
.navigationTitle("settings.title")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showResetConfirmation = true
|
||||
} label: {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
}
|
||||
.accessibilityLabel("settings.reset.confirm")
|
||||
.confirmationDialog(
|
||||
"settings.reset.title",
|
||||
isPresented: $showResetConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("common.reset", role: .destructive) {
|
||||
config.reset()
|
||||
SpeechHistoryStore.shared.clearAll()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.reset.message")
|
||||
}
|
||||
}
|
||||
if presentation == .sheet {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
// Keep `dismiss` off the Settings root — pairing it
|
||||
@@ -98,25 +90,35 @@ struct SettingsView: View {
|
||||
}
|
||||
.task { await loadDynamicLocales() }
|
||||
.onAppear {
|
||||
consumeClipboardDeepLinkIfNeeded()
|
||||
consumeSettingsDeepLinkIfNeeded()
|
||||
consumeAccountDeepLinkIfNeeded()
|
||||
Task { await accountSession.refreshAccountData() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
|
||||
consumeClipboardDeepLinkIfNeeded()
|
||||
consumeSettingsDeepLinkIfNeeded()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenAccountDeepLink)) { _ in
|
||||
consumeAccountDeepLinkIfNeeded()
|
||||
}
|
||||
}
|
||||
.navigationStackTabBarVisibility(isRoot: path.isEmpty)
|
||||
}
|
||||
|
||||
private func consumeClipboardDeepLinkIfNeeded() {
|
||||
guard SettingsDeepLink.consumePending() == .clipboard else { return }
|
||||
private func consumeSettingsDeepLinkIfNeeded() {
|
||||
guard let link = SettingsDeepLink.consumePending() else { return }
|
||||
if !path.isEmpty {
|
||||
path = NavigationPath()
|
||||
}
|
||||
path.append(SettingsRoute.clipboard)
|
||||
switch link {
|
||||
case .aiService:
|
||||
settingsScrollTarget = SettingsDeepLink.aiService.rawValue
|
||||
case .speechRecognition:
|
||||
path.append(SettingsRoute.speechRecognition)
|
||||
case .textPolish:
|
||||
path.append(SettingsRoute.textPolish)
|
||||
case .clipboard:
|
||||
path.append(SettingsRoute.clipboard)
|
||||
}
|
||||
}
|
||||
|
||||
private func consumeAccountDeepLinkIfNeeded() {
|
||||
@@ -151,188 +153,76 @@ struct SettingsView: View {
|
||||
|
||||
private var accountEntrySection: some View {
|
||||
CardSection("account.settings.section") {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Group {
|
||||
switch accountSession.sessionPhase {
|
||||
case .restoring:
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Text("account.settings.restoring")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.settingsListRow()
|
||||
case .signedOut:
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("account.signedOut.title")
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.signedOut.body")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||
.disabled(accountSession.operation != nil)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
case let .signedIn(session):
|
||||
Button {
|
||||
path.append(SettingsRoute.account)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.md) {
|
||||
AccountAvatarView(
|
||||
accountID: session.accountID,
|
||||
displayName: session.displayName,
|
||||
size: 44
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(
|
||||
session.displayName
|
||||
?? AppL10n.string(
|
||||
"account.profile.fallbackName",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(accountSettingsSubtitle)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel(
|
||||
Text(
|
||||
"\(Text("account.settings.signedIn")) \(session.accountID.uuidString)"
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Group {
|
||||
switch accountSession.sessionPhase {
|
||||
case .restoring:
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Text("account.settings.restoring")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
.settingsListRow()
|
||||
case .signedOut:
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("account.signedOut.title")
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.signedOut.body")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||
.disabled(accountSession.operation != nil)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
case let .signedIn(session):
|
||||
Button {
|
||||
path.append(SettingsRoute.account)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.md) {
|
||||
AccountAvatarView(
|
||||
accountID: session.accountID,
|
||||
displayName: session.displayName,
|
||||
size: 44
|
||||
)
|
||||
|
||||
if accountSession.isSignedIn {
|
||||
accountRewardsEntrySection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var accountRewardsEntrySection: some View {
|
||||
VStack(spacing: 0) {
|
||||
switch accountSession.snapshotPhase {
|
||||
case let .loaded(snapshot):
|
||||
let totalCredits = creditTotal(snapshot)
|
||||
Button {
|
||||
path.append(SettingsRoute.account)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(creditRemainingText(snapshot.credits.balance))
|
||||
.font(TypeStyle.title3.monospacedDigit())
|
||||
Text(
|
||||
session.displayName
|
||||
?? AppL10n.string(
|
||||
"account.profile.fallbackName",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.credits.balance")
|
||||
Text(accountSettingsSubtitle)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
VStack(alignment: .trailing, spacing: Spacing.xxs) {
|
||||
Text(creditUsedText(snapshot.credits.usedCredits))
|
||||
Text(creditTotalText(totalCredits))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(minWidth: 32, minHeight: 32)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
AccountCreditProgress(
|
||||
remaining: snapshot.credits.balance,
|
||||
used: snapshot.credits.usedCredits,
|
||||
showsLabels: false
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel(
|
||||
Text(
|
||||
"\(Text("account.settings.signedIn")) \(session.accountID.uuidString)"
|
||||
)
|
||||
)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(Spacing.md)
|
||||
case .failed:
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text("account.error.load")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Button("account.retry") {
|
||||
Task { await accountSession.refreshAccountData(force: true) }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
case .idle, .loading:
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Text("account.loading.center")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
Divider().background(palette.divider)
|
||||
AccountReferralLinkView(viewModel: accountSession.referralProfile)
|
||||
.surfaceCard()
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
private func creditTotal(_ snapshot: AccountCenterSnapshot) -> Int64 {
|
||||
let remaining = max(snapshot.credits.balance, 0)
|
||||
let used = max(snapshot.credits.usedCredits, 0)
|
||||
let (total, overflow) = remaining.addingReportingOverflow(used)
|
||||
return overflow ? Int64.max : total
|
||||
}
|
||||
|
||||
private func creditRemainingText(_ remaining: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.credits.remainingCompact",
|
||||
language: config.uiLanguage,
|
||||
max(remaining, 0).formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
private func creditUsedText(_ used: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.credits.usedCompact",
|
||||
language: config.uiLanguage,
|
||||
max(used, 0).formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
private func creditTotalText(_ total: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.credits.totalValueCompact",
|
||||
language: config.uiLanguage,
|
||||
total.formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
|
||||
private var accountSettingsSubtitle: String {
|
||||
@@ -462,81 +352,6 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountReferralLinkView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject var viewModel: ReferralProfileViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch viewModel.state {
|
||||
case .idle:
|
||||
retryRow(
|
||||
messageKey: "account.referral.loadLink",
|
||||
actionKey: "account.referral.loadLink"
|
||||
)
|
||||
case .loading:
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(palette.accent)
|
||||
Text("account.referral.loadingLink")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
case .failed(let messageKey):
|
||||
retryRow(messageKey: messageKey, actionKey: "account.retry")
|
||||
case .loaded(let profile):
|
||||
loadedContent(profile)
|
||||
}
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityIdentifier("account.referral.profile")
|
||||
}
|
||||
|
||||
private func loadedContent(_ profile: ReferralProfile) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Text("account.referral.equalRewardDescription")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
AccountInvitationButton(invitationURL: profile.code.inviteURL)
|
||||
}
|
||||
|
||||
if viewModel.isRefreshing {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
Text("account.referral.refreshingLink")
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
} else if let refreshErrorKey = viewModel.refreshErrorKey {
|
||||
retryRow(messageKey: refreshErrorKey, actionKey: "account.retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func retryRow(
|
||||
messageKey: String,
|
||||
actionKey: LocalizedStringKey
|
||||
) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(LocalizedStringKey(messageKey))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Button(actionKey) {
|
||||
Task { await viewModel.refresh() }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sheet dismiss (isolated from Settings root)
|
||||
|
||||
private struct SettingsSheetDismissButton: View {
|
||||
|
||||
@@ -38,17 +38,13 @@ struct TranslationPickerRow: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
Menu {
|
||||
ForEach(TranslationLanguageCatalog.all) { language in
|
||||
Button {
|
||||
apply(language)
|
||||
} label: {
|
||||
if currentSelectionId == language.id {
|
||||
Label(displayLabel(for: language), systemImage: "checkmark")
|
||||
} else {
|
||||
Text(displayLabel(for: language))
|
||||
}
|
||||
Picker("", selection: translationSelection) {
|
||||
ForEach(TranslationLanguageCatalog.all) { language in
|
||||
Text(displayLabel(for: language))
|
||||
.tag(language.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(currentLabel)
|
||||
@@ -79,6 +75,15 @@ struct TranslationPickerRow: View {
|
||||
TranslationLanguageCatalog.isOff(currentSelectionId)
|
||||
}
|
||||
|
||||
private var translationSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { currentSelectionId },
|
||||
set: { selectionID in
|
||||
apply(TranslationLanguageCatalog.resolve(selectionID))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func displayLabel(for language: TranslationLanguage) -> String {
|
||||
if language.id == TranslationLanguageCatalog.offLocaleId {
|
||||
return AppL10n.string("settings.translation.off")
|
||||
|
||||
@@ -18,23 +18,28 @@ struct TypingInputSettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(AppL10n.string("settings.typingInput.schema.section", language: config.uiLanguage)) {
|
||||
Section {
|
||||
Picker(
|
||||
AppL10n.string("settings.typingInput.schema.picker", language: config.uiLanguage),
|
||||
selection: $configuration.schema
|
||||
) {
|
||||
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(
|
||||
pair.displayName,
|
||||
isOn: Binding(
|
||||
get: { configuration.fuzzyPairs.contains(pair) },
|
||||
set: { enabled in
|
||||
@@ -42,23 +47,30 @@ struct TypingInputSettingsView: View {
|
||||
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(AppL10n.string("settings.typingInput.resources.section", language: config.uiLanguage)) {
|
||||
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
|
||||
)
|
||||
@@ -68,19 +80,26 @@ struct TypingInputSettingsView: View {
|
||||
Button(AppL10n.string("settings.typingInput.resources.redeploy", language: config.uiLanguage)) {
|
||||
deployUpdatedSchemas()
|
||||
}
|
||||
.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
|
||||
}
|
||||
.font(TypeStyle.body)
|
||||
.disabled(isDeploying)
|
||||
.foregroundStyle(palette.danger)
|
||||
} footer: {
|
||||
Text(AppL10n.string("settings.typingInput.habits.footer", language: config.uiLanguage))
|
||||
.font(TypeStyle.caption2)
|
||||
}
|
||||
}
|
||||
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.navigationTitle(AppL10n.string("settings.typingInput.title", language: config.uiLanguage))
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"settings.keyboardHaptic.off" = "Off";
|
||||
"settings.keyboardHaptic.light" = "Light";
|
||||
"settings.keyboardHaptic.strong" = "Strong";
|
||||
"settings.cursorDragNavigation.title" = "Drag empty area to move cursor";
|
||||
"settings.systemPrompt.reset" = "Reset";
|
||||
"settings.asrLocale" = "ASR locale";
|
||||
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device";
|
||||
@@ -495,8 +494,11 @@
|
||||
"home.setup.permission.speech" = "Speech recognition is off — voice input won't work.";
|
||||
"home.setup.permission.both" = "Microphone and speech recognition are off — voice input won't work.";
|
||||
"home.setup.permission.request" = "Next";
|
||||
"home.setup.cloudIncomplete" = "Cloud engine needs an API key. Open the Settings tab.";
|
||||
"home.setup.polishKeyMissing" = "Add an API key in Settings to enable AI polish. Without it, raw ASR text is inserted.";
|
||||
"home.setup.cloudIncomplete" = "Cloud recognition and AI polish need API keys.";
|
||||
"home.setup.polishKeyMissing" = "Add an API key to enable AI polish. Without it, local dictation still inserts raw recognized text.";
|
||||
"home.setup.byok.configure" = "Configure API Key";
|
||||
"home.setup.credits.alternative" = "Or use OSG credits for transcription and AI without managing API keys.";
|
||||
"home.setup.credits.open" = "Use Credits";
|
||||
"home.setup.keyboardHint" = "Don't see OSGKeyboard? Add it in iOS Settings.";
|
||||
"home.setup.keyboardHint.dismiss" = "Got it";
|
||||
"home.flow.starting" = "Starting voice session…";
|
||||
@@ -514,11 +516,12 @@
|
||||
"home.preview.placeholder" = "Tap to type and test…";
|
||||
"home.card.history.empty" = "No voice transcripts yet";
|
||||
"home.card.dictionary.empty" = "No words yet";
|
||||
"home.card.dictionary.smart.subtitle" = "Frequently used new terms discovered from your Rime typing";
|
||||
"home.card.dictionary.smart.empty" = "No suggestions yet";
|
||||
"home.card.dictionary.smart.subtitle" = "Frequently used new terms discovered from your typing";
|
||||
"home.card.dictionary.smart.empty" = "No recommended terms yet";
|
||||
"home.card.dictionary.smart.addHint" = "Tap to add frequently used terms to your personal dictionary";
|
||||
"home.card.dictionary.smart.accessibilityHint" = "Used %lld times. Double-tap to add it to your personal dictionary.";
|
||||
"home.card.dictionary.confirm.title" = "Add “%@” to your personal dictionary?";
|
||||
"home.card.dictionary.confirm.message" = "Rime has recorded this term %lld times. Once added, it will also improve speech recognition and AI polishing.";
|
||||
"home.card.dictionary.confirm.message" = "You’ve used this term %lld times. Once added, it will also improve speech recognition and AI polishing.";
|
||||
"home.card.dictionary.confirm.add" = "Add to dictionary";
|
||||
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
|
||||
"home.wide.mode.cloud" = "Cloud";
|
||||
@@ -562,11 +565,11 @@
|
||||
"skills.action.install" = "Install";
|
||||
"skills.action.uninstall" = "Uninstall";
|
||||
"skills.action.reinstallShortcut" = "Reinstall Shortcut";
|
||||
"skills.reply.name" = "Reply in source language";
|
||||
"skills.reply.name" = "Reply";
|
||||
"skills.reply.description" = "Understand the copied text and reply naturally in its primary language.";
|
||||
"skills.playfulReply.name" = "Playful reply";
|
||||
"skills.playfulReply.description" = "Reply with a short, witty joke that stays kind and appropriate.";
|
||||
"skills.replyInSourceLanguage.name" = "Reply in source language";
|
||||
"skills.replyInSourceLanguage.name" = "Reply";
|
||||
"skills.replyInSourceLanguage.description" = "Understand the copied text and reply naturally in the same language.";
|
||||
"skills.summarize.name" = "Summarize key points";
|
||||
"skills.summarize.description" = "Summarize the content and extract facts, decisions, conclusions, and next steps when present.";
|
||||
@@ -660,7 +663,7 @@
|
||||
"polishStyles.learn.generating" = "Generating…";
|
||||
"polishStyles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
|
||||
"polishStyles.learn.limit" = "Delete a custom style before generating another one.";
|
||||
"polishStyles.learn.error.insufficient" = "Keep dictating until 5,000 effective characters are available.";
|
||||
"polishStyles.learn.error.insufficient" = "Keep dictating until 2,500 effective characters are available.";
|
||||
"polishStyles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again.";
|
||||
"polishStyles.learn.error.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again.";
|
||||
"polishStyles.learn.error.requestTooLarge" = "The learning sample is too large for the configured AI service. Shorten unusually long history entries and try again.";
|
||||
@@ -724,7 +727,6 @@
|
||||
"settings.personalDictionary.empty.title" = "No words yet";
|
||||
"settings.personalDictionary.empty.body" = "Add your personal dictionary to improve recognition accuracy\nSyncs across devices via iCloud";
|
||||
"settings.personalDictionary.search.prompt" = "Search words";
|
||||
"settings.personalDictionary.usageCount" = "%lld uses";
|
||||
"settings.personalDictionary.add.title" = "Add word";
|
||||
"settings.personalDictionary.edit.title" = "Edit word";
|
||||
"settings.personalDictionary.add.field" = "Word";
|
||||
@@ -830,6 +832,8 @@
|
||||
"account.signedOut.title" = "Your OSG account";
|
||||
"account.signedOut.body" = "Sign in to use managed credits and referrals.";
|
||||
"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.";
|
||||
"account.signIn.apple" = "Sign in with Apple";
|
||||
"account.signIn.hint" = "Creates or opens your optional OSG account.";
|
||||
"account.signIn.loading" = "Signing in…";
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"settings.keyboardHaptic.off" = "关";
|
||||
"settings.keyboardHaptic.light" = "轻";
|
||||
"settings.keyboardHaptic.strong" = "强";
|
||||
"settings.cursorDragNavigation.title" = "触摸空白区域移动光标";
|
||||
"settings.systemPrompt.reset" = "重置";
|
||||
"settings.asrLocale" = "识别语言";
|
||||
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
|
||||
@@ -494,8 +493,11 @@
|
||||
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
|
||||
"home.setup.permission.both" = "麦克风和语音识别还没授权,语音输入用不了。";
|
||||
"home.setup.permission.request" = "下一步";
|
||||
"home.setup.cloudIncomplete" = "选了云端引擎,先去「设置」填 API Key。";
|
||||
"home.setup.polishKeyMissing" = "请先在「设置」填写 API Key 才能润色;未填写时会直接插入原始识别结果。";
|
||||
"home.setup.cloudIncomplete" = "云端识别和 AI 润色需要配置 API Key。";
|
||||
"home.setup.polishKeyMissing" = "配置 API Key 后可启用 AI 润色;未配置时,本地听写仍会插入原始识别文本。";
|
||||
"home.setup.byok.configure" = "配置 API Key";
|
||||
"home.setup.credits.alternative" = "也可以使用 OSG 积分完成语音转写和 AI,无需管理 API Key。";
|
||||
"home.setup.credits.open" = "使用积分";
|
||||
"home.setup.keyboardHint" = "列表里没有?去系统设置里添加键盘。";
|
||||
"home.setup.keyboardHint.dismiss" = "知道了";
|
||||
"home.flow.starting" = "正在启动语音会话…";
|
||||
@@ -513,11 +515,12 @@
|
||||
"home.preview.placeholder" = "点这里试试键盘";
|
||||
"home.card.history.empty" = "还没有语音识别记录";
|
||||
"home.card.dictionary.empty" = "还没有词条";
|
||||
"home.card.dictionary.smart.subtitle" = "从 Rime 输入习惯中发现的高频新词";
|
||||
"home.card.dictionary.smart.empty" = "暂无推荐词";
|
||||
"home.card.dictionary.smart.subtitle" = "从中英文输入习惯中发现的高频新词";
|
||||
"home.card.dictionary.smart.empty" = "暂无推荐热词";
|
||||
"home.card.dictionary.smart.addHint" = "点击添加常用词到个性词库";
|
||||
"home.card.dictionary.smart.accessibilityHint" = "已使用 %lld 次,轻点两次加入个性词库。";
|
||||
"home.card.dictionary.confirm.title" = "将“%@”加入个性词库?";
|
||||
"home.card.dictionary.confirm.message" = "Rime 已记录这个词 %lld 次。加入后也会用于提升语音识别和 AI 润色效果。";
|
||||
"home.card.dictionary.confirm.message" = "这个词已使用 %lld 次。加入后也会用于提升语音识别和 AI 润色效果。";
|
||||
"home.card.dictionary.confirm.add" = "加入词库";
|
||||
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
|
||||
"home.wide.mode.cloud" = "云端";
|
||||
@@ -561,11 +564,11 @@
|
||||
"skills.action.install" = "安装";
|
||||
"skills.action.uninstall" = "卸载";
|
||||
"skills.action.reinstallShortcut" = "重新安装捷径";
|
||||
"skills.reply.name" = "原语言回复";
|
||||
"skills.reply.name" = "回复";
|
||||
"skills.reply.description" = "理解复制内容,并使用原文的主要语言自然回复。";
|
||||
"skills.playfulReply.name" = "俏皮回复";
|
||||
"skills.playfulReply.description" = "用简短、有梗但不冒犯的方式接住这句话。";
|
||||
"skills.replyInSourceLanguage.name" = "用原语言回复";
|
||||
"skills.replyInSourceLanguage.name" = "回复";
|
||||
"skills.replyInSourceLanguage.description" = "理解复制内容,并使用相同语言自然回复。";
|
||||
"skills.summarize.name" = "总结要点";
|
||||
"skills.summarize.description" = "总结核心内容,并按需提炼事实、决定、结论和下一步。";
|
||||
@@ -659,7 +662,7 @@
|
||||
"polishStyles.learn.generating" = "生成中…";
|
||||
"polishStyles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
|
||||
"polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
|
||||
"polishStyles.learn.error.insufficient" = "请继续听写,累积到 5,000 个有效字符后再生成。";
|
||||
"polishStyles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。";
|
||||
"polishStyles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
|
||||
"polishStyles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。";
|
||||
"polishStyles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。";
|
||||
@@ -723,7 +726,6 @@
|
||||
"settings.personalDictionary.empty.title" = "还没有词条";
|
||||
"settings.personalDictionary.empty.body" = "添加个性化词库,提升识别准确性\n词库将通过 iCloud 实现跨设备同步";
|
||||
"settings.personalDictionary.search.prompt" = "搜索词条";
|
||||
"settings.personalDictionary.usageCount" = "使用 %lld 次";
|
||||
"settings.personalDictionary.add.title" = "添加词条";
|
||||
"settings.personalDictionary.edit.title" = "编辑词条";
|
||||
"settings.personalDictionary.add.field" = "词条";
|
||||
@@ -829,6 +831,8 @@
|
||||
"account.signedOut.title" = "OSG 账号";
|
||||
"account.signedOut.body" = "登录后可使用托管积分与邀请功能。";
|
||||
"account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。";
|
||||
"home.accountRewards.signedOut.title" = "登录领免费积分";
|
||||
"home.accountRewards.signedOut.summary" = "符合资格的新用户可领 1,000 积分;邀请好友完成首次有效使用,双方各得 1,000 积分。";
|
||||
"account.signIn.apple" = "通过 Apple 登录";
|
||||
"account.signIn.hint" = "创建或打开可选的 OSG 账号。";
|
||||
"account.signIn.loading" = "正在登录…";
|
||||
|
||||
Reference in New Issue
Block a user