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
|
import SwiftUI
|
||||||
|
|
||||||
struct MetricCard: View {
|
struct MetricCard: View {
|
||||||
|
let icon: String
|
||||||
let title: String
|
let title: String
|
||||||
let value: String
|
let value: String
|
||||||
let icon: String
|
let detail: String?
|
||||||
let color: Color
|
let color: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack(spacing: 12) {
|
HStack(alignment: .center, spacing: 12) {
|
||||||
// Icon
|
ZStack {
|
||||||
Image(systemName: icon)
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
.font(.system(size: 24))
|
.fill(color.opacity(0.15))
|
||||||
.foregroundColor(color)
|
Image(systemName: icon)
|
||||||
.frame(width: 32, height: 32)
|
.resizable()
|
||||||
.background(
|
.scaledToFit()
|
||||||
Circle()
|
.frame(width: 18, height: 18)
|
||||||
.fill(color.opacity(0.1))
|
.foregroundColor(color)
|
||||||
)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(title)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text(value)
|
|
||||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
}
|
||||||
|
.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)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
.padding()
|
.padding(16)
|
||||||
.background(Color(.controlBackgroundColor))
|
.background(
|
||||||
.cornerRadius(10)
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
.shadow(radius: 2)
|
.fill(.thinMaterial)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,31 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Charts
|
|
||||||
|
|
||||||
struct MetricsContent: View {
|
struct MetricsContent: View {
|
||||||
let transcriptions: [Transcription]
|
let transcriptions: [Transcription]
|
||||||
|
let licenseState: LicenseViewModel.LicenseState
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if transcriptions.isEmpty {
|
Group {
|
||||||
emptyStateView
|
if transcriptions.isEmpty {
|
||||||
} else {
|
emptyStateView
|
||||||
ScrollView {
|
} else {
|
||||||
VStack(spacing: 20) {
|
ScrollView {
|
||||||
TimeEfficiencyView(totalRecordedTime: totalRecordedTime, estimatedTypingTime: estimatedTypingTime)
|
VStack(spacing: 24) {
|
||||||
|
heroSection
|
||||||
metricsGrid
|
metricsSection
|
||||||
|
HStack(alignment: .top, spacing: 18) {
|
||||||
voiceInkTrendChart
|
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 {
|
private var emptyStateView: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
Image(systemName: "waveform")
|
Image(systemName: "waveform")
|
||||||
.font(.system(size: 50))
|
.font(.system(size: 56, weight: .semibold))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text("No Transcriptions Yet")
|
Text("No Transcriptions Yet")
|
||||||
.font(.title2)
|
.font(.title3.weight(.semibold))
|
||||||
.fontWeight(.semibold)
|
Text("Start your first recording to unlock value insights.")
|
||||||
Text("Start recording to see your metrics")
|
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color(.windowBackgroundColor))
|
.background(Color(.windowBackgroundColor))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var metricsGrid: some View {
|
// MARK: - Sections
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 20) {
|
|
||||||
|
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(
|
MetricCard(
|
||||||
title: "Words Dictated",
|
icon: "mic.fill",
|
||||||
value: "\(totalWordsTranscribed)",
|
title: "Sessions Recorded",
|
||||||
icon: "text.word.spacing",
|
|
||||||
color: .blue
|
|
||||||
)
|
|
||||||
MetricCard(
|
|
||||||
title: "VoiceInk Sessions",
|
|
||||||
value: "\(transcriptions.count)",
|
value: "\(transcriptions.count)",
|
||||||
icon: "mic.circle.fill",
|
detail: "VoiceInk sessions completed",
|
||||||
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",
|
|
||||||
color: .purple
|
color: .purple
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var voiceInkTrendChart: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Text("30-Day VoiceInk Trend")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
Chart {
|
MetricCard(
|
||||||
ForEach(dailyTranscriptionCounts, id: \.date) { item in
|
icon: "text.alignleft",
|
||||||
LineMark(
|
title: "Words Dictated",
|
||||||
x: .value("Date", item.date),
|
value: Formatters.formattedNumber(totalWordsTranscribed),
|
||||||
y: .value("Sessions", item.count)
|
detail: "words generated",
|
||||||
)
|
color: Color(nsColor: .controlAccentColor)
|
||||||
.interpolationMethod(.catmullRom)
|
)
|
||||||
|
|
||||||
AreaMark(
|
MetricCard(
|
||||||
x: .value("Date", item.date),
|
icon: "speedometer",
|
||||||
y: .value("Sessions", item.count)
|
title: "Words Per Minute",
|
||||||
)
|
value: averageWordsPerMinute > 0
|
||||||
.foregroundStyle(LinearGradient(colors: [.blue.opacity(0.3), .blue.opacity(0.1)], startPoint: .top, endPoint: .bottom))
|
? String(format: "%.1f", averageWordsPerMinute)
|
||||||
.interpolationMethod(.catmullRom)
|
: "–",
|
||||||
}
|
detail: "VoiceInk vs. typing by hand",
|
||||||
}
|
color: .yellow
|
||||||
.chartXAxis {
|
)
|
||||||
AxisMarks(values: .stride(by: .day, count: 7)) { _ in
|
|
||||||
AxisGridLine()
|
MetricCard(
|
||||||
AxisTick()
|
icon: "keyboard.fill",
|
||||||
AxisValueLabel(format: .dateTime.day().month(), centered: true)
|
title: "Keystrokes Saved",
|
||||||
}
|
value: Formatters.formattedNumber(totalKeystrokesSaved),
|
||||||
}
|
detail: "fewer keystrokes",
|
||||||
.chartYAxis {
|
color: .orange
|
||||||
AxisMarks { value in
|
)
|
||||||
AxisGridLine()
|
|
||||||
AxisTick()
|
|
||||||
AxisValueLabel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: 250)
|
|
||||||
}
|
}
|
||||||
.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 {
|
private var totalWordsTranscribed: Int {
|
||||||
transcriptions.reduce(0) { $0 + $1.text.split(separator: " ").count }
|
transcriptions.reduce(0) { $0 + $1.text.split(separator: " ").count }
|
||||||
}
|
}
|
||||||
@ -124,30 +199,91 @@ struct MetricsContent: View {
|
|||||||
return estimatedTypingTimeInMinutes * 60
|
return estimatedTypingTimeInMinutes * 60
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dailyTranscriptionCounts: [(date: Date, count: Int)] {
|
private var timeSaved: TimeInterval {
|
||||||
let calendar = Calendar.current
|
max(estimatedTypingTime - totalRecordedTime, 0)
|
||||||
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 {
|
private var averageWordsPerMinute: Double {
|
||||||
guard totalRecordedTime > 0 else { return 0 }
|
guard totalRecordedTime > 0 else { return 0 }
|
||||||
return Double(totalWordsTranscribed) / (totalRecordedTime / 60.0)
|
return Double(totalWordsTranscribed) / (totalRecordedTime / 60.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var averageWordsPerSession: Double {
|
private var totalKeystrokesSaved: Int {
|
||||||
guard !transcriptions.isEmpty else { return 0 }
|
Int(Double(totalWordsTranscribed) * 5.0)
|
||||||
return Double(totalWordsTranscribed) / Double(transcriptions.count)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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 {
|
Group {
|
||||||
if skipSetupCheck {
|
if skipSetupCheck {
|
||||||
MetricsContent(transcriptions: Array(transcriptions))
|
MetricsContent(
|
||||||
|
transcriptions: Array(transcriptions),
|
||||||
|
licenseState: licenseViewModel.licenseState
|
||||||
|
)
|
||||||
} else if isSetupComplete {
|
} else if isSetupComplete {
|
||||||
MetricsContent(transcriptions: Array(transcriptions))
|
MetricsContent(
|
||||||
|
transcriptions: Array(transcriptions),
|
||||||
|
licenseState: licenseViewModel.licenseState
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
MetricsSetupView()
|
MetricsSetupView()
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user