vOOice/VoiceInk/Views/Metrics/MetricsContent.swift
2025-10-29 22:38:29 +05:45

290 lines
9.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
struct MetricsContent: View {
let transcriptions: [Transcription]
let licenseState: LicenseViewModel.LicenseState
var body: some View {
Group {
if transcriptions.isEmpty {
emptyStateView
} else {
ScrollView {
VStack(spacing: 24) {
heroSection
metricsSection
HStack(alignment: .top, spacing: 18) {
HelpAndResourcesSection()
DashboardPromotionsSection(licenseState: licenseState)
}
}
.padding(.vertical, 28)
.padding(.horizontal, 32)
}
.background(Color(.windowBackgroundColor))
.overlay(alignment: .bottomTrailing) {
footerActionsView
.padding()
}
}
}
}
private var emptyStateView: some View {
VStack(spacing: 20) {
Image(systemName: "waveform")
.font(.system(size: 56, weight: .semibold))
.foregroundColor(.secondary)
Text("No Transcriptions Yet")
.font(.title3.weight(.semibold))
Text("Start your first recording to unlock value insights.")
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.windowBackgroundColor))
}
// MARK: - Sections
private var heroSection: some View {
VStack(spacing: 10) {
HStack {
Spacer(minLength: 0)
(Text("You have saved ")
.fontWeight(.bold)
.foregroundColor(.white.opacity(0.85))
+
Text(formattedTimeSaved)
.fontWeight(.black)
.font(.system(size: 36, design: .rounded))
.foregroundStyle(.white)
+
Text(" with VoiceInk")
.fontWeight(.bold)
.foregroundColor(.white.opacity(0.85))
)
.font(.system(size: 30))
.multilineTextAlignment(.center)
Spacer(minLength: 0)
}
.lineLimit(1)
.minimumScaleFactor(0.5)
Text(heroSubtitle)
.font(.system(size: 15, weight: .medium))
.foregroundColor(.white.opacity(0.85))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
.padding(28)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(heroGradient)
)
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(Color.white.opacity(0.1), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.08), radius: 30, x: 0, y: 16)
}
private var metricsSection: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 240), spacing: 16)], spacing: 16) {
MetricCard(
icon: "mic.fill",
title: "Sessions Recorded",
value: "\(transcriptions.count)",
detail: "VoiceInk sessions completed",
color: .purple
)
MetricCard(
icon: "text.alignleft",
title: "Words Dictated",
value: Formatters.formattedNumber(totalWordsTranscribed),
detail: "words generated",
color: Color(nsColor: .controlAccentColor)
)
MetricCard(
icon: "speedometer",
title: "Words Per Minute",
value: averageWordsPerMinute > 0
? String(format: "%.1f", averageWordsPerMinute)
: "",
detail: "VoiceInk vs. typing by hand",
color: .yellow
)
MetricCard(
icon: "keyboard.fill",
title: "Keystrokes Saved",
value: Formatters.formattedNumber(totalKeystrokesSaved),
detail: "fewer keystrokes",
color: .orange
)
}
}
private var footerActionsView: some View {
HStack(spacing: 12) {
CopySystemInfoButton()
feedbackButton
}
}
private var feedbackButton: some View {
Button(action: {
EmailSupport.openSupportEmail()
}) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.bubble.fill")
Text("Feedback or Issues?")
}
.font(.system(size: 13, weight: .medium))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Capsule().fill(.thinMaterial))
}
.buttonStyle(.plain)
}
private var formattedTimeSaved: String {
let formatted = Formatters.formattedDuration(timeSaved, style: .full, fallback: "Time savings coming soon")
return formatted
}
private var heroSubtitle: String {
guard !transcriptions.isEmpty else {
return "Your VoiceInk journey starts with your first recording."
}
let wordsText = Formatters.formattedNumber(totalWordsTranscribed)
let sessionCount = transcriptions.count
let sessionText = sessionCount == 1 ? "session" : "sessions"
return "Dictated \(wordsText) words across \(sessionCount) \(sessionText)."
}
private var heroGradient: LinearGradient {
LinearGradient(
gradient: Gradient(colors: [
Color(nsColor: .controlAccentColor),
Color(nsColor: .controlAccentColor).opacity(0.85),
Color(nsColor: .controlAccentColor).opacity(0.7)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
// MARK: - Computed Metrics
private var totalWordsTranscribed: Int {
transcriptions.reduce(0) { $0 + $1.text.split(separator: " ").count }
}
private var totalRecordedTime: TimeInterval {
transcriptions.reduce(0) { $0 + $1.duration }
}
private var estimatedTypingTime: TimeInterval {
let averageTypingSpeed: Double = 35 // words per minute
let totalWords = Double(totalWordsTranscribed)
let estimatedTypingTimeInMinutes = totalWords / averageTypingSpeed
return estimatedTypingTimeInMinutes * 60
}
private var timeSaved: TimeInterval {
max(estimatedTypingTime - totalRecordedTime, 0)
}
private var averageWordsPerMinute: Double {
guard totalRecordedTime > 0 else { return 0 }
return Double(totalWordsTranscribed) / (totalRecordedTime / 60.0)
}
private var totalKeystrokesSaved: Int {
Int(Double(totalWordsTranscribed) * 5.0)
}
private var firstTranscriptionDateText: String? {
guard let firstDate = transcriptions.map(\.timestamp).min() else { return nil }
return dateFormatter.string(from: firstDate)
}
private var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter
}
}
private enum Formatters {
static let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter
}()
static let durationFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.maximumUnitCount = 2
return formatter
}()
static func formattedNumber(_ value: Int) -> String {
return numberFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
}
static func formattedDuration(_ interval: TimeInterval, style: DateComponentsFormatter.UnitsStyle, fallback: String = "") -> String {
guard interval > 0 else { return fallback }
durationFormatter.unitsStyle = style
durationFormatter.allowedUnits = interval >= 3600 ? [.hour, .minute] : [.minute, .second]
return durationFormatter.string(from: interval) ?? fallback
}
}
private struct CopySystemInfoButton: View {
@State private var isCopied: Bool = false
var body: some View {
Button(action: {
copySystemInfo()
}) {
HStack(spacing: 8) {
Image(systemName: isCopied ? "checkmark" : "doc.on.doc")
.rotationEffect(.degrees(isCopied ? 360 : 0))
Text(isCopied ? "Copied!" : "Copy System Info")
}
.font(.system(size: 13, weight: .medium))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Capsule().fill(.thinMaterial))
}
.buttonStyle(.plain)
.scaleEffect(isCopied ? 1.1 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isCopied)
}
private func copySystemInfo() {
SystemInfoService.shared.copySystemInfoToClipboard()
withAnimation {
isCopied = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
isCopied = false
}
}
}
}