chore(release): bump version to 2.1.6 (build 131)

Version: CURRENT_PROJECT_VERSION 130 -> 131 (project.yml, AGENTS.md, READMEs,
docs/llms.txt).

fix(flow): host-app identification now compiles in every build configuration
(Debug, TestFlight, Release). The arbiter probe and HostAppIdentity were
DEBUG-only, so TestFlight/App Store builds never knew the host bundle id and
could not auto-return after a startflow handoff. Debug-only logging and the
Home test card are unchanged.

Also includes the pending work in the tree:
- Home: single account/setup card surface (HomeAccountSurfacePolicy/Views)
- Wide screens: voice key row and centred mic placement, iPhone Duo layout
- Keyboard: clipboard skill ranking and AI skill layout updates
- Debug: account session handoff to the Simulator (+ import script)
- Docs: portfolio assets, privacy policy URL, site/README refresh
This commit is contained in:
Rocky
2026-10-29 11:54:13 +08:00
parent 055a40976f
commit 4dce67df84
65 changed files with 3682 additions and 787 deletions
@@ -46,6 +46,13 @@ public enum KeyboardChromeLayout {
/// keyboard), which is why it no longer shares this constant.
public static let voiceContentMaxWidth: CGFloat = 700
/// Hosts that draw the keyboard-style voice key row span the typing width:
/// the row, top bar and logo must sit on the same frame as the typing keys,
/// or they jump sideways on every tab switch.
public static func voiceContentWidthCap(usesVoiceKeyRow: Bool) -> CGFloat {
usesVoiceKeyRow ? .infinity : voiceContentMaxWidth
}
/// Width at or above which the typing surface switches to wide-iPad
/// metrics (taller rows). Chosen to sit between the widest iPad portrait
/// width (1024 pt on 13") and the narrowest landscape width (1133 pt on
@@ -60,11 +67,54 @@ public enum KeyboardChromeLayout {
isPad && hasRegularWidth
}
/// System-sized iPhone-shaped keys for a phone host with regular space on
/// both axes — the unfolded iPhone Duo inner display. Landscape Max-size
/// iPhones are regular × compact and keep the phone metrics.
public static func usesExpandedPhoneMetrics(
isPad: Bool,
hasRegularWidth: Bool,
hasRegularHeight: Bool
) -> Bool {
!isPad && hasRegularWidth && hasRegularHeight
}
/// Shorter phone panel whenever the host is vertically compact: every
/// iPhone in landscape, including the folded iPhone Duo. Compact-height
/// iPad windows keep their own metrics.
public static func usesCompactHeightPhoneMetrics(isPad: Bool, hasCompactHeight: Bool) -> Bool {
!isPad && hasCompactHeight
}
/// iPad always draws its own globe key. Elsewhere UIKit decides: Face ID
/// iPhones get a system switch below the keyboard, while the unfolded
/// iPhone Duo and Home-button iPhones need the keyboard to provide one.
public static func showsInputModeSwitchKey(
isPad: Bool,
systemRequiresSwitchKey: Bool
) -> Bool {
isPad || systemRequiresSwitchKey
}
/// Wide metrics need iPad-scale keys *and* enough width to justify them.
public static func usesWideIPadMetrics(isIPad: Bool, width: CGFloat) -> Bool {
isIPad && width >= wideIPadWidthThreshold
}
/// Below this width the voice microphone capsule shrinks (Display Zoom on
/// a small iPhone, iPad Slide Over). Every current iPhone is wider.
public static let compactVoiceWidthThreshold: CGFloat = 350
/// Below this width an iPad-metrics voice surface uses its smaller circles.
public static let compactIPadVoiceWidthThreshold: CGFloat = 420
/// `width == 0` means "not measured yet" and never counts as compact.
public static func usesCompactVoiceMetrics(width: CGFloat) -> Bool {
width > 0 && width < compactVoiceWidthThreshold
}
public static func usesCompactIPadVoiceMetrics(isIPad: Bool, width: CGFloat) -> Bool {
isIPad && width > 0 && width < compactIPadVoiceWidthThreshold
}
/// Splits the width left after three gaps into a 12 / 18 / 50 / 20 row for
/// layouts that include a globe key, with a wide centre and balanced sides.
public static func actionKeyWidths(availableWidth: CGFloat) -> (globe: CGFloat, side: CGFloat, center: CGFloat, side2: CGFloat) {
@@ -93,28 +143,6 @@ public enum KeyboardChromeLayout {
)
}
/// iPad voice bottom row `[globe · delete · return · space]`. The phone's
/// 12/18/50/20 split would hand the centre key ~577 pt once the surface
/// spans an iPad; these fractions keep every key in a usable range while
/// giving the primary return action a little more room.
public static let iPadVoiceGlobeFraction: CGFloat = 0.10
public static let iPadVoiceSideFraction: CGFloat = 0.24
public static let iPadVoiceCenterFraction: CGFloat = 0.40
public static let iPadVoiceSide2Fraction: CGFloat = 0.26
/// iPad variant of `actionKeyWidths` for the voice surface.
public static func iPadVoiceActionKeyWidths(
availableWidth: CGFloat
) -> (globe: CGFloat, side: CGFloat, center: CGFloat, side2: CGFloat) {
let keyWidth = max(0, availableWidth - actionKeySpacing * 3)
return (
globe: keyWidth * iPadVoiceGlobeFraction,
side: keyWidth * iPadVoiceSideFraction,
center: keyWidth * iPadVoiceCenterFraction,
side2: keyWidth * iPadVoiceSide2Fraction
)
}
/// Six-slot iPad variant of `actionKeyWidths`.
public static func iPadActionKeyWidths(
availableWidth: CGFloat
@@ -29,6 +29,27 @@ public enum TypingSurfaceMetrics {
public static let keyHorizontalSpacing: CGFloat = 6
public static let secondRowInset: CGFloat = 18
// MARK: - Expanded phone (unfolded iPhone Duo inner display)
/// The system keyboard on the inner display keeps the iPhone key layout at
/// ~38.5 pt rows inside a 263 pt panel, and the system adds ~23 pt of
/// chrome around a custom input view. These rows give a 260 pt shell —
/// still roomy for the voice surface's live transcript — so the panel
/// stays ~20 pt above the system one instead of ~40 pt.
public static let expandedPhoneKeyRowHeight: CGFloat = 41
public static let expandedPhoneKeyRowSpacing: CGFloat = 12
public static let expandedPhoneKeyHorizontalSpacing: CGFloat = 10
// MARK: - Compact-height phone (any iPhone in landscape, incl. Duo outer)
/// A landscape iPhone is only ~400–470 pt tall, so the 281 pt portrait
/// panel would take 60–70% of it. These rows give a 244 pt shell — enough
/// for the voice surface to centre its mic and still fit the 3-line live
/// transcript above it — instead of following the system keyboard down
/// to ~200 pt.
public static let compactHeightPhoneKeyRowHeight: CGFloat = 40
public static let compactHeightPhoneKeyRowSpacing: CGFloat = 8
// MARK: - iPad (narrow: portrait, or a compact-ish regular window)
public static let iPadKeyRowHeight: CGFloat = 54
@@ -47,8 +68,43 @@ public enum TypingSurfaceMetrics {
/// Key metrics for a device class and available width. Width — not device
/// orientation — picks the wide bucket, so a resized Stage Manager window
/// lands on the right metrics without consulting orientation at all.
public static func metrics(isIPad: Bool, width: CGFloat) -> TypingKeyLayoutBuilder.Metrics {
/// `isExpandedPhone` / `isCompactHeightPhone` are only meaningful when
/// `isIPad` is false, and never true together (regular vs compact height).
public static func metrics(
isIPad: Bool,
width: CGFloat,
isExpandedPhone: Bool = false,
isCompactHeightPhone: Bool = false
) -> TypingKeyLayoutBuilder.Metrics {
guard isIPad else {
if isCompactHeightPhone {
return TypingKeyLayoutBuilder.Metrics(
keyRowHeight: compactHeightPhoneKeyRowHeight,
keyRowSpacing: compactHeightPhoneKeyRowSpacing,
keyHorizontalSpacing: keyHorizontalSpacing,
// Ignored: landscape keys are ~70 pt wide and need the
// system's half-key indent, not the portrait 18 pt.
secondRowInset: 0,
bottomRowHeight: compactHeightPhoneKeyRowHeight,
bottomActionSpacing: KeyboardChromeLayout.actionKeySpacing,
gridToBottomSpacing: compactHeightPhoneKeyRowSpacing,
derivesSecondRowInsetFromKeyWidth: true
)
}
if isExpandedPhone {
return TypingKeyLayoutBuilder.Metrics(
keyRowHeight: expandedPhoneKeyRowHeight,
keyRowSpacing: expandedPhoneKeyRowSpacing,
keyHorizontalSpacing: expandedPhoneKeyHorizontalSpacing,
// Ignored: wide keys need the system's half-key indent.
secondRowInset: 0,
bottomRowHeight: expandedPhoneKeyRowHeight,
// Bottom-row widths are pre-split with this spacing.
bottomActionSpacing: KeyboardChromeLayout.actionKeySpacing,
gridToBottomSpacing: expandedPhoneKeyRowSpacing,
derivesSecondRowInsetFromKeyWidth: true
)
}
return TypingKeyLayoutBuilder.Metrics(
keyRowHeight: keyRowHeight,
keyRowSpacing: keyRowSpacing,
@@ -77,9 +133,19 @@ public enum TypingSurfaceMetrics {
/// Content-driven keyboard height. iPad grows its rows, so the shell has to
/// grow with them or the bottom row is clipped.
public static func contentHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
guard isIPad else { return KeyboardChromeLayout.totalHeight }
let m = metrics(isIPad: true, width: width)
public static func contentHeight(
isIPad: Bool,
width: CGFloat,
isExpandedPhone: Bool = false,
isCompactHeightPhone: Bool = false
) -> CGFloat {
guard isIPad || isExpandedPhone || isCompactHeightPhone else { return KeyboardChromeLayout.totalHeight }
let m = metrics(
isIPad: isIPad,
width: width,
isExpandedPhone: isExpandedPhone,
isCompactHeightPhone: isCompactHeightPhone
)
let inner = topRegionHeight
+ verticalKeySpacing
+ (3 * m.keyRowHeight + 2 * m.keyRowSpacing)
@@ -316,6 +316,14 @@ public enum AIClipboardSkillCatalog: Sendable {
/// Legacy ID consolidated into `summarizeID`.
public static let extractConclusionsID = "extractConclusions"
public static let translateID = "translate"
/// Translates text that is already in the device language into the user's
/// configured translation target language. The mirror of `translateID`
/// (which handles foreign text → device language); the keyboard offers one
/// or the other depending on the copy's language, never both.
public static let translateToTargetID = "translateToTarget"
/// Fixes grammar, improves wording, and polishes foreign-language text while
/// keeping it in its original language. Offered only for foreign copies.
public static let polishOriginalID = "polishOriginal"
public static let acceptInvitationID = "acceptInvitation"
public static let declineInvitationID = "declineInvitation"
public static let acceptTaskID = "acceptTask"
@@ -416,6 +424,24 @@ public enum AIClipboardSkillCatalog: Sendable {
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: translateToTargetID,
systemImage: "globe",
titleKey: "keyboard.ai.skill.translateToTarget",
cardTitleKey: "skills.translateToTarget.name",
descriptionKey: "skills.translateToTarget.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: polishOriginalID,
systemImage: "wand.and.stars",
titleKey: "keyboard.ai.skill.polishOriginal",
cardTitleKey: "skills.polishOriginal.name",
descriptionKey: "skills.polishOriginal.description",
kind: .transform,
isDefault: true
),
AIClipboardSkill(
id: openLinkID,
systemImage: "arrow.up.right.square.fill",
@@ -692,6 +718,14 @@ public enum AIClipboardSkillCatalog: Sendable {
now: now
)
}
// Polishing rewrites the user's own draft, so it carries the same
// never-answer boundary as "Speak as me" (built-in prompt only).
if skill.id == polishOriginalID, skill.customPrompt == nil {
let boundary = locale == "zh"
? PolishPromptComposer.chineseNeverAnswerContract
: PolishPromptComposer.englishNeverAnswerContract
return "\(baseInstruction)\n\(boundary)"
}
if canonicalID(for: skill.id) == speakAsMeID {
return speakAsMeInstruction(
baseInstruction,
@@ -724,10 +758,27 @@ public enum AIClipboardSkillCatalog: Sendable {
: "To \(target)"
}
/// Chip label for "Translate to <target>". Nil when the user has translation
/// turned off (or the id is unknown), in which case the skill is not offered.
/// The language name is localized to the UI language ("英语" / "English").
public static func translateToTargetButtonTitle(
translationTargetLocaleId: String,
uiLanguage: AppUILanguage
) -> String? {
guard !TranslationLanguageCatalog.isOff(translationTargetLocaleId) else { return nil }
let language = TranslationLanguageCatalog.resolve(translationTargetLocaleId)
let displayLocale = Locale(identifier: uiLanguage.resolvedLanguageCode())
let name = displayLocale.localizedString(forIdentifier: language.id)
?? language.nativeName
return uiLanguage.resolvedLanguageCode() == "zh-Hans"
? "翻译\(name)"
: "To \(name)"
}
public static func instruction(
skillID: String,
locale: String,
translationTargetLocaleId _: String,
translationTargetLocaleId: String,
preferredLanguages: [String] = Locale.preferredLanguages,
now: Date = Date()
) -> String {
@@ -779,6 +830,15 @@ public enum AIClipboardSkillCatalog: Sendable {
locale: locale,
preferredLanguages: preferredLanguages
)
case translateToTargetID:
return translateToTargetInstruction(
zh: zh,
translationTargetLocaleId: translationTargetLocaleId
)
case polishOriginalID:
return zh
? "请在不改变语言的前提下优化剪贴板文本:先纠正语法、拼写和标点错误,再改善不自然或不准确的措辞,最后适度润色,让表达通顺、自然、得体。严格使用原文的语言输出,不要翻译;保留原意、事实、数字、人名、链接、代码和段落格式,不要扩写或删减要点。只输出优化后的文本,不要解释,也不要列出修改点。"
: "Improve the clipboard text without changing its language: first fix grammar, spelling, and punctuation errors, then improve awkward or inaccurate wording, and finally polish lightly so it reads smoothly, naturally, and appropriately. Output strictly in the source language and never translate. Preserve the meaning, facts, numbers, names, links, code, and paragraph formatting; do not add or drop points. Output only the improved text, with no explanation and no list of changes."
case businessReplyID:
return zh
? "请写一段专业但不官腔的商务聊天回复,表达直接、自然,保留人名、组织名、时间和承诺边界,可直接发送。不要套用正式邮件开场和结尾。"
@@ -987,6 +1047,23 @@ public enum AIClipboardSkillCatalog: Sendable {
return "\(baseInstruction)\n\(conversationalBaseline)\(sceneInstruction)\n\(personalStyle)"
}
/// Translate into the user's configured target language. An "off" or stale
/// target falls back to the catalog default so the Skills-tab prompt preview
/// never shows an empty language name.
private static func translateToTargetInstruction(
zh: Bool,
translationTargetLocaleId: String
) -> String {
let resolved = TranslationLanguageCatalog.resolve(translationTargetLocaleId)
let language = TranslationLanguageCatalog.isOff(resolved.id)
? TranslationLanguageCatalog.resolve(TranslationLanguageCatalog.defaultLocaleId)
: resolved
let target = language.promptLanguageName
return zh
? "请把剪贴板文本翻译成 \(target),准确保留原意、语气、名称、链接和格式;如果文本本身已经是 \(target),则原样输出。只输出译文,不要解释。"
: "Translate the clipboard text into \(target), preserving meaning, tone, names, links, and formatting. If the text is already in \(target), return it unchanged. Output only the translation with no explanation."
}
/// Clipboard translation always follows the device's primary system language.
private static func translateInstruction(
locale: String,
@@ -498,6 +498,12 @@ public struct AppGroupStore: @unchecked Sendable {
if storedMigrationVersion < 8 {
additionIDs.insert(AIClipboardSkillCatalog.blessingReplyID)
}
if storedMigrationVersion < 12 {
// Translate-to-target and Polish ship as defaults; install them
// once for existing layouts (a later opt-out stays opted out).
additionIDs.insert(AIClipboardSkillCatalog.translateToTargetID)
additionIDs.insert(AIClipboardSkillCatalog.polishOriginalID)
}
// v9 consolidates playful and business reply into Reply. Do not
// add Reply here: `sanitized` preserves it only when any reply ID
// was enabled, so a user's explicit disabled state stays disabled.
@@ -529,7 +535,7 @@ public struct AppGroupStore: @unchecked Sendable {
}
}
private static let currentAgentSkillDefaultsMigrationVersion = 11
private static let currentAgentSkillDefaultsMigrationVersion = 12
private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
@@ -40,7 +40,8 @@ public enum ClipboardSkillSemanticRanker {
sourceText: String,
analysis: ClipboardSemanticAnalysis,
uiLanguage _: AppUILanguage,
preferredLanguages: [String] = Locale.preferredLanguages
preferredLanguages: [String] = Locale.preferredLanguages,
translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
) -> [AIClipboardSkill] {
guard skills.count > 1 else { return skills }
return sorted(
@@ -48,7 +49,8 @@ public enum ClipboardSkillSemanticRanker {
scores: relevanceScores(
sourceText: sourceText,
analysis: analysis,
preferredLanguages: preferredLanguages
preferredLanguages: preferredLanguages,
translationTargetLocaleId: translationTargetLocaleId
)
)
}
@@ -61,13 +63,15 @@ public enum ClipboardSkillSemanticRanker {
analysis: ClipboardSemanticAnalysis,
uiLanguage _: AppUILanguage,
limit: Int,
preferredLanguages: [String] = Locale.preferredLanguages
preferredLanguages: [String] = Locale.preferredLanguages,
translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
) -> [AIClipboardSkill] {
guard limit > 0 else { return [] }
var scores = relevanceScores(
sourceText: sourceText,
analysis: analysis,
preferredLanguages: preferredLanguages
preferredLanguages: preferredLanguages,
translationTargetLocaleId: translationTargetLocaleId
)
let genericReply = suppressesInterpersonalRouting(analysis, sourceText: sourceText)
? nil
@@ -114,7 +118,8 @@ public enum ClipboardSkillSemanticRanker {
private static func relevanceScores(
sourceText: String,
analysis: ClipboardSemanticAnalysis,
preferredLanguages: [String]
preferredLanguages: [String],
translationTargetLocaleId: String
) -> [String: Int] {
var scores: [String: Int] = [:]
func boost(_ id: String, _ value: Int) {
@@ -149,7 +154,20 @@ public enum ClipboardSkillSemanticRanker {
analysis.language,
preferredLanguages: preferredLanguages
) {
// Foreign text: translate it into the device language (auto-translate
// uses the same path) and offer to polish it in its own language.
// Polish is a secondary option, so it ranks below a real Reply.
boost(AIClipboardSkillCatalog.translateID, 230)
boost(AIClipboardSkillCatalog.polishOriginalID, 100)
} else if isDeviceLanguage(
analysis.language,
preferredLanguages: preferredLanguages
), canTranslateToTarget(
from: analysis.language,
translationTargetLocaleId: translationTargetLocaleId
) {
// Device-language text: offer the user's configured target language.
boost(AIClipboardSkillCatalog.translateToTargetID, 225)
}
if analysis.hasAddress {
@@ -390,6 +408,39 @@ public enum ClipboardSkillSemanticRanker {
)
}
/// Confidently the device's primary language — the counterpart of
/// `isLanguageMismatch`. An unknown / low-confidence label is neither.
private static func isDeviceLanguage(
_ language: ClipboardLanguageLabel?,
preferredLanguages: [String]
) -> Bool {
guard let language, language.confidence >= languageConfidenceThreshold else {
return false
}
return SystemLanguageResolver.isSameLanguage(
sourceIdentifier: language.identifier,
targetIdentifier: SystemLanguageResolver.primaryIdentifier(
preferredLanguages: preferredLanguages
)
)
}
/// "Translate to <target>" needs a real target that differs from the
/// copy's own language; translating into the same language is a no-op.
private static func canTranslateToTarget(
from language: ClipboardLanguageLabel?,
translationTargetLocaleId: String
) -> Bool {
guard let language,
!TranslationLanguageCatalog.isOff(translationTargetLocaleId) else {
return false
}
return !SystemLanguageResolver.isSameLanguage(
sourceIdentifier: language.identifier,
targetIdentifier: translationTargetLocaleId
)
}
private static func isRoutingEvidence(_ label: ClipboardIntentLabel) -> Bool {
label.isDetected && label.isApprovedForAutomaticRouting
}
+19 -2
View File
@@ -237,13 +237,30 @@ public final class KeyboardState {
/// The view controller resolves this from device idiom + horizontal size
/// class so SwiftUI and the UIKit height constraint cannot disagree.
public var usesIPadLayoutMetrics: Bool = false
/// The custom system-keyboard switch is iPad-only. iPhone relies on the
/// system-provided switch below the keyboard instead of showing a duplicate.
/// Unfolded iPhone Duo: phone key layout on rows sized to the system
/// keyboard of the inner display. Resolved alongside
/// `usesIPadLayoutMetrics`; never true at the same time.
public var usesExpandedPhoneLayoutMetrics: Bool = false
/// Any iPhone in landscape (vertically compact): a shorter phone panel.
/// Never true together with the iPad or expanded-phone metrics.
public var usesCompactHeightPhoneLayoutMetrics: Bool = false
/// Whether the keyboard draws its own globe key: always on iPad, and
/// elsewhere only when UIKit reports no system switch below the keyboard.
public var showsSystemGlobeKey: Bool = false
/// Width the controller sized the keyboard to. Both the UIKit height
/// constraint and the SwiftUI key grid pick their metrics from this one
/// value so they can never disagree and clip the bottom row.
public var layoutWidth: CGFloat = 0
/// Wide hosts swap the voice surface's circle cluster for a keyboard-style
/// bottom row aligned with the typing keys (see `VoiceKeyRowLayout`).
public var usesVoiceKeyRow: Bool {
VoiceKeyRowLayout.isAvailable(
usesIPadMetrics: usesIPadLayoutMetrics,
usesExpandedPhoneMetrics: usesExpandedPhoneLayoutMetrics,
usesCompactHeightPhoneMetrics: usesCompactHeightPhoneLayoutMetrics,
hostWidth: layoutWidth
)
}
/// `true` when the last voice insertion is still at the caret and can
/// be undone (suffix-checked against `documentContextBeforeInput`).
public var undoAvailable: Bool = false
@@ -0,0 +1,84 @@
// VoiceKeyRowLayout.swift
// OSGKeyboard · Shared
//
// Keyboard-style voice bottom row for wide hosts (iPad, unfolded iPhone Duo).
import CoreGraphics
/// Horizontal geometry of the wide-host voice bottom row:
///
/// [leadingSide][leadingContext][ send ][trailingContext][trailingSide]
///
/// - The four icon keys (space / undo / edit / delete) share one width and send
/// is exactly twice as wide, so the primary action reads as the main key while
/// the row fills edge to edge with uniform gaps. The row is symmetric, so send
/// sits on the centre line under the microphone.
/// - No globe: the voice surface leaves input-mode switching to the typing tab.
/// - Context slots (undo / edit) exist even while the keys are disabled, so
/// nothing in the row shifts when they toggle.
///
/// Coordinates are relative to the row's own leading edge.
public struct VoiceKeyRowLayout: Equatable, Sendable {
public struct Slot: Equatable, Sendable {
public let minX: CGFloat
public let width: CGFloat
public var midX: CGFloat { minX + width / 2 }
public var maxX: CGFloat { minX + width }
public init(minX: CGFloat, width: CGFloat) {
self.minX = minX
self.width = width
}
}
/// Send width as a multiple of one icon key.
public static let sendWidthMultiplier: CGFloat = 2
/// Below this host width the icon keys would shrink past a comfortable
/// touch target, so narrower hosts keep the phone's circle cluster.
public static let minimumHostWidth: CGFloat = 500
public let leadingSide: Slot
public let leadingContext: Slot
public let send: Slot
public let trailingContext: Slot
public let trailingSide: Slot
/// Wide hosts only: iPad-scale metrics, the expanded-phone tier, or a
/// landscape iPhone (whose shorter panel has no room for two circle rows),
/// with enough width for the row. Portrait iPhones keep the circle
/// cluster, which suits one-handed thumb reach.
public static func isAvailable(
usesIPadMetrics: Bool,
usesExpandedPhoneMetrics: Bool,
usesCompactHeightPhoneMetrics: Bool,
hostWidth: CGFloat
) -> Bool {
(usesIPadMetrics || usesExpandedPhoneMetrics || usesCompactHeightPhoneMetrics)
&& hostWidth >= minimumHostWidth
}
/// - Parameter rowWidth: The row's width, i.e. the same width the typing bottom row spans.
public static func make(rowWidth: CGFloat) -> VoiceKeyRowLayout {
let spacing = KeyboardChromeLayout.actionKeySpacing
// 4 icon keys + one send key worth `sendWidthMultiplier` icon keys,
// with 4 gaps between those five.
let keySpan = max(0, rowWidth - spacing * 4)
let keyWidth = keySpan / (4 + sendWidthMultiplier)
let leadingSide = Slot(minX: 0, width: keyWidth)
let leadingContext = Slot(minX: leadingSide.maxX + spacing, width: keyWidth)
let send = Slot(minX: leadingContext.maxX + spacing, width: keyWidth * sendWidthMultiplier)
let trailingContext = Slot(minX: send.maxX + spacing, width: keyWidth)
let trailingSide = Slot(minX: trailingContext.maxX + spacing, width: keyWidth)
return VoiceKeyRowLayout(
leadingSide: leadingSide,
leadingContext: leadingContext,
send: send,
trailingContext: trailingContext,
trailingSide: trailingSide
)
}
}
@@ -0,0 +1,29 @@
// VoiceMicPlacement.swift
// OSGKeyboard · Shared
//
// Vertical placement of the voice surface's mic row. Every shell height
// (phone, unfolded Duo, landscape, iPad narrow / wide) leaves a different
// amount of free height between the top bar and the bottom row; this splits it
// into the context slot above the mic and the gap below it with one rule, so
// the mic sits centred in the area below the top bar on every device.
import CoreGraphics
public enum VoiceMicPlacement {
/// Never let the mic touch the bottom row.
public static let minimumGap: CGFloat = 10
/// Context slot height for a free height (`contextAndGap` = slot + gap).
/// Centred means the slot above the mic equals the gap plus the bottom row
/// below it, but the slot never drops under `minimumContextHeight` (the
/// live transcript) and the gap never under `minimumGap`.
public static func contextHeight(
contextAndGap: CGFloat,
bottomRowHeight: CGFloat,
minimumContextHeight: CGFloat
) -> CGFloat {
let centred = (contextAndGap + bottomRowHeight) / 2
let ceiling = max(0, contextAndGap - minimumGap)
return min(ceiling, max(minimumContextHeight, centred))
}
}