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 lastFinal = ""
|
||||
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.
|
||||
private var isAppForeground = false
|
||||
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
||||
@@ -407,6 +409,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
chunkedPipeline = pipeline
|
||||
|
||||
isUtteranceRecording = true
|
||||
utteranceRecordingStartedAt = Date()
|
||||
FlowDiagnostics.log(
|
||||
"beginUtterance engine=\(store.engineMode) asr=\(store.localASRBackend.rawValue) " +
|
||||
"asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
|
||||
@@ -473,6 +476,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
private func abortUtterance() {
|
||||
isUtteranceRecording = false
|
||||
isUtteranceProcessing = false
|
||||
utteranceRecordingStartedAt = nil
|
||||
finalizeTask?.cancel()
|
||||
finalizeTask = nil
|
||||
asrTask?.cancel()
|
||||
@@ -490,6 +494,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
private func failUtterance(message: String) {
|
||||
isUtteranceRecording = false
|
||||
isUtteranceProcessing = false
|
||||
utteranceRecordingStartedAt = nil
|
||||
finalizeTask?.cancel()
|
||||
finalizeTask = nil
|
||||
asrTask?.cancel()
|
||||
@@ -507,6 +512,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
private func finishProcessing(withError message: String) {
|
||||
isUtteranceProcessing = false
|
||||
utteranceRecordingStartedAt = nil
|
||||
finalizeTask?.cancel()
|
||||
finalizeTask = nil
|
||||
chunkedPipeline = nil
|
||||
@@ -555,12 +561,15 @@ final class FlowSessionManager: ObservableObject {
|
||||
? "flow.error.recognitionInterrupted"
|
||||
: "flow.error.noSpeech"
|
||||
FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s")
|
||||
utteranceRecordingStartedAt = nil
|
||||
FlowSessionBridge.storeTranscriptionError(
|
||||
AppL10n.string(key)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let recordingDuration = consumeRecordingDuration()
|
||||
|
||||
let engineMode = store.engineMode
|
||||
let chunkNote = Self.chunkWarningMessage(chunkWarnings)
|
||||
// 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 " +
|
||||
"len=\(text.count)"
|
||||
)
|
||||
SpeechHistoryStore.shared.append(text: text, engineMode: engineMode)
|
||||
SpeechHistoryStore.shared.recordUtterance(
|
||||
text: text,
|
||||
engineMode: engineMode,
|
||||
duration: recordingDuration,
|
||||
wasTranslation: false
|
||||
)
|
||||
currentPartial = ""
|
||||
lastFinal = ""
|
||||
chunkWarnings = []
|
||||
@@ -616,7 +630,12 @@ final class FlowSessionManager: ObservableObject {
|
||||
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 = ""
|
||||
lastFinal = ""
|
||||
@@ -639,6 +658,12 @@ final class FlowSessionManager: ObservableObject {
|
||||
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
|
||||
/// localised hint ("please fill in your DeepSeek key in Settings")
|
||||
/// rather than letting the keyboard show a generic network error.
|
||||
|
||||
@@ -47,6 +47,21 @@ final class SpeechHistoryStore: ObservableObject {
|
||||
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() {
|
||||
entries.removeAll()
|
||||
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)
|
||||
}
|
||||
|
||||
HomeStatsCard()
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, Spacing.md)
|
||||
|
||||
previewField
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"home.flow.endShort" = "End";
|
||||
"home.preview.label" = "Try typing";
|
||||
"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.warming" = "Loading ASR model into memory…";
|
||||
"home.engine.downloading" = "Downloading ASR model…";
|
||||
|
||||
@@ -291,6 +291,10 @@
|
||||
"home.flow.endShort" = "结束";
|
||||
"home.preview.label" = "输入测试";
|
||||
"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.warming" = "正在加载语音识别模型…";
|
||||
"home.engine.downloading" = "正在下载语音识别模型…";
|
||||
|
||||
Reference in New Issue
Block a user