feat(home): add usage statistics card above preview field

Track dictation duration, dictation/translation character counts, and
personal dictionary size. Record metrics when Flow finalizes utterances.

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-07-03 09:54:13 +00:00
parent 5cb772d699
commit b909e9082b
7 changed files with 288 additions and 2 deletions
+27 -2
View File
@@ -57,6 +57,8 @@ final class FlowSessionManager: ObservableObject {
private var currentPartial = "" private var currentPartial = ""
private var lastFinal = "" private var lastFinal = ""
private var chunkWarnings: [String] = [] private var chunkWarnings: [String] = []
/// Wall-clock span of the current mic-open utterance (excludes LLM polish).
private var utteranceRecordingStartedAt: Date?
/// True while the host app scene is `.active` drives foreground renewal. /// True while the host app scene is `.active` drives foreground renewal.
private var isAppForeground = false private var isAppForeground = false
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
@@ -407,6 +409,7 @@ final class FlowSessionManager: ObservableObject {
chunkedPipeline = pipeline chunkedPipeline = pipeline
isUtteranceRecording = true isUtteranceRecording = true
utteranceRecordingStartedAt = Date()
FlowDiagnostics.log( FlowDiagnostics.log(
"beginUtterance engine=\(store.engineMode) asr=\(store.localASRBackend.rawValue) " + "beginUtterance engine=\(store.engineMode) asr=\(store.localASRBackend.rawValue) " +
"asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s" "asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
@@ -473,6 +476,7 @@ final class FlowSessionManager: ObservableObject {
private func abortUtterance() { private func abortUtterance() {
isUtteranceRecording = false isUtteranceRecording = false
isUtteranceProcessing = false isUtteranceProcessing = false
utteranceRecordingStartedAt = nil
finalizeTask?.cancel() finalizeTask?.cancel()
finalizeTask = nil finalizeTask = nil
asrTask?.cancel() asrTask?.cancel()
@@ -490,6 +494,7 @@ final class FlowSessionManager: ObservableObject {
private func failUtterance(message: String) { private func failUtterance(message: String) {
isUtteranceRecording = false isUtteranceRecording = false
isUtteranceProcessing = false isUtteranceProcessing = false
utteranceRecordingStartedAt = nil
finalizeTask?.cancel() finalizeTask?.cancel()
finalizeTask = nil finalizeTask = nil
asrTask?.cancel() asrTask?.cancel()
@@ -507,6 +512,7 @@ final class FlowSessionManager: ObservableObject {
private func finishProcessing(withError message: String) { private func finishProcessing(withError message: String) {
isUtteranceProcessing = false isUtteranceProcessing = false
utteranceRecordingStartedAt = nil
finalizeTask?.cancel() finalizeTask?.cancel()
finalizeTask = nil finalizeTask = nil
chunkedPipeline = nil chunkedPipeline = nil
@@ -555,12 +561,15 @@ final class FlowSessionManager: ObservableObject {
? "flow.error.recognitionInterrupted" ? "flow.error.recognitionInterrupted"
: "flow.error.noSpeech" : "flow.error.noSpeech"
FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s") FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s")
utteranceRecordingStartedAt = nil
FlowSessionBridge.storeTranscriptionError( FlowSessionBridge.storeTranscriptionError(
AppL10n.string(key) AppL10n.string(key)
) )
return return
} }
let recordingDuration = consumeRecordingDuration()
let engineMode = store.engineMode let engineMode = store.engineMode
let chunkNote = Self.chunkWarningMessage(chunkWarnings) let chunkNote = Self.chunkWarningMessage(chunkWarnings)
// Re-read App Group at finalize so chip-side translation changes // Re-read App Group at finalize so chip-side translation changes
@@ -574,7 +583,12 @@ final class FlowSessionManager: ObservableObject {
"finalize ASR-only total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s " + "finalize ASR-only total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s " +
"len=\(text.count)" "len=\(text.count)"
) )
SpeechHistoryStore.shared.append(text: text, engineMode: engineMode) SpeechHistoryStore.shared.recordUtterance(
text: text,
engineMode: engineMode,
duration: recordingDuration,
wasTranslation: false
)
currentPartial = "" currentPartial = ""
lastFinal = "" lastFinal = ""
chunkWarnings = [] chunkWarnings = []
@@ -616,7 +630,12 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.storeTranscriptionResult(text, polishWarning: warning) FlowSessionBridge.storeTranscriptionResult(text, polishWarning: warning)
} }
SpeechHistoryStore.shared.append(text: delivered, engineMode: engineMode) SpeechHistoryStore.shared.recordUtterance(
text: delivered,
engineMode: engineMode,
duration: recordingDuration,
wasTranslation: pipelineStore.isTranslationEffective
)
currentPartial = "" currentPartial = ""
lastFinal = "" lastFinal = ""
@@ -639,6 +658,12 @@ final class FlowSessionManager: ObservableObject {
return warnings.joined(separator: "\n") return warnings.joined(separator: "\n")
} }
private func consumeRecordingDuration() -> TimeInterval {
defer { utteranceRecordingStartedAt = nil }
guard let start = utteranceRecordingStartedAt else { return 0 }
return max(0, Date().timeIntervalSince(start))
}
/// v0.2.0: surface the local-mode cloud-polish error path with a /// v0.2.0: surface the local-mode cloud-polish error path with a
/// localised hint ("please fill in your DeepSeek key in Settings") /// localised hint ("please fill in your DeepSeek key in Settings")
/// rather than letting the keyboard show a generic network error. /// rather than letting the keyboard show a generic network error.
@@ -47,6 +47,21 @@ final class SpeechHistoryStore: ObservableObject {
persist() persist()
} }
/// Append history and update cumulative home-screen usage stats.
func recordUtterance(
text: String,
engineMode: String,
duration: TimeInterval,
wasTranslation: Bool
) {
append(text: text, engineMode: engineMode)
UsageStatisticsStore.shared.recordUtterance(
text: text,
duration: duration,
wasTranslation: wasTranslation
)
}
func clearAll() { func clearAll() {
entries.removeAll() entries.removeAll()
persist() persist()
@@ -0,0 +1,102 @@
// UsageStatisticsStore.swift
// OSGKeyboard · Main App
//
// Cumulative usage metrics shown on the home screen stats card.
// Updated when Flow finalizes a successful utterance.
import Foundation
import Combine
struct UsageStatistics: Codable, Equatable {
var dictationDurationSeconds: TimeInterval
var dictationCharacterCount: Int
var translationCharacterCount: Int
static let zero = UsageStatistics(
dictationDurationSeconds: 0,
dictationCharacterCount: 0,
translationCharacterCount: 0
)
}
@MainActor
final class UsageStatisticsStore: ObservableObject {
static let shared = UsageStatisticsStore()
@Published private(set) var dictationDurationSeconds: TimeInterval = 0
@Published private(set) var dictationCharacterCount: Int = 0
@Published private(set) var translationCharacterCount: Int = 0
private let defaults: UserDefaults
private let storageKey = "usageStatistics.v1"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
load()
}
func recordUtterance(text: String, duration: TimeInterval, wasTranslation: Bool) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let count = Self.characterCount(for: trimmed)
if wasTranslation {
translationCharacterCount += count
} else {
dictationCharacterCount += count
}
dictationDurationSeconds += max(0, duration)
persist()
}
static func characterCount(for text: String) -> Int {
text.trimmingCharacters(in: .whitespacesAndNewlines).count
}
// MARK: - Formatting
static func formatDuration(_ seconds: TimeInterval, language: AppUILanguage) -> String {
let total = max(0, Int(seconds.rounded()))
if total < 60 {
return language.resolvedLanguageCode().hasPrefix("zh")
? "\(total)"
: "\(total)s"
}
let hours = total / 3600
let minutes = (total % 3600) / 60
if hours > 0 {
return language.resolvedLanguageCode().hasPrefix("zh")
? "\(hours)小时\(minutes)"
: "\(hours)h \(minutes)m"
}
return language.resolvedLanguageCode().hasPrefix("zh")
? "\(minutes)"
: "\(minutes)m"
}
static func formatCount(_ value: Int, language: AppUILanguage) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: language.resolvedLanguageCode())
return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
}
private func load() {
guard let data = defaults.data(forKey: storageKey),
let stats = try? JSONDecoder().decode(UsageStatistics.self, from: data)
else { return }
dictationDurationSeconds = stats.dictationDurationSeconds
dictationCharacterCount = stats.dictationCharacterCount
translationCharacterCount = stats.translationCharacterCount
}
private func persist() {
let stats = UsageStatistics(
dictationDurationSeconds: dictationDurationSeconds,
dictationCharacterCount: dictationCharacterCount,
translationCharacterCount: translationCharacterCount
)
guard let data = try? JSONEncoder().encode(stats) else { return }
defaults.set(data, forKey: storageKey)
}
}
@@ -0,0 +1,132 @@
// HomeStatsCard.swift
// OSGKeyboard · Main App
//
// Home screen summary: dictation time, dictation characters,
// translation characters, and personal-dictionary entry count.
import SwiftUI
import OSGKeyboardShared
struct HomeStatsCard: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject private var stats = UsageStatisticsStore.shared
@ObservedObject private var config = ProviderConfig.shared
@State private var dictionaryCount = 0
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
statCell(
systemImage: "waveform",
value: UsageStatisticsStore.formatDuration(
stats.dictationDurationSeconds,
language: config.uiLanguage
),
label: "home.stats.dictationDuration"
)
divider
statCell(
systemImage: "text.alignleft",
value: UsageStatisticsStore.formatCount(
stats.dictationCharacterCount,
language: config.uiLanguage
),
label: "home.stats.dictationCharacters"
)
}
horizontalDivider
HStack(spacing: 0) {
statCell(
systemImage: "character.bubble",
value: UsageStatisticsStore.formatCount(
stats.translationCharacterCount,
language: config.uiLanguage
),
label: "home.stats.translationCharacters"
)
divider
statCell(
systemImage: "books.vertical",
value: UsageStatisticsStore.formatCount(
dictionaryCount,
language: config.uiLanguage
),
label: "home.stats.dictionaryEntries"
)
}
}
.background(cardBackground, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.onAppear(perform: refreshDictionaryCount)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshDictionaryCount()
}
}
private var cardBackground: some View {
ZStack(alignment: .top) {
palette.surface
LinearGradient(
colors: [
palette.accent.opacity(0.10),
palette.accent.opacity(0.02),
palette.surface.opacity(0)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
private func statCell(systemImage: String, value: String, label: LocalizedStringKey) -> some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(spacing: 6) {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.accent)
Text(value)
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
Text(label)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
}
private var divider: some View {
Rectangle()
.fill(palette.divider)
.frame(width: 0.5)
}
private var horizontalDivider: some View {
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
}
private func refreshDictionaryCount() {
dictionaryCount = AppGroupStore().personalDictionary.entries.count
}
}
#if DEBUG
#Preview {
ThemedRoot {
HomeStatsCard()
.padding()
}
}
#endif
+4
View File
@@ -64,6 +64,10 @@ struct HomeView: View {
.padding(.bottom, Spacing.lg) .padding(.bottom, Spacing.lg)
} }
HomeStatsCard()
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.md)
previewField previewField
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+4
View File
@@ -292,6 +292,10 @@
"home.flow.endShort" = "End"; "home.flow.endShort" = "End";
"home.preview.label" = "Try typing"; "home.preview.label" = "Try typing";
"home.preview.placeholder" = "Tap to type and test…"; "home.preview.placeholder" = "Tap to type and test…";
"home.stats.dictationDuration" = "Dictation time";
"home.stats.dictationCharacters" = "Dictation chars";
"home.stats.translationCharacters" = "Translation chars";
"home.stats.dictionaryEntries" = "Dictionary";
"home.engine.unsupportedOS" = "Qwen3-ASR CoreML requires iOS 18+"; "home.engine.unsupportedOS" = "Qwen3-ASR CoreML requires iOS 18+";
"home.engine.warming" = "Loading ASR model into memory…"; "home.engine.warming" = "Loading ASR model into memory…";
"home.engine.downloading" = "Downloading ASR model…"; "home.engine.downloading" = "Downloading ASR model…";
@@ -291,6 +291,10 @@
"home.flow.endShort" = "结束"; "home.flow.endShort" = "结束";
"home.preview.label" = "输入测试"; "home.preview.label" = "输入测试";
"home.preview.placeholder" = "点这里试试键盘"; "home.preview.placeholder" = "点这里试试键盘";
"home.stats.dictationDuration" = "听写时长";
"home.stats.dictationCharacters" = "听写字数";
"home.stats.translationCharacters" = "翻译字数";
"home.stats.dictionaryEntries" = "个性词库";
"home.engine.unsupportedOS" = "Qwen3-ASR CoreML 需要 iOS 18 或更高版本"; "home.engine.unsupportedOS" = "Qwen3-ASR CoreML 需要 iOS 18 或更高版本";
"home.engine.warming" = "正在加载语音识别模型…"; "home.engine.warming" = "正在加载语音识别模型…";
"home.engine.downloading" = "正在下载语音识别模型…"; "home.engine.downloading" = "正在下载语音识别模型…";