feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish

Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
Rocky
2026-08-13 01:00:51 +08:00
parent fd6e0d3e7e
commit 9f308fadd2
202 changed files with 10897 additions and 5962 deletions
@@ -0,0 +1,108 @@
// AIHintStore.swift
// OSGKeyboard · Shared
//
// Reads/writes host-ready hint packs from App Group. Keyboard only reads.
import Foundation
public enum AIHintStore: Sendable {
public static let refreshInterval: TimeInterval = 12 * 60 * 60
/// Without a feed `expiresAt`, a pack still stops being served once it is
/// this old stale hot topics are worse than the evergreen local catalog.
public static let maximumPackAge: TimeInterval = 48 * 60 * 60
public static func loadReadyPack(
locale: String,
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> AIHintPack? {
guard let defaults,
let data = defaults.data(forKey: AIHintAppGroupKeys.readyPackKey(locale: locale))
else { return nil }
return try? JSONDecoder().decode(AIHintPack.self, from: data)
}
public static func saveReadyPack(
_ pack: AIHintPack,
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) {
guard let defaults else { return }
var copy = pack
copy.refreshedAt = copy.refreshedAt ?? Date()
guard let data = try? JSONEncoder().encode(copy) else { return }
defaults.set(data, forKey: AIHintAppGroupKeys.readyPackKey(locale: pack.locale))
defaults.set(
Date().timeIntervalSince1970,
forKey: AIHintAppGroupKeys.lastSuccessKey(locale: pack.locale)
)
defaults.synchronize()
}
public static func lastSuccessAt(
locale: String,
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> Date? {
let key = AIHintAppGroupKeys.lastSuccessKey(locale: locale)
guard let defaults, defaults.object(forKey: key) != nil else { return nil }
return Date(timeIntervalSince1970: defaults.double(forKey: key))
}
public static func markAttempt(
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) {
defaults?.set(Date().timeIntervalSince1970, forKey: AIHintAppGroupKeys.lastAttemptAt)
}
/// One stale locale is enough to schedule a refresh pass.
public static func shouldRefresh(
now: Date = Date(),
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> Bool {
AIHintFeedEndpoints.supportedLocales.contains { locale in
shouldRefresh(locale: locale, now: now, defaults: defaults)
}
}
public static func shouldRefresh(
locale: String,
now: Date = Date(),
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> Bool {
guard let last = lastSuccessAt(locale: locale, defaults: defaults) else { return true }
return now.timeIntervalSince(last) >= refreshInterval
}
/// Keyboard-facing pack: fresh ready remote/local merge, else built-in catalog.
public static func resolvedPack(
locale: String,
now: Date = Date(),
defaults: UserDefaults? = AppGroup.defaultsIfAvailable
) -> AIHintPack {
if let ready = loadReadyPack(locale: locale, defaults: defaults),
!ready.cards.isEmpty,
!isExpired(ready, now: now) {
return ready
}
return AIHintPack(
locale: locale,
cards: AIHintLocalCatalog.cards(locale: locale),
refreshedAt: nil
)
}
/// The feed's `expiresAt` is authoritative; `maximumPackAge` is the fallback.
static func isExpired(_ pack: AIHintPack, now: Date = Date()) -> Bool {
if let expiresAt = pack.expiresAt, let deadline = date(fromISO8601: expiresAt) {
return now > deadline
}
guard let refreshedAt = pack.refreshedAt else { return false }
return now.timeIntervalSince(refreshedAt) >= maximumPackAge
}
private static func date(fromISO8601 value: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: value) { return date }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: value)
}
}