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:
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" = "正在下载语音识别模型…";
|
||||||
|
|||||||
Reference in New Issue
Block a user