104 lines
4.3 KiB
Swift
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())
|
|
}
|
|
}
|