diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index 9c7c95f..450d42b 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -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. diff --git a/OSGKeyboard/Services/SpeechHistoryStore.swift b/OSGKeyboard/Services/SpeechHistoryStore.swift index eae0df0..df13d66 100644 --- a/OSGKeyboard/Services/SpeechHistoryStore.swift +++ b/OSGKeyboard/Services/SpeechHistoryStore.swift @@ -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() diff --git a/OSGKeyboard/Services/UsageStatisticsStore.swift b/OSGKeyboard/Services/UsageStatisticsStore.swift new file mode 100644 index 0000000..8410451 --- /dev/null +++ b/OSGKeyboard/Services/UsageStatisticsStore.swift @@ -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) + } +} diff --git a/OSGKeyboard/Views/Components/HomeStatsCard.swift b/OSGKeyboard/Views/Components/HomeStatsCard.swift new file mode 100644 index 0000000..ad47312 --- /dev/null +++ b/OSGKeyboard/Views/Components/HomeStatsCard.swift @@ -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 diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index 6a3d1a8..b0a2a17 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -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) diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index bee40da..06299a4 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -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…"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index b3d2f8b..40058f1 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -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" = "正在下载语音识别模型…";