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."
)
releaseNotesDisclosure(for: update)
HStack(spacing: 12) {
Button {
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
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Header
HStack(alignment: .center, spacing: 16) {
Image(systemName: "puzzlepiece.extension.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 48, height: 48)
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
.accessibilityHidden(true)
VStack(spacing: 20) {
// Header
HStack(alignment: .center, spacing: 16) {
Image(systemName: "puzzlepiece.extension.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Connect Browser Extension")
.font(.title.weight(.bold))
Text("Capture downloads directly from your browser in three easy steps.")
.foregroundStyle(.secondary)
.font(.body)
}
Spacer()
VStack(alignment: .leading, spacing: 4) {
Text("Connect Browser Extension")
.font(.title2.weight(.bold))
Text("Capture downloads directly from your browser in three easy steps.")
.foregroundStyle(.secondary)
.font(.subheadline)
}
.padding(.bottom, 8)
Spacer()
}
KeychainAccessCard()
KeychainAccessCard()
if settings.isKeychainAccessGranted {
// Step 1: Copy Token
StepCardView(
stepNumber: 1,
title: "Copy Pairing Token",
description: "This secure token authorizes your browser extension.",
icon: "doc.on.clipboard.fill",
iconColor: .blue,
actionText: "Copy Token",
action: {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
withAnimation {
showToast = true
if settings.isKeychainAccessGranted {
HStack(spacing: 12) {
// Step 1
CompactStepCardView(
stepNumber: 1,
title: "Copy Token",
description: "This secure token authorizes your browser extension.",
icon: "doc.on.clipboard.fill",
iconColor: .blue,
actionText: "Copy Token",
action: {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
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
StepCardView(
stepNumber: 2,
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)
}
}
)
Image(systemName: "chevron.right")
.font(.title3.weight(.bold))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
// Step 3: Paste and Save
StepCardView(
stepNumber: 3,
title: "Paste & Connect",
description: "Click the Firelink icon in your browser's toolbar and paste the token into the App Pairing Token field.",
icon: "arrow.down.doc.fill",
iconColor: .green,
actionText: nil,
action: nil
)
// Step 2
CompactStepCardView(
stepNumber: 2,
title: "Get Extension",
description: "Install the Firelink Companion extension on your 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)
}
}
)
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()
@@ -103,18 +112,16 @@ struct IntegrationSettingsPane: View {
}
}
.font(.footnote)
.padding(.top, 8)
}
}
.padding(32)
Spacer()
}
.padding(24)
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
.background(Color(NSColor.windowBackgroundColor))
}
}
struct StepCardView: View {
struct CompactStepCardView: View {
let stepNumber: Int
let title: String
let description: String
@@ -126,83 +133,88 @@ struct StepCardView: View {
var secondaryAction: (() -> Void)? = nil
var body: some View {
HStack(spacing: 16) {
// Step Number Badge
ZStack {
Circle()
.fill(Color(nsColor: .controlBackgroundColor))
.frame(width: 32, height: 32)
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
Text("\(stepNumber)")
.font(.system(.headline, design: .rounded).weight(.bold))
.foregroundStyle(.primary)
}
// Icon
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(iconColor.opacity(0.15))
.frame(width: 48, height: 48)
Image(systemName: icon)
.font(.system(size: 24))
.foregroundStyle(iconColor)
VStack(spacing: 12) {
HStack(alignment: .top) {
// Step Number Badge
ZStack {
Circle()
.fill(Color(nsColor: .controlBackgroundColor))
.frame(width: 24, height: 24)
.shadow(color: .black.opacity(0.1), radius: 1, y: 1)
Text("\(stepNumber)")
.font(.system(.caption, design: .rounded).weight(.bold))
.foregroundStyle(.primary)
}
Spacer()
// Icon
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(iconColor.opacity(0.15))
.frame(width: 32, height: 32)
Image(systemName: icon)
.font(.system(size: 16))
.foregroundStyle(iconColor)
}
}
// Text Content
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
Text(description)
.font(.subheadline)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Spacer(minLength: 8)
// 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 {
Button(action: secondaryAction) {
Text(secondaryActionText)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.font(.caption.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color(nsColor: .controlBackgroundColor))
.foregroundColor(.primary)
.cornerRadius(8)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 8)
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
.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(
RoundedRectangle(cornerRadius: 16)
RoundedRectangle(cornerRadius: 12)
.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(
RoundedRectangle(cornerRadius: 16)
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
@@ -4,66 +4,61 @@ struct KeychainAccessCard: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
.frame(width: 48, height: 48)
Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
.font(.system(size: 24))
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
}
HStack(spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 4) {
Text("Keychain Access")
.font(.headline)
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()
Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
.font(.system(size: 18))
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
}
HStack {
Spacer()
if settings.isKeychainAccessGranted {
Label("Access Granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.subheadline.weight(.medium))
.padding(.trailing, 8)
Button(role: .destructive) {
settings.revokeKeychainAccess()
} label: {
Text("Revoke Access")
}
} else {
Button {
settings.grantKeychainAccess()
} label: {
Text("Grant Access")
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(8)
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 2) {
Text("Keychain Access")
.font(.headline)
Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 16)
if settings.isKeychainAccessGranted {
Label("Granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.subheadline.weight(.medium))
Button(role: .destructive) {
settings.revokeKeychainAccess()
} label: {
Text("Revoke")
}
} 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(
RoundedRectangle(cornerRadius: 16)
RoundedRectangle(cornerRadius: 12)
.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(
RoundedRectangle(cornerRadius: 16)
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
@@ -26,11 +26,10 @@ struct ToastNotification: ViewModifier {
}
.zIndex(1)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
isShowing = false
}
.task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
withAnimation {
isShowing = false
}
}
}