From 367da05feb66b1e298b69cf778a2cb7af7c14621 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Wed, 29 Oct 2025 17:02:42 +0545 Subject: [PATCH] Reorganized/redesigned the Dashboard --- VoiceInk/Views/Metrics/MetricCard.swift | 63 ++-- VoiceInk/Views/Metrics/MetricsContent.swift | 273 +++++++++++++++--- .../Views/Metrics/TimeEfficiencyView.swift | 267 ----------------- 3 files changed, 270 insertions(+), 333 deletions(-) delete mode 100644 VoiceInk/Views/Metrics/TimeEfficiencyView.swift diff --git a/VoiceInk/Views/Metrics/MetricCard.swift b/VoiceInk/Views/Metrics/MetricCard.swift index cae403f..424dadb 100644 --- a/VoiceInk/Views/Metrics/MetricCard.swift +++ b/VoiceInk/Views/Metrics/MetricCard.swift @@ -1,38 +1,51 @@ import SwiftUI struct MetricCard: View { + let icon: String let title: String let value: String - let icon: String + let detail: String? let color: Color var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - // Icon - Image(systemName: icon) - .font(.system(size: 24)) - .foregroundColor(color) - .frame(width: 32, height: 32) - .background( - Circle() - .fill(color.opacity(0.1)) - ) - - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(.subheadline) - .foregroundColor(.secondary) - Text(value) - .font(.system(size: 24, weight: .bold, design: .rounded)) - .foregroundColor(.primary) + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(color.opacity(0.15)) + Image(systemName: icon) + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .foregroundColor(color) } + .frame(width: 34, height: 34) + + Text(title) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + Text(value) + .font(.system(size: 24, weight: .black, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.6) + + if let detail, !detail.isEmpty { + Text(detail) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) } } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(Color(.controlBackgroundColor)) - .cornerRadius(10) - .shadow(radius: 2) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.thinMaterial) + ) } } diff --git a/VoiceInk/Views/Metrics/MetricsContent.swift b/VoiceInk/Views/Metrics/MetricsContent.swift index 8c0a098..65e3497 100644 --- a/VoiceInk/Views/Metrics/MetricsContent.swift +++ b/VoiceInk/Views/Metrics/MetricsContent.swift @@ -4,16 +4,23 @@ struct MetricsContent: View { let transcriptions: [Transcription] var body: some View { - if transcriptions.isEmpty { - emptyStateView - } else { - ScrollView { - VStack(spacing: 20) { - TimeEfficiencyView(totalRecordedTime: totalRecordedTime, estimatedTypingTime: estimatedTypingTime) - - metricsGrid + Group { + if transcriptions.isEmpty { + emptyStateView + } else { + ScrollView { + VStack(spacing: 24) { + heroSection + metricsSection + } + .padding(.vertical, 28) + .padding(.horizontal, 32) + } + .background(Color(.windowBackgroundColor)) + .overlay(alignment: .bottomTrailing) { + footerActionsView + .padding() } - .padding() } } } @@ -21,49 +28,157 @@ struct MetricsContent: View { private var emptyStateView: some View { VStack(spacing: 20) { Image(systemName: "waveform") - .font(.system(size: 50)) + .font(.system(size: 56, weight: .semibold)) .foregroundColor(.secondary) Text("No Transcriptions Yet") - .font(.title2) - .fontWeight(.semibold) - Text("Start recording to see your metrics") + .font(.title3.weight(.semibold)) + Text("Start your first recording to unlock value insights.") .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.windowBackgroundColor)) } - private var metricsGrid: some View { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 20) { + // 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( - title: "Words Dictated", - value: "\(totalWordsTranscribed)", - icon: "text.word.spacing", - color: .blue - ) - MetricCard( - title: "VoiceInk Sessions", + icon: "mic.fill", + title: "Sessions Recorded", value: "\(transcriptions.count)", - icon: "mic.circle.fill", - color: .green - ) - MetricCard( - title: "Average Words/Minute", - value: String(format: "%.1f", averageWordsPerMinute), - icon: "speedometer", - color: .orange - ) - MetricCard( - title: "Words/Session", - value: String(format: "%.1f", averageWordsPerSession), - icon: "chart.bar.fill", + 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 - // Computed properties for metrics private var totalWordsTranscribed: Int { transcriptions.reduce(0) { $0 + $1.text.split(separator: " ").count } } @@ -79,15 +194,91 @@ struct MetricsContent: View { return estimatedTypingTimeInMinutes * 60 } + private var timeSaved: TimeInterval { + max(estimatedTypingTime - totalRecordedTime, 0) + } - // Add computed properties for new metrics private var averageWordsPerMinute: Double { guard totalRecordedTime > 0 else { return 0 } return Double(totalWordsTranscribed) / (totalRecordedTime / 60.0) } - private var averageWordsPerSession: Double { - guard !transcriptions.isEmpty else { return 0 } - return Double(totalWordsTranscribed) / Double(transcriptions.count) + 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] : [.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 + } + } + } +} \ No newline at end of file diff --git a/VoiceInk/Views/Metrics/TimeEfficiencyView.swift b/VoiceInk/Views/Metrics/TimeEfficiencyView.swift deleted file mode 100644 index 61b64d5..0000000 --- a/VoiceInk/Views/Metrics/TimeEfficiencyView.swift +++ /dev/null @@ -1,267 +0,0 @@ -import SwiftUI - -struct TimeEfficiencyView: View { - // MARK: - Properties - - private let totalRecordedTime: TimeInterval - private let estimatedTypingTime: TimeInterval - - // Computed properties for efficiency metrics - private var timeSaved: TimeInterval { - estimatedTypingTime - totalRecordedTime - } - - private var efficiencyMultiplier: Double { - guard totalRecordedTime > 0 else { return 0 } - let multiplier = estimatedTypingTime / totalRecordedTime - return round(multiplier * 10) / 10 // Round to 1 decimal place - } - - private var efficiencyMultiplierFormatted: String { - String(format: "%.1fx", efficiencyMultiplier) - } - - // MARK: - Initializer - - init(totalRecordedTime: TimeInterval, estimatedTypingTime: TimeInterval) { - self.totalRecordedTime = totalRecordedTime - self.estimatedTypingTime = estimatedTypingTime - } - - // MARK: - Body - - var body: some View { - VStack(spacing: 0) { - mainContent - } - } - - // MARK: - Main Content View - - private var mainContent: some View { - VStack(spacing: 24) { - headerSection - timeComparisonSection - bottomSection - } - .padding(.vertical, 24) - .background(Color(.controlBackgroundColor)) - .cornerRadius(10) - .shadow(radius: 2) - } - - // MARK: - Subviews - - private var headerSection: some View { - VStack(alignment: .center, spacing: 8) { - HStack(spacing: 8) { - Text("You are") - .font(.system(size: 32, weight: .bold)) - - Text("\(efficiencyMultiplierFormatted) Faster") - .font(.system(size: 32, weight: .bold)) - .foregroundStyle(efficiencyGradient) - - Text("with VoiceInk") - .font(.system(size: 32, weight: .bold)) - } - .lineLimit(1) - .minimumScaleFactor(0.5) - } - .padding(.horizontal, 24) - } - - private var timeComparisonSection: some View { - HStack(spacing: 16) { - TimeBlockView( - duration: totalRecordedTime, - label: "SPEAKING TIME", - icon: "mic.circle.fill", - color: .green - ) - - TimeBlockView( - duration: estimatedTypingTime, - label: "TYPING TIME", - icon: "keyboard.fill", - color: .orange - ) - } - .padding(.horizontal, 24) - } - - private var bottomSection: some View { - HStack { - timeSavedView - Spacer() - reportIssueButton - } - .padding(.horizontal, 24) - } - - private var timeSavedView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("YOU'VE SAVED ⏳") - .font(.system(size: 13, weight: .heavy)) - .tracking(4) - .foregroundColor(.secondary) - - Text(formatDuration(timeSaved)) - .font(.system(size: 32, weight: .black, design: .rounded)) - .foregroundStyle(accentGradient) - } - } - - private var reportIssueButton: some View { - ZStack { - Button(action: { - EmailSupport.openSupportEmail() - }) { - HStack(alignment: .center, spacing: 12) { - // Left icon - Image(systemName: "exclamationmark.bubble.fill") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(.white) - - // Center text - Text("Feedback or Issues?") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.white) - - Spacer(minLength: 8) - } - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background(accentGradient) - .cornerRadius(10) - } - .buttonStyle(.plain) - .shadow(color: Color.accentColor.opacity(0.2), radius: 3, y: 1) - .frame(maxWidth: 280) - - // Copy system info button overlaid and centered vertically - HStack { - Spacer() - CopySystemInfoButton() - .padding(.trailing, 8) - } - .frame(maxWidth: 280) - } - .frame(maxWidth: 280) - } - - private var efficiencyGradient: LinearGradient { - LinearGradient( - colors: [ - Color.green, - Color.green.opacity(0.7) - ], - startPoint: .leading, - endPoint: .trailing - ) - } - - private var accentGradient: LinearGradient { - LinearGradient( - colors: [ - Color(nsColor: .controlAccentColor), - Color(nsColor: .controlAccentColor).opacity(0.8) - ], - startPoint: .leading, - endPoint: .trailing - ) - } - - // MARK: - Utility Methods - - private func formatDuration(_ duration: TimeInterval) -> String { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute, .second] - formatter.unitsStyle = .abbreviated - return formatter.string(from: duration) ?? "" - } -} - -// MARK: - Helper Struct - -struct TimeBlockView: View { - let duration: TimeInterval - let label: String - let icon: String - let color: Color - - private func formatDuration(_ duration: TimeInterval) -> String { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute, .second] - formatter.unitsStyle = .abbreviated - return formatter.string(from: duration) ?? "" - } - - var body: some View { - HStack(spacing: 16) { - Image(systemName: icon) - .font(.system(size: 24, weight: .semibold)) - .foregroundColor(color) - - VStack(alignment: .leading, spacing: 4) { - Text(formatDuration(duration)) - .font(.system(size: 24, weight: .bold, design: .rounded)) - - Text(label) - .font(.system(size: 12, weight: .heavy)) - .tracking(2) - .foregroundColor(.secondary) - } - - Spacer() - } - .padding(.horizontal, 24) - .padding(.vertical, 16) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(color.opacity(0.1)) - ) - } -} - -// MARK: - Copy System Info Button -private struct CopySystemInfoButton: View { - @State private var isCopied: Bool = false - - var body: some View { - Button(action: { - copySystemInfo() - }) { - Image(systemName: isCopied ? "checkmark" : "doc.on.doc") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.white.opacity(0.9)) - .padding(8) - .background( - Circle() - .fill(Color.white.opacity(0.2)) - .overlay( - Circle() - .stroke(Color.white.opacity(0.3), lineWidth: 1) - ) - ) - .rotationEffect(.degrees(isCopied ? 360 : 0)) - } - .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 - } - } - } -}