Merge pull request #347 from Beingpax/redesign-the-dashboard
Redesign the dashboard
This commit is contained in:
commit
e7519b4d24
157
VoiceInk/Views/Metrics/DashboardPromotionsSection.swift
Normal file
157
VoiceInk/Views/Metrics/DashboardPromotionsSection.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
70
VoiceInk/Views/Metrics/HelpAndResourcesSection.swift
Normal file
70
VoiceInk/Views/Metrics/HelpAndResourcesSection.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user