From 39c7e4671cc8172f28d067ef92a6d863814ab3c6 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Wed, 29 Oct 2025 13:56:34 +0545 Subject: [PATCH 1/5] Remove 30-day VoiceInk trend --- VoiceInk/Views/Metrics/MetricsContent.swift | 62 +-------------------- 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/VoiceInk/Views/Metrics/MetricsContent.swift b/VoiceInk/Views/Metrics/MetricsContent.swift index 80aa977..8c0a098 100644 --- a/VoiceInk/Views/Metrics/MetricsContent.swift +++ b/VoiceInk/Views/Metrics/MetricsContent.swift @@ -1,5 +1,4 @@ import SwiftUI -import Charts struct MetricsContent: View { let transcriptions: [Transcription] @@ -11,10 +10,8 @@ struct MetricsContent: View { ScrollView { VStack(spacing: 20) { TimeEfficiencyView(totalRecordedTime: totalRecordedTime, estimatedTypingTime: estimatedTypingTime) - + metricsGrid - - voiceInkTrendChart } .padding() } @@ -65,48 +62,6 @@ struct MetricsContent: View { } } - 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) - } - .padding() - .background(Color(.controlBackgroundColor)) - .cornerRadius(10) - .shadow(radius: 2) - } // Computed properties for metrics private var totalWordsTranscribed: Int { @@ -124,21 +79,6 @@ 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() - } // Add computed properties for new metrics private var averageWordsPerMinute: Double { From 367da05feb66b1e298b69cf778a2cb7af7c14621 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Wed, 29 Oct 2025 17:02:42 +0545 Subject: [PATCH 2/5] 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 - } - } - } -} From 5cd64a1247d810190cceb954521583cfd4b1dddb Mon Sep 17 00:00:00 2001 From: Beingpax Date: Wed, 29 Oct 2025 20:51:31 +0545 Subject: [PATCH 3/5] Added Promotional Cards --- .../Metrics/DashboardPromotionsSection.swift | 172 ++++++++++++++++++ VoiceInk/Views/Metrics/MetricsContent.swift | 3 +- 2 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 VoiceInk/Views/Metrics/DashboardPromotionsSection.swift diff --git a/VoiceInk/Views/Metrics/DashboardPromotionsSection.swift b/VoiceInk/Views/Metrics/DashboardPromotionsSection.swift new file mode 100644 index 0000000..fa15e43 --- /dev/null +++ b/VoiceInk/Views/Metrics/DashboardPromotionsSection.swift @@ -0,0 +1,172 @@ +import SwiftUI +import AppKit + +struct DashboardPromotionsSection: View { + let licenseState: LicenseViewModel.LicenseState + + private var shouldShowUpgradePromotion: Bool { + guard case .trial(let daysRemaining) = licenseState else { return false } + return daysRemaining <= 9 + } + + 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: "Share VoiceInk, Save 30%", + message: "Tell your audience about VoiceInk on social and unlock a 30% discount on VoiceInk Pro when they upgrade.", + gradient: 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 + ), + 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.", + gradient: 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 + ), + 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 gradient: LinearGradient + let accentSymbol: String + let glowColor: Color + let actionTitle: String + let actionIcon: String + let action: () -> Void + + 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(gradient) + .overlay { + ZStack { + Circle() + .fill(.white.opacity(0.12)) + .frame(width: 140, height: 140) + .offset(x: 60, y: -60) + Circle() + .strokeBorder(.white.opacity(0.15), lineWidth: 1) + .frame(width: 170, height: 170) + .offset(x: -40, y: 70) + } + .clipped() + } + ) + .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(.white.opacity(0.08), lineWidth: 1) + ) + .shadow(color: glowColor.opacity(0.28), radius: 24, x: 0, y: 14) + } +} diff --git a/VoiceInk/Views/Metrics/MetricsContent.swift b/VoiceInk/Views/Metrics/MetricsContent.swift index 65e3497..81d776e 100644 --- a/VoiceInk/Views/Metrics/MetricsContent.swift +++ b/VoiceInk/Views/Metrics/MetricsContent.swift @@ -12,6 +12,7 @@ struct MetricsContent: View { VStack(spacing: 24) { heroSection metricsSection + DashboardPromotionsSection() } .padding(.vertical, 28) .padding(.horizontal, 32) @@ -281,4 +282,4 @@ private struct CopySystemInfoButton: View { } } } -} \ No newline at end of file +} From e21c34fdc3d0d04bc228fe5d9f9c84dc9f11fcb5 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Wed, 29 Oct 2025 20:52:09 +0545 Subject: [PATCH 4/5] Added promotional cards --- .../Metrics/DashboardPromotionsSection.swift | 57 +++++++------------ VoiceInk/Views/Metrics/MetricsContent.swift | 5 +- VoiceInk/Views/MetricsView.swift | 10 +++- 3 files changed, 32 insertions(+), 40 deletions(-) diff --git a/VoiceInk/Views/Metrics/DashboardPromotionsSection.swift b/VoiceInk/Views/Metrics/DashboardPromotionsSection.swift index fa15e43..39ee886 100644 --- a/VoiceInk/Views/Metrics/DashboardPromotionsSection.swift +++ b/VoiceInk/Views/Metrics/DashboardPromotionsSection.swift @@ -5,8 +5,14 @@ struct DashboardPromotionsSection: View { let licenseState: LicenseViewModel.LicenseState private var shouldShowUpgradePromotion: Bool { - guard case .trial(let daysRemaining) = licenseState else { return false } - return daysRemaining <= 9 + switch licenseState { + case .trial(let daysRemaining): + return daysRemaining <= 2 + case .trialExpired: + return true + case .licensed: + return false + } } private var shouldShowAffiliatePromotion: Bool { @@ -26,16 +32,8 @@ struct DashboardPromotionsSection: View { if shouldShowUpgradePromotion { DashboardPromotionCard( badge: "30% OFF", - title: "Share VoiceInk, Save 30%", - message: "Tell your audience about VoiceInk on social and unlock a 30% discount on VoiceInk Pro when they upgrade.", - gradient: 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 - ), + 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", @@ -50,14 +48,6 @@ struct DashboardPromotionsSection: View { 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.", - gradient: 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 - ), accentSymbol: "link.badge.plus", glowColor: Color(red: 0.08, green: 0.48, blue: 0.85), actionTitle: "Explore Affiliate", @@ -90,12 +80,20 @@ private struct DashboardPromotionCard: View { let badge: String let title: String let message: String - let gradient: LinearGradient 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) { @@ -147,26 +145,13 @@ private struct DashboardPromotionCard: View { .frame(maxWidth: .infinity, minHeight: 200, alignment: .topLeading) .background( RoundedRectangle(cornerRadius: 28, style: .continuous) - .fill(gradient) - .overlay { - ZStack { - Circle() - .fill(.white.opacity(0.12)) - .frame(width: 140, height: 140) - .offset(x: 60, y: -60) - Circle() - .strokeBorder(.white.opacity(0.15), lineWidth: 1) - .frame(width: 170, height: 170) - .offset(x: -40, y: 70) - } - .clipped() - } + .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.28), radius: 24, x: 0, y: 14) + .shadow(color: glowColor.opacity(0.15), radius: 12, x: 0, y: 8) } } diff --git a/VoiceInk/Views/Metrics/MetricsContent.swift b/VoiceInk/Views/Metrics/MetricsContent.swift index 81d776e..a01db40 100644 --- a/VoiceInk/Views/Metrics/MetricsContent.swift +++ b/VoiceInk/Views/Metrics/MetricsContent.swift @@ -2,6 +2,7 @@ import SwiftUI struct MetricsContent: View { let transcriptions: [Transcription] + let licenseState: LicenseViewModel.LicenseState var body: some View { Group { @@ -12,7 +13,7 @@ struct MetricsContent: View { VStack(spacing: 24) { heroSection metricsSection - DashboardPromotionsSection() + DashboardPromotionsSection(licenseState: licenseState) } .padding(.vertical, 28) .padding(.horizontal, 32) @@ -241,7 +242,7 @@ private enum Formatters { 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] + durationFormatter.allowedUnits = interval >= 3600 ? [.hour, .minute] : [.minute, .second] return durationFormatter.string(from: interval) ?? fallback } } 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() } From 96185a5d0ddd3b21d5db962eee808870f1b90abf Mon Sep 17 00:00:00 2001 From: Beingpax Date: Wed, 29 Oct 2025 22:38:29 +0545 Subject: [PATCH 5/5] Added help and resources section --- .../Metrics/HelpAndResourcesSection.swift | 70 +++++++++++++++++++ VoiceInk/Views/Metrics/MetricsContent.swift | 5 +- 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 VoiceInk/Views/Metrics/HelpAndResourcesSection.swift 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/MetricsContent.swift b/VoiceInk/Views/Metrics/MetricsContent.swift index a01db40..c3605be 100644 --- a/VoiceInk/Views/Metrics/MetricsContent.swift +++ b/VoiceInk/Views/Metrics/MetricsContent.swift @@ -13,7 +13,10 @@ struct MetricsContent: View { VStack(spacing: 24) { heroSection metricsSection - DashboardPromotionsSection(licenseState: licenseState) + HStack(alignment: .top, spacing: 18) { + HelpAndResourcesSection() + DashboardPromotionsSection(licenseState: licenseState) + } } .padding(.vertical, 28) .padding(.horizontal, 32)