Merge pull request #347 from Beingpax/redesign-the-dashboard

Redesign the dashboard
This commit is contained in:
Prakash Joshi Pax 2025-10-29 22:44:36 +05:45 committed by GitHub
commit e7519b4d24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 509 additions and 394 deletions

View 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)
}
}

View 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)
}
}

View File

@ -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)
)
} }
} }

View File

@ -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
}
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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()
} }