diff --git a/VoiceInk/Views/Metrics/DashboardPromotionsSection.swift b/VoiceInk/Views/Metrics/DashboardPromotionsSection.swift new file mode 100644 index 0000000..39ee886 --- /dev/null +++ b/VoiceInk/Views/Metrics/DashboardPromotionsSection.swift @@ -0,0 +1,157 @@ +import SwiftUI +import AppKit + +struct DashboardPromotionsSection: View { + let licenseState: LicenseViewModel.LicenseState + + private var shouldShowUpgradePromotion: Bool { + switch licenseState { + case .trial(let daysRemaining): + return daysRemaining <= 2 + case .trialExpired: + return true + case .licensed: + return false + } + } + + private var shouldShowAffiliatePromotion: Bool { + if case .licensed = licenseState { + return true + } + return false + } + + private var shouldShowPromotions: Bool { + shouldShowUpgradePromotion || shouldShowAffiliatePromotion + } + + var body: some View { + if shouldShowPromotions { + HStack(alignment: .top, spacing: 18) { + if shouldShowUpgradePromotion { + DashboardPromotionCard( + badge: "30% OFF", + title: "Unlock VoiceInk Pro For Less", + message: "Share VoiceInk on your socials, and instantly unlock a 30% discount on VoiceInk Pro.", + accentSymbol: "megaphone.fill", + glowColor: Color(red: 0.08, green: 0.48, blue: 0.85), + actionTitle: "Share & Unlock", + actionIcon: "arrow.up.right", + action: openSocialShare + ) + .frame(maxWidth: .infinity) + } + + if shouldShowAffiliatePromotion { + DashboardPromotionCard( + badge: "AFFILIATE 30%", + title: "Earn With The VoiceInk Affiliate Program", + message: "Share VoiceInk with friends or your audience and receive 30% on every referral that upgrades.", + accentSymbol: "link.badge.plus", + glowColor: Color(red: 0.08, green: 0.48, blue: 0.85), + actionTitle: "Explore Affiliate", + actionIcon: "arrow.up.right", + action: openAffiliateProgram + ) + .frame(maxWidth: .infinity) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + EmptyView() + } + } + + private func openSocialShare() { + if let url = URL(string: "https://tryvoiceink.com/social-share") { + NSWorkspace.shared.open(url) + } + } + + private func openAffiliateProgram() { + if let url = URL(string: "https://tryvoiceink.com/affiliate") { + NSWorkspace.shared.open(url) + } + } +} + +private struct DashboardPromotionCard: View { + let badge: String + let title: String + let message: String + let accentSymbol: String + let glowColor: Color + let actionTitle: String + let actionIcon: String + let action: () -> Void + + private static let defaultGradient: LinearGradient = LinearGradient( + colors: [ + Color(red: 0.08, green: 0.48, blue: 0.85), + Color(red: 0.05, green: 0.18, blue: 0.42) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top) { + Text(badge.uppercased()) + .font(.system(size: 11, weight: .heavy)) + .tracking(0.8) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.white.opacity(0.2)) + .clipShape(Capsule()) + .foregroundColor(.white) + + Spacer() + + Image(systemName: accentSymbol) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white.opacity(0.85)) + .padding(10) + .background(.white.opacity(0.18)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + Text(title) + .font(.system(size: 21, weight: .heavy, design: .rounded)) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + + Text(message) + .font(.system(size: 13.5, weight: .medium)) + .foregroundColor(.white.opacity(0.85)) + .fixedSize(horizontal: false, vertical: true) + + Button(action: action) { + HStack(spacing: 6) { + Text(actionTitle) + Image(systemName: actionIcon) + } + .font(.system(size: 13, weight: .semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(.white.opacity(0.22)) + .clipShape(Capsule()) + .foregroundColor(.white) + } + .buttonStyle(.plain) + } + .padding(24) + .frame(maxWidth: .infinity, minHeight: 200, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill(Self.defaultGradient) + ) + .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(.white.opacity(0.08), lineWidth: 1) + ) + .shadow(color: glowColor.opacity(0.15), radius: 12, x: 0, y: 8) + } +} diff --git a/VoiceInk/Views/Metrics/HelpAndResourcesSection.swift b/VoiceInk/Views/Metrics/HelpAndResourcesSection.swift new file mode 100644 index 0000000..1d4e247 --- /dev/null +++ b/VoiceInk/Views/Metrics/HelpAndResourcesSection.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct HelpAndResourcesSection: View { + var body: some View { + VStack(alignment: .leading, spacing: 15) { + Text("Help & Resources") + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundColor(.primary.opacity(0.8)) + + VStack(alignment: .leading, spacing: 12) { + resourceLink( + icon: "sparkles", + title: "Recommended Models", + url: "https://tryvoiceink.com/recommended-models" + ) + + resourceLink( + icon: "video.fill", + title: "YouTube Videos & Guides", + url: "https://www.youtube.com/@tryvoiceink/videos" + ) + + resourceLink( + icon: "book.fill", + title: "Documentation", + url: "https://tryvoiceink.com/docs" // Placeholder + ) + } + } + .padding(22) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(Color.primary.opacity(0.1), lineWidth: 1) + ) + } + + private func resourceLink(icon: String, title: String, url: String) -> some View { + Button(action: { + if let url = URL(string: url) { + NSWorkspace.shared.open(url) + } + }) { + HStack { + Image(systemName: icon) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.accentColor) + .frame(width: 20) + + Text(title) + .font(.system(size: 13)) + .fontWeight(.semibold) + + Spacer() + + Image(systemName: "arrow.up.right") + .foregroundColor(.secondary) + } + .padding(12) + .background(Color.primary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + + } + .buttonStyle(.plain) + } +} 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 80aa977..c3605be 100644 --- a/VoiceInk/Views/Metrics/MetricsContent.swift +++ b/VoiceInk/Views/Metrics/MetricsContent.swift @@ -1,22 +1,31 @@ import SwiftUI -import Charts struct MetricsContent: View { let transcriptions: [Transcription] + let licenseState: LicenseViewModel.LicenseState var body: some View { - if transcriptions.isEmpty { - emptyStateView - } else { - ScrollView { - VStack(spacing: 20) { - TimeEfficiencyView(totalRecordedTime: totalRecordedTime, estimatedTypingTime: estimatedTypingTime) - - metricsGrid - - voiceInkTrendChart + 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() } - .padding() } } } @@ -24,91 +33,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 ) - } - } - - private var voiceInkTrendChart: some View { - VStack(alignment: .leading, spacing: 10) { - Text("30-Day VoiceInk Trend") - .font(.headline) - Chart { - ForEach(dailyTranscriptionCounts, id: \.date) { item in - LineMark( - x: .value("Date", item.date), - y: .value("Sessions", item.count) - ) - .interpolationMethod(.catmullRom) - - AreaMark( - x: .value("Date", item.date), - y: .value("Sessions", item.count) - ) - .foregroundStyle(LinearGradient(colors: [.blue.opacity(0.3), .blue.opacity(0.1)], startPoint: .top, endPoint: .bottom)) - .interpolationMethod(.catmullRom) - } - } - .chartXAxis { - AxisMarks(values: .stride(by: .day, count: 7)) { _ in - AxisGridLine() - AxisTick() - AxisValueLabel(format: .dateTime.day().month(), centered: true) - } - } - .chartYAxis { - AxisMarks { value in - AxisGridLine() - AxisTick() - AxisValueLabel() - } - } - .frame(height: 250) + 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 + ) } - .padding() - .background(Color(.controlBackgroundColor)) - .cornerRadius(10) - .shadow(radius: 2) } - // Computed properties for metrics + 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 } } @@ -124,30 +199,91 @@ struct MetricsContent: View { return estimatedTypingTimeInMinutes * 60 } - private var dailyTranscriptionCounts: [(date: Date, count: Int)] { - let calendar = Calendar.current - let now = Date() - let thirtyDaysAgo = calendar.date(byAdding: .day, value: -29, to: now)! - - let dailyData = (0..<30).compactMap { dayOffset -> (date: Date, count: Int)? in - guard let date = calendar.date(byAdding: .day, value: -dayOffset, to: now) else { return nil } - let startOfDay = calendar.startOfDay(for: date) - let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! - let count = transcriptions.filter { $0.timestamp >= startOfDay && $0.timestamp < endOfDay }.count - return (date: startOfDay, count: count) - } - - return dailyData.reversed() + 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] : [.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 + } + } + } +} 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 - } - } - } -} diff --git a/VoiceInk/Views/MetricsView.swift b/VoiceInk/Views/MetricsView.swift index 85a6be0..b2b92a6 100644 --- a/VoiceInk/Views/MetricsView.swift +++ b/VoiceInk/Views/MetricsView.swift @@ -51,9 +51,15 @@ struct MetricsView: View { Group { if skipSetupCheck { - MetricsContent(transcriptions: Array(transcriptions)) + MetricsContent( + transcriptions: Array(transcriptions), + licenseState: licenseViewModel.licenseState + ) } else if isSetupComplete { - MetricsContent(transcriptions: Array(transcriptions)) + MetricsContent( + transcriptions: Array(transcriptions), + licenseState: licenseViewModel.licenseState + ) } else { MetricsSetupView() }