Files
nucleic/Sources/NucleicApp/UpdateSidebarBanner.swift
T

104 lines
4.3 KiB
Swift

import SwiftUI
/// An unobtrusive, click-to-update strip pinned at the very top of the sidebar, above the mode
/// switcher — the *only* place a background-discovered update is surfaced. Tapping it downloads the
/// update and relaunches into the new version with no extra windows. Manual "Check for Updates…"
/// (Settings ▸ General) never routes here. Renders nothing unless `AppUpdater` has a pending or
/// in-progress sidebar update, so it's invisible in the common case.
struct UpdateSidebarBanner: View {
@EnvironmentObject private var updater: AppUpdater
@Environment(\.appPalette) private var palette
var body: some View {
content
.animation(.easeInOut(duration: 0.2), value: updater.sidebar)
}
@ViewBuilder
private var content: some View {
switch updater.sidebar {
case .none:
EmptyView()
case .available(let version):
available(version: version)
case .downloading(let fraction):
progress(fraction: fraction)
case .relaunching:
status(icon: "arrow.triangle.2.circlepath", text: "Restarting…")
}
}
/// The actionable state: the whole strip is one button that starts the update.
private func available(version: String) -> some View {
Button { updater.installSidebarUpdate() } label: {
HStack(spacing: 8) {
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(palette.accent)
VStack(alignment: .leading, spacing: 1) {
Text("Update available")
.font(.caption.weight(.semibold))
Text("Version \(version) — click to update")
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
.modifier(BannerChrome(tint: palette.accent))
}
.buttonStyle(.plain)
// Line the card's edges up with the mode switcher and session rows below. Those live
// inside the sidebar `List`, which insets its content by `.safeAreaPadding(.horizontal, 5)`
// on top of each row's own `.padding(.horizontal, 8)` — but this banner is pinned *above*
// that List, so it has to carry both itself (5 + 8 = 13) to match.
.padding(.horizontal, 13)
.padding(.top, 8)
// A bit of breathing room below so the card isn't crowded against the mode switcher.
.padding(.bottom, 8)
.transition(.move(edge: .top).combined(with: .opacity))
}
private func progress(fraction: Double) -> some View {
status(
icon: nil,
text: fraction > 0 ? "Updating… \(Int((fraction * 100).rounded()))%" : "Updating…",
spinning: true)
}
private func status(icon: String?, text: String, spinning: Bool = false) -> some View {
HStack(spacing: 8) {
if spinning {
ProgressView().controlSize(.small)
} else if let icon {
Image(systemName: icon).foregroundStyle(palette.accent)
}
Text(text).font(.caption.weight(.medium))
Spacer(minLength: 0)
}
.modifier(BannerChrome(tint: palette.accent))
// Match `available(version:)` — see the note there on the 13pt inset (list's 5 + row's 8).
.padding(.horizontal, 13)
.padding(.top, 8)
.padding(.bottom, 8)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
/// Shared strip chrome: matches the sidebar mode switcher's tinted, hairline-bordered rounded card
/// (corner radius 9) so the banner reads as part of the same control stack.
private struct BannerChrome: ViewModifier {
let tint: Color
func body(content: Content) -> some View {
content
.padding(.horizontal, 10)
.padding(.vertical, 7)
.frame(maxWidth: .infinity, alignment: .leading)
.background(tint.opacity(0.10), in: .rect(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(tint.opacity(0.30), lineWidth: 1))
.contentShape(Rectangle())
}
}