135 lines
4.5 KiB
Swift
135 lines
4.5 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import SwiftUI
|
|
import TermindDomain
|
|
|
|
extension ConnectionStatus {
|
|
public var tint: Color {
|
|
switch self {
|
|
case .disconnected: .secondary
|
|
case .connecting, .reconnecting: .orange
|
|
case .connected: .green
|
|
case .failed: .red
|
|
}
|
|
}
|
|
|
|
public var label: String {
|
|
switch self {
|
|
case .disconnected: String(localized: AppStrings.Connections.notConnected)
|
|
case .connecting: String(localized: AppStrings.Connections.connecting)
|
|
case .connected: String(localized: AppStrings.Connections.connected)
|
|
case .reconnecting: String(localized: AppStrings.Connections.reconnecting)
|
|
case .failed(let message): message
|
|
}
|
|
}
|
|
|
|
public func showsIconBadge(hasUnread: Bool = false) -> Bool {
|
|
self != .disconnected || hasUnread
|
|
}
|
|
|
|
/// Connection progress takes precedence over unread output.
|
|
public func indicatorTint(hasUnread: Bool = false) -> Color {
|
|
hasUnread && !isBusy ? .accentColor : tint
|
|
}
|
|
}
|
|
|
|
/// Also usable on its own, where a disconnected connection still needs a gray dot.
|
|
public struct ConnectionStatusDot: View {
|
|
private let status: ConnectionStatus
|
|
private let hasUnread: Bool
|
|
private let size: CGFloat
|
|
private let pulses: Bool
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@State private var isPulsing = false
|
|
|
|
public init(status: ConnectionStatus, hasUnread: Bool = false, size: CGFloat = 7, pulses: Bool = true) {
|
|
self.status = status
|
|
self.hasUnread = hasUnread
|
|
self.size = size
|
|
self.pulses = pulses
|
|
}
|
|
|
|
public var body: some View {
|
|
let animatesPulse = isPulsing && pulses && !reduceMotion
|
|
Circle()
|
|
.fill(status.indicatorTint(hasUnread: hasUnread))
|
|
.frame(width: size, height: size)
|
|
.opacity(animatesPulse ? 0.3 : 1)
|
|
// Remove the repeating animation when settled, rather than animating back with it.
|
|
.animation(
|
|
animatesPulse ? .easeInOut(duration: 0.9).repeatForever(autoreverses: true) : nil,
|
|
value: animatesPulse
|
|
)
|
|
.onAppear { isPulsing = status.isBusy }
|
|
.onChange(of: status.isBusy) { _, busy in isPulsing = busy }
|
|
.accessibilityLabel(Text(status.label))
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
|
|
/// Platform surfaces supply their native background colors without importing UI frameworks here.
|
|
public struct ConnectionBadgeStyle {
|
|
let size: CGFloat
|
|
let padding: CGFloat
|
|
let background: Color
|
|
let border: Color
|
|
let borderWidth: CGFloat
|
|
let offset: CGSize
|
|
let pulses: Bool
|
|
|
|
public static func card(background: Color, pulses: Bool = true) -> Self {
|
|
Self(
|
|
size: 8, padding: 0, background: .clear, border: background, borderWidth: 1.5,
|
|
offset: .zero, pulses: pulses
|
|
)
|
|
}
|
|
|
|
public static var sidebar: Self {
|
|
Self(
|
|
size: 6, padding: 0, background: .clear, border: .clear, borderWidth: 0,
|
|
offset: CGSize(width: 3, height: 3), pulses: true
|
|
)
|
|
}
|
|
|
|
public static func welcome(background: Color, border: Color) -> Self {
|
|
Self(
|
|
size: 7, padding: 2, background: background, border: border, borderWidth: 0.5,
|
|
offset: CGSize(width: 2, height: 2), pulses: true
|
|
)
|
|
}
|
|
}
|
|
|
|
/// A status badge on any icon, without changing the icon's layout footprint.
|
|
public struct ConnectionStatusIcon<Icon: View>: View {
|
|
private let status: ConnectionStatus?
|
|
private let hasUnread: Bool
|
|
private let style: ConnectionBadgeStyle
|
|
private let icon: Icon
|
|
|
|
public init(
|
|
status: ConnectionStatus? = nil,
|
|
hasUnread: Bool = false,
|
|
style: ConnectionBadgeStyle,
|
|
@ViewBuilder icon: () -> Icon
|
|
) {
|
|
self.status = status
|
|
self.hasUnread = hasUnread
|
|
self.style = style
|
|
self.icon = icon()
|
|
}
|
|
|
|
public var body: some View {
|
|
icon.overlay(alignment: .bottomTrailing) {
|
|
if let status, status.showsIconBadge(hasUnread: hasUnread) {
|
|
ConnectionStatusDot(status: status, hasUnread: hasUnread, size: style.size, pulses: style.pulses)
|
|
.padding(style.padding)
|
|
.background(style.background, in: .circle)
|
|
.overlay { Circle().strokeBorder(style.border, lineWidth: style.borderWidth) }
|
|
.offset(style.offset)
|
|
}
|
|
}
|
|
}
|
|
}
|