290 lines
9.6 KiB
Swift
290 lines
9.6 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|
||
}
|