feat(settings): redesign integration pane and fix toast notification bugs

This commit is contained in:
NimBold
2026-06-09 13:40:07 +03:30
parent 0ec2213a4c
commit b7380bea35
5 changed files with 187 additions and 201 deletions
@@ -149,8 +149,6 @@ struct AboutSettingsPane: View {
subtitle: "You have Firelink \(appVersion). Download the new release from GitHub when you're ready." subtitle: "You have Firelink \(appVersion). Download the new release from GitHub when you're ready."
) )
releaseNotesDisclosure(for: update)
HStack(spacing: 12) { HStack(spacing: 12) {
Button { Button {
NSWorkspace.shared.open(update.releaseURL) NSWorkspace.shared.open(update.releaseURL)
@@ -243,22 +241,4 @@ struct AboutSettingsPane: View {
} }
} }
private func releaseNotesDisclosure(for update: AvailableReleaseUpdate) -> some View {
DisclosureGroup("What's New") {
ScrollView {
Text(releaseNotes(from: update.releaseNotes))
.font(.caption)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(maxHeight: 180)
.padding(8)
.background(Color(NSColor.controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
}
}
private func releaseNotes(from markdown: String) -> AttributedString {
(try? AttributedString(markdown: markdown)) ?? AttributedString(markdown)
}
} }
@@ -7,85 +7,94 @@ struct IntegrationSettingsPane: View {
@State private var showToast = false @State private var showToast = false
var body: some View { var body: some View {
ScrollView { VStack(spacing: 20) {
VStack(spacing: 24) { // Header
// Header HStack(alignment: .center, spacing: 16) {
HStack(alignment: .center, spacing: 16) { Image(systemName: "puzzlepiece.extension.fill")
Image(systemName: "puzzlepiece.extension.fill") .resizable()
.resizable() .aspectRatio(contentMode: .fit)
.aspectRatio(contentMode: .fit) .frame(width: 40, height: 40)
.frame(width: 48, height: 48) .foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0))) .accessibilityHidden(true)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Connect Browser Extension") Text("Connect Browser Extension")
.font(.title.weight(.bold)) .font(.title2.weight(.bold))
Text("Capture downloads directly from your browser in three easy steps.") Text("Capture downloads directly from your browser in three easy steps.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.font(.body) .font(.subheadline)
}
Spacer()
} }
.padding(.bottom, 8) Spacer()
}
KeychainAccessCard() KeychainAccessCard()
if settings.isKeychainAccessGranted { if settings.isKeychainAccessGranted {
// Step 1: Copy Token HStack(spacing: 12) {
StepCardView( // Step 1
stepNumber: 1, CompactStepCardView(
title: "Copy Pairing Token", stepNumber: 1,
description: "This secure token authorizes your browser extension.", title: "Copy Token",
icon: "doc.on.clipboard.fill", description: "This secure token authorizes your browser extension.",
iconColor: .blue, icon: "doc.on.clipboard.fill",
actionText: "Copy Token", iconColor: .blue,
action: { actionText: "Copy Token",
NSPasteboard.general.clearContents() action: {
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string) NSPasteboard.general.clearContents()
withAnimation { NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
showToast = true withAnimation {
showToast = true
}
},
secondaryActionText: "Regenerate",
secondaryAction: {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
} }
}, )
secondaryActionText: "Regenerate",
secondaryAction: {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
}
)
// Step 2: Get Extension Image(systemName: "chevron.right")
StepCardView( .font(.title3.weight(.bold))
stepNumber: 2, .foregroundStyle(Color(nsColor: .tertiaryLabelColor))
title: "Get Extension",
description: "Install the Firelink Companion extension on your favorite browser.",
icon: "globe",
iconColor: .orange,
actionText: "Firefox Add-ons",
action: {
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
NSWorkspace.shared.open(url)
}
},
secondaryActionText: "Releases",
secondaryAction: {
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
NSWorkspace.shared.open(url)
}
}
)
// Step 3: Paste and Save // Step 2
StepCardView( CompactStepCardView(
stepNumber: 3, stepNumber: 2,
title: "Paste & Connect", title: "Get Extension",
description: "Click the Firelink icon in your browser's toolbar and paste the token into the App Pairing Token field.", description: "Install the Firelink Companion extension on your browser.",
icon: "arrow.down.doc.fill", icon: "globe",
iconColor: .green, iconColor: .orange,
actionText: nil, actionText: "Firefox Add-ons",
action: nil action: {
) if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
NSWorkspace.shared.open(url)
}
},
secondaryActionText: "Releases",
secondaryAction: {
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
NSWorkspace.shared.open(url)
}
}
)
Image(systemName: "chevron.right")
.font(.title3.weight(.bold))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
// Step 3
CompactStepCardView(
stepNumber: 3,
title: "Paste & Connect",
description: "Click the Firelink icon in your browser's toolbar and paste the token.",
icon: "arrow.down.doc.fill",
iconColor: .green,
actionText: nil,
action: nil
)
}
.fixedSize(horizontal: false, vertical: true)
Divider() Divider()
@@ -103,18 +112,16 @@ struct IntegrationSettingsPane: View {
} }
} }
.font(.footnote) .font(.footnote)
.padding(.top, 8)
}
} }
.padding(32) Spacer()
} }
.padding(24)
.toast(isShowing: $showToast, message: "Token copied to clipboard!") .toast(isShowing: $showToast, message: "Token copied to clipboard!")
.background(Color(NSColor.windowBackgroundColor)) .background(Color(NSColor.windowBackgroundColor))
} }
} }
struct StepCardView: View { struct CompactStepCardView: View {
let stepNumber: Int let stepNumber: Int
let title: String let title: String
let description: String let description: String
@@ -126,83 +133,88 @@ struct StepCardView: View {
var secondaryAction: (() -> Void)? = nil var secondaryAction: (() -> Void)? = nil
var body: some View { var body: some View {
HStack(spacing: 16) { VStack(spacing: 12) {
// Step Number Badge HStack(alignment: .top) {
ZStack { // Step Number Badge
Circle() ZStack {
.fill(Color(nsColor: .controlBackgroundColor)) Circle()
.frame(width: 32, height: 32) .fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.1), radius: 2, y: 1) .frame(width: 24, height: 24)
.shadow(color: .black.opacity(0.1), radius: 1, y: 1)
Text("\(stepNumber)")
.font(.system(.headline, design: .rounded).weight(.bold)) Text("\(stepNumber)")
.foregroundStyle(.primary) .font(.system(.caption, design: .rounded).weight(.bold))
} .foregroundStyle(.primary)
}
// Icon Spacer()
ZStack { // Icon
RoundedRectangle(cornerRadius: 12) ZStack {
.fill(iconColor.opacity(0.15)) RoundedRectangle(cornerRadius: 8)
.frame(width: 48, height: 48) .fill(iconColor.opacity(0.15))
.frame(width: 32, height: 32)
Image(systemName: icon)
.font(.system(size: 24)) Image(systemName: icon)
.foregroundStyle(iconColor) .font(.system(size: 16))
.foregroundStyle(iconColor)
}
} }
// Text Content // Text Content
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(title) Text(title)
.font(.headline) .font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
Text(description) Text(description)
.font(.subheadline) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
Spacer() Spacer(minLength: 8)
// Action Button // Action Button
HStack(spacing: 8) { VStack(spacing: 8) {
if let actionText = actionText, let action = action {
Button(action: action) {
Text(actionText)
.font(.caption.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction { if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
Button(action: secondaryAction) { Button(action: secondaryAction) {
Text(secondaryActionText) Text(secondaryActionText)
.font(.subheadline.weight(.medium)) .font(.caption.weight(.medium))
.padding(.horizontal, 16) .frame(maxWidth: .infinity)
.padding(.vertical, 8) .padding(.vertical, 6)
.background(Color(nsColor: .controlBackgroundColor)) .background(Color(nsColor: .controlBackgroundColor))
.foregroundColor(.primary) .foregroundColor(.primary)
.cornerRadius(8) .cornerRadius(6)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1) .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
if let actionText = actionText, let action = action {
Button(action: action) {
Text(actionText)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(8)
}
.buttonStyle(.plain)
}
} }
} }
.padding(16) .padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background( .background(
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 12)
.fill(Color(nsColor: .controlBackgroundColor)) .fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2) .shadow(color: .black.opacity(0.05), radius: 4, y: 1)
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1) .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
) )
} }
@@ -4,66 +4,61 @@ struct KeychainAccessCard: View {
@EnvironmentObject private var settings: AppSettings @EnvironmentObject private var settings: AppSettings
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { HStack(spacing: 16) {
HStack(spacing: 16) { ZStack {
ZStack { RoundedRectangle(cornerRadius: 10)
RoundedRectangle(cornerRadius: 12) .fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
.fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15)) .frame(width: 36, height: 36)
.frame(width: 48, height: 48)
Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
.font(.system(size: 24))
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
}
VStack(alignment: .leading, spacing: 4) { Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
Text("Keychain Access") .font(.system(size: 18))
.font(.headline) .foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
Text("Firelink needs Keychain access to securely store your browser extension pairing token and site login passwords.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
} }
HStack { VStack(alignment: .leading, spacing: 2) {
Spacer() Text("Keychain Access")
if settings.isKeychainAccessGranted { .font(.headline)
Label("Access Granted", systemImage: "checkmark.circle.fill") Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
.foregroundStyle(.green) .font(.caption)
.font(.subheadline.weight(.medium)) .foregroundStyle(.secondary)
.padding(.trailing, 8) .fixedSize(horizontal: false, vertical: true)
}
Button(role: .destructive) {
settings.revokeKeychainAccess() Spacer(minLength: 16)
} label: {
Text("Revoke Access") if settings.isKeychainAccessGranted {
} Label("Granted", systemImage: "checkmark.circle.fill")
} else { .foregroundStyle(.green)
Button { .font(.subheadline.weight(.medium))
settings.grantKeychainAccess()
} label: { Button(role: .destructive) {
Text("Grant Access") settings.revokeKeychainAccess()
.font(.subheadline.weight(.medium)) } label: {
.padding(.horizontal, 16) Text("Revoke")
.padding(.vertical, 8)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(8)
}
.buttonStyle(.plain)
} }
} else {
Button {
settings.grantKeychainAccess()
} label: {
Text("Grant Access")
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(6)
}
.buttonStyle(.plain)
} }
} }
.padding(16) .padding(12)
.background( .background(
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 12)
.fill(Color(nsColor: .controlBackgroundColor)) .fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2) .shadow(color: .black.opacity(0.05), radius: 4, y: 1)
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1) .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
) )
} }
@@ -26,11 +26,10 @@ struct ToastNotification: ViewModifier {
} }
.zIndex(1) .zIndex(1)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing)
.onAppear { .task {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { try? await Task.sleep(nanoseconds: 2_000_000_000)
withAnimation { withAnimation {
isShowing = false isShowing = false
}
} }
} }
} }