diff options
| author | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-11-27 23:38:04 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <git@r.bdr.sh> | 2025-11-27 23:38:04 +0100 |
| commit | 213710bf5bd6413c747bf126db50816ef5de5a6e (patch) | |
| tree | 912f8cf87955a08077c92fea8ad934f50b7ab975 /Hotline/macOS/ServerView.swift | |
| parent | f466b21dc02f78c984ba6748e703f6780a7a0db4 (diff) | |
| parent | 6a95b53616a4abfa306ddce43151cf4fefbd20ed (diff) | |
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'Hotline/macOS/ServerView.swift')
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 707 |
1 files changed, 302 insertions, 405 deletions
diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 3a25925..4026e92 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -1,28 +1,6 @@ import SwiftUI import UniformTypeIdentifiers - -@Observable -class ServerState: Equatable { - var id: UUID = UUID() - var selection: ServerNavigationType - - init(selection: ServerNavigationType) { - self.selection = selection - } - - static func == (lhs: ServerState, rhs: ServerState) -> Bool { - return lhs.id == rhs.id - } -} - -enum MenuItemType { - case chat - case news - case messageBoard - case files - case tasks - case user -} +import AppKit struct ServerMenuItem: Identifiable, Hashable { let id: UUID @@ -69,10 +47,8 @@ struct ListItemView: View { if let i = icon { Image(i) .resizable() - // .renderingMode(.template) .scaledToFit() .frame(width: 20, height: 20) - // .padding(.leading, 2) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) } @@ -90,80 +66,39 @@ struct ListItemView: View { } } -struct ActiveHotlineModelFocusedValueKey: FocusedValueKey { - typealias Value = Hotline -} - -struct ActiveServerStateFocusedValueKey: FocusedValueKey { - typealias Value = ServerState -} - extension FocusedValues { - var activeHotlineModel: Hotline? { - get { self[ActiveHotlineModelFocusedValueKey.self] } - set { self[ActiveHotlineModelFocusedValueKey.self] = newValue } - } - - var activeServerState: ServerState? { - get { self[ActiveServerStateFocusedValueKey.self] } - set { self[ActiveServerStateFocusedValueKey.self] = newValue } - } -} - -enum ServerNavigationType: Identifiable, Hashable, Equatable { - var id: String { - switch self { - case .chat: - return "Chat" - case .news: - return "News" - case .board: - return "Board" - case .files: - return "Files" - case .accounts: - return "Accounts" - case .user(let userID): - return String(userID) - } - } - - case chat - case news - case board - case files - case accounts - case user(userID: UInt16) + @Entry var activeHotlineModel: HotlineState? + @Entry var activeServerState: ServerState? } struct ServerView: View { - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @Environment(\.controlActiveState) private var controlActiveState @Environment(\.scenePhase) private var scenePhase @Environment(\.modelContext) private var modelContext + + @Binding var server: Server + + @State private var model: HotlineState = HotlineState() @State private var reconnectTimer: Task<Void, Never>? @State private var shouldReconnect: Bool = false - @State private var model: Hotline = Hotline( - trackerClient: HotlineTrackerClient(), client: HotlineClient()) @State private var state: ServerState = ServerState(selection: .chat) @State private var agreementShown: Bool = false @State private var connectAddress: String = "" @State private var connectLogin: String = "" @State private var connectPassword: String = "" - @State private var connectNameSheetPresented: Bool = false - @State private var connectName: String = "" - @State private var autoconnect: Bool = false - - @Binding var server: Server + @State private var connectionDisplayed: Bool = false +// @State private var accountsShown: Bool = false + @State private var autoconnect: Bool = false static var menuItems: [ServerMenuItem] = [ ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"), ServerMenuItem(type: .board, name: "Board", image: "Section Board"), ServerMenuItem(type: .news, name: "News", image: "Section News"), ServerMenuItem(type: .files, name: "Files", image: "Section Files"), - ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), +// ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -171,21 +106,19 @@ struct ServerView: View { ServerMenuItem(type: .board, name: "Board", image: "Section Board"), ServerMenuItem(type: .files, name: "Files", image: "Section Files"), ] - - enum FocusFields { - case address - case login - case password - } - - @FocusState private var focusedField: FocusFields? - + var body: some View { Group { - if model.status == .disconnected { - connectForm - .navigationTitle("Connect to Server") - } else if model.status != .loggedIn { + if self.model.status == .disconnected { + VStack(alignment: .center) { + Spacer() + self.connectForm + Spacer() + } + .presentedWindowToolbarStyle(.unified(showsTitle: false)) + .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) + } + else if self.model.status.isLoggingIn { HStack { Image("Hotline") .resizable() @@ -193,41 +126,80 @@ struct ServerView: View { .scaledToFit() .foregroundColor(Color(hex: 0xE10000)) .frame(width: 18) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + .opacity(self.controlActiveState == .inactive ? 0.5 : 1.0) .padding(.trailing, 4) - ProgressView(value: connectionStatusToProgress(status: model.status)) { - Text(connectionStatusToLabel(status: model.status)) + ProgressView(value: connectionStatusToProgress(status: self.model.status)) { + Text(connectionStatusToLabel(status: self.model.status)) } - .accentColor(colorScheme == .dark ? .white : .black) + .accentColor(self.colorScheme == .dark ? .white : .black) } .frame(maxWidth: 300) .padding() - .navigationTitle("Connecting to Server") - } else { - serverView - .environment(model) - .onChange(of: Prefs.shared.userIconID) { sendPreferences() } - .onChange(of: Prefs.shared.username) { sendPreferences() } - .onChange(of: Prefs.shared.refusePrivateMessages) { sendPreferences() } - .onChange(of: Prefs.shared.refusePrivateChat) { sendPreferences() } - .onChange(of: Prefs.shared.enableAutomaticMessage) { sendPreferences() } - .onChange(of: Prefs.shared.automaticMessage) { sendPreferences() } + .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) + } + else if self.model.status == .loggedIn { + self.serverView + .environment(self.model) + .onChange(of: Prefs.shared.userIconID) { + Task { try? await self.model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.username) { + Task { try? await self.model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.refusePrivateMessages) { + Task { try? await self.model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.refusePrivateChat) { + Task { try? await self.model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.enableAutomaticMessage) { + Task { try? await self.model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.automaticMessage) { + Task { try? await self.model.sendUserPreferences() } + } + .sheet(isPresented: self.$state.broadcastShown) { + BroadcastMessageSheet() + .environment(self.model) + .presentationSizing(.fitted) + } + .sheet(isPresented: self.$state.accountsShown) { + AccountManagerView() + .environment(self.model) + .frame(width: 400, height: 450) + .presentationSizing(.fitted) + } .toolbar { - ToolbarItem(placement: .navigation) { - Image("Server Large") - // .renderingMode(.template) - - .resizable() - .scaledToFit() - .frame(width: 28) - .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + if #available(macOS 26.0, *) { + ToolbarItem(placement: .navigation) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28) + .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0) + } + .sharedBackgroundVisibility(.hidden) + } + else { + ToolbarItem(placement: .navigation) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28) + .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0) + } } } } } .onDisappear { - model.disconnect() + Task { + await self.model.disconnect() + } + } + .onChange(of: self.model.serverTitle) { + self.state.serverName = self.model.serverTitle } .onChange(of: model.status) { if model.status == .loggedIn { @@ -239,181 +211,84 @@ struct ServerView: View { startReconnectTimer() } } - .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) { - Button("OK") {} - } - .task { - var address = server.address - if server.port != HotlinePorts.DefaultServerPort { - address += ":\(server.port)" + .onAppear { + var address = self.server.address + if self.server.port != HotlinePorts.DefaultServerPort { + address += ":\(self.server.port)" } - connectAddress = server.address - connectLogin = server.login - connectPassword = server.password - + self.connectAddress = self.server.address + self.connectLogin = self.server.login + self.connectPassword = self.server.password + // Connect to server automatically unless the option key is held down. if !NSEvent.modifierFlags.contains(.option) { - connectToServer() + self.connectToServer() + } + + self.connectionDisplayed = true + } + .alert("Something Went Wrong", isPresented: self.$model.errorDisplayed) { + Button("OK") {} + } message: { + if let message = self.model.errorMessage, + !message.isBlank { + Text(message) } } .focusedSceneValue(\.activeHotlineModel, model) .focusedSceneValue(\.activeServerState, state) } - - private func startReconnectTimer() { - reconnectTimer = Task { - while !Task.isCancelled { - connectToServer() - try? await Task.sleep(for: .seconds(5)) - } + + private var connectForm: some View { + ConnectView(address: self.$connectAddress, login: self.$connectLogin, password: self.$connectPassword) { + self.connectToServer() } - } - - var connectForm: some View { - VStack(alignment: .center) { - GroupBox { - Form { - Group { - TextField(text: $connectAddress) { - Text("Address:") - } - .focused($focusedField, equals: .address) - - Text( - "Type the address of the Hotline server you would like to connect to. If you have an account on that server, type your login and password too." - ) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.bottom, 4) - - TextField(text: $connectLogin, prompt: Text("Optional")) { - Text("Login:") - } - .focused($focusedField, equals: .login) - SecureField(text: $connectPassword, prompt: Text("Optional")) { - Text("Password:") - } - .focused($focusedField, equals: .password) - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - - HStack { - Button("Save...") { - if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - connectNameSheetPresented = true - } - } - .disabled(connectAddress.isEmpty) - .controlSize(.regular) - .buttonStyle(.automatic) - .help("Bookmark server") - - Spacer() - - Button("Cancel") { - dismiss() - } - .controlSize(.regular) - .buttonStyle(.automatic) - .keyboardShortcut(.cancelAction) - - Button("Connect") { - Task { - connectToServer() - } - } - - .controlSize(.regular) - .buttonStyle(.automatic) - .keyboardShortcut(.defaultAction) - } - .padding(.top, 8) - - } - .padding() - .onChange(of: connectAddress) { - let (a, p) = Server.parseServerAddressAndPort(connectAddress) - server.address = a - server.port = p - } - .onChange(of: connectLogin) { - server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) - } - .onChange(of: connectPassword) { - server.password = connectPassword - } - } - .onAppear { - focusedField = .address - } + .focusSection() + .onChange(of: self.connectAddress) { + let (a, p) = Server.parseServerAddressAndPort(self.connectAddress) + self.server.address = a + self.server.port = p } - .frame(maxWidth: 380) - .padding() - .sheet(isPresented: $connectNameSheetPresented) { - VStack(alignment: .leading) { - Text("Name this server bookmark:") - .foregroundStyle(.secondary) - .padding(.bottom, 4) - TextField("Bookmark Name", text: $connectName) - .textFieldStyle(.roundedBorder) - .controlSize(.large) - Toggle("Connect on Startup", isOn: $autoconnect) - } - .frame(width: 250) - .padding() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - connectNameSheetPresented = false - connectName = "" - } - } - - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - let name = String(connectName.trimmingCharacters(in: .whitespacesAndNewlines)) - if !name.isEmpty { - connectNameSheetPresented = false - connectName = "" - Task.detached { - let (host, port) = await Server.parseServerAddressAndPort(connectAddress) - let login: String? = await connectLogin.isEmpty ? nil : connectLogin - let password: String? = await connectPassword.isEmpty ? nil : connectPassword - - if !host.isEmpty { - let newBookmark = await Bookmark( - type: .server, name: name, address: host, port: port, login: login, - password: password, autoconnect: autoconnect) - await MainActor.run { - Bookmark.add(newBookmark, context: modelContext) - } - } - } - } - } - } - } - .onAppear { - autoconnect = false - } + .onChange(of: self.connectLogin) { + self.server.login = self.connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) + } + .onChange(of: self.connectPassword) { + self.server.password = self.connectPassword + } + .onAppear { + autoconnect = false } } - var navigationList: some View { + private var navigationList: some View { List(selection: $state.selection) { // Don't show news on older servers. ForEach(model.serverVersion < 151 ? ServerView.classicMenuItems : ServerView.menuItems) { menuItem in if menuItem.type == .chat { - ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat) - .tag(menuItem.type) - } else if menuItem.type == .accounts { - if model.access?.contains(.canOpenUsers) == true { - ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag( - menuItem.type) - } - } else { + ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat).tag(menuItem.type) + } +// else if menuItem.type == .board { +// if self.model.access?.contains(.canReadMessageBoard) == true { +// ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) +// } +// } +// else if menuItem.type == .accounts { +// if model.access?.contains(.canOpenUsers) == true { +// ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) +// } +// } + else if menuItem.type == .files { + ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) + .overlay(alignment: .trailing) { + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.mini) + .padding(.trailing, 4) + } + } + } + else { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) } } @@ -443,34 +318,32 @@ struct ServerView: View { } var transfersSection: some View { - // Section("Transfers") { ForEach(model.transfers) { transfer in - TransferItemView(transfer: transfer) + ServerTransferRow(transfer: transfer) } - // } } var usersSection: some View { - // Section("\(model.users.count) Online") { ForEach(model.users) { user in HStack(spacing: 5) { - if let iconImage = Hotline.getClassicIcon(Int(user.iconID)) { + if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { Image(nsImage: iconImage) .frame(width: 16, height: 16) .padding(.leading, 2) .padding(.trailing, 2) - } else { + } + else { Image("User") .frame(width: 16, height: 16) .padding(.leading, 2) .padding(.trailing, 2) } - + Text(user.name) .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary) - + Spacer() - + if model.hasUnreadInstantMessages(userID: user.id) { Circle() .frame(width: 6, height: 6) @@ -482,91 +355,82 @@ struct ServerView: View { .opacity(controlActiveState == .inactive ? 0.5 : 1.0) .tag(ServerNavigationType.user(userID: user.id)) } - // } } var serverView: some View { NavigationSplitView { self.navigationList - .frame(maxWidth: .infinity) - .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500) + .navigationSplitViewColumnWidth(200) +// .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 400) + .toolbar(removing: .sidebarToggle) +// .toolbar { +// if self.model.access?.contains(.canOpenUsers) == true { +// ToolbarItem(placement: .primaryAction) { +// Button { +// self.state.accountsShown = true +// } label: { +// Label("Manage Accounts", systemImage: "gear") +// } +// .help("Manage Accounts") +// } +// } +// } } detail: { - switch state.selection { - case .chat: - ChatView() - .navigationTitle(model.serverTitle) - .navigationSubtitle("Public Chat") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) - case .news: - NewsView() - .navigationTitle(model.serverTitle) - .navigationSubtitle("Newsgroups") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) - case .board: - MessageBoardView() - .navigationTitle(model.serverTitle) - .navigationSubtitle("Message Board") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) - case .files: - FilesView() - .navigationTitle(model.serverTitle) - .navigationSubtitle("Shared Files") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) - case .accounts: - AccountManagerView() - .navigationTitle(model.serverTitle) - .navigationSubtitle("Accounts") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) - case .user(let userID): - let user = model.users.first(where: { $0.id == userID }) - MessageView(userID: userID) - .navigationTitle(model.serverTitle) - .navigationSubtitle(user?.name ?? "Private Message") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) - .onAppear { - model.markInstantMessagesAsRead(userID: userID) - } - } + switch state.selection { + case .chat: + ChatView() + case .news: + NewsView() + case .board: + MessageBoardView() + case .files: + FilesView() + case .user(let userID): + MessageView(userID: userID) + } } - .toolbar(removing: .sidebarToggle) - + .navigationTitle(self.model.serverTitle) } - + // MARK: - @MainActor func connectToServer() { - guard !server.address.isEmpty else { + guard !self.server.address.isEmpty else { return } - model.login(server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID) { - success in - if !success { - print("FAILED LOGIN??") - model.disconnect() - } else { - sendPreferences() - model.getUserList() - model.downloadBanner() + + // Set status here so it's immediate (not waiting to enter task). + self.model.status = .connecting + + Task { @MainActor in + do { + try await self.model.login( + server: server, + username: Prefs.shared.username, + iconID: Prefs.shared.userIconID + ) + } catch { + print("ServerView: Login failed: \(error)") } } } - - private func connectionStatusToProgress(status: HotlineClientStatus) -> Double { + + private func connectionStatusToProgress(status: HotlineConnectionStatus) -> Double { switch status { case .disconnected: return 0.0 case .connecting: return 0.4 case .connected: - return 0.75 - case .loggingIn: return 0.9 case .loggedIn: return 1.0 + case .failed: + return 0.0 } } - private func connectionStatusToLabel(status: HotlineClientStatus) -> String { + private func connectionStatusToLabel(status: HotlineConnectionStatus) -> String { let n = server.name ?? server.address switch status { case .disconnected: @@ -574,88 +438,69 @@ struct ServerView: View { case .connecting: return "Connecting to \(n)..." case .connected: - return "Connected to \(n)" - case .loggingIn: return "Logging in to \(n)..." case .loggedIn: return "Logged in to \(n)" + case .failed(let error): + return "Failed: \(error)" } } - - @MainActor func sendPreferences() { - if self.model.status == .loggedIn { - var options: HotlineUserOptions = HotlineUserOptions() - - if Prefs.shared.refusePrivateMessages { - options.update(with: .refusePrivateMessages) - } - - if Prefs.shared.refusePrivateChat { - options.update(with: .refusePrivateChat) - } - - if Prefs.shared.enableAutomaticMessage { - options.update(with: .automaticResponse) - } - - print("Updating preferences with server") - - self.model.sendUserInfo( - username: Prefs.shared.username, iconID: Prefs.shared.userIconID, options: options, - autoresponse: Prefs.shared.automaticMessage) - } - } + } -//#Preview { -// ServerView(server: Server(name: "", description: "", address: "", port: 0)) -//} - -struct TransferItemView: View { +struct ServerTransferRow: View { let transfer: TransferInfo @Environment(\.controlActiveState) private var controlActiveState - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var hovered: Bool = false @State private var buttonHovered: Bool = false - - private func formattedProgressHelp() -> String { - if self.transfer.completed { - return "File transfer complete" - } else if self.transfer.failed { - return "File transfer failed" - } else if self.transfer.progress > 0.0 { - if self.transfer.timeRemaining > 0.0 { - return - "\(round(self.transfer.progress * 100.0))% – \(self.transfer.timeRemaining) seconds left" - } else { - return "\(round(self.transfer.progress * 100.0))% complete" - } - } - return "" - } - + @State private var detailsShown: Bool = false + var body: some View { HStack(alignment: .center, spacing: 5) { HStack(spacing: 0) { Spacer() - FileIconView(filename: transfer.title, fileType: nil) - .frame(width: 16, height: 16) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - // .padding(.leading, 2) + if self.transfer.isFolder { + Image("Folder") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + else { + FileIconView(filename: transfer.title, fileType: nil) + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } Spacer() } .frame(width: 20) - - Text(transfer.title) + + Text(self.transfer.folderName ?? self.transfer.title) .lineLimit(1) .truncationMode(.middle) - - Spacer() - + + Spacer(minLength: 0) + + if !self.transfer.done { + if self.transfer.progress == 0.0 { + ProgressView() + .progressViewStyle(.linear) + .controlSize(.extraLarge) + .frame(maxWidth: 40) + } + else { + ProgressView(value: self.transfer.progress, total: 1.0) + .progressViewStyle(.linear) + .controlSize(.extraLarge) + .frame(maxWidth: 40) + } + } + if self.hovered { Button { - model.deleteTransfer(id: transfer.id) + AppState.shared.cancelTransfer(id: transfer.id) } label: { Image(systemName: self.buttonHovered ? "xmark.circle.fill" : "xmark.circle") .resizable() @@ -686,21 +531,73 @@ struct TransferItemView: View { .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } else if transfer.progress == 0.0 { - ProgressView() - .progressViewStyle(.circular) - .controlSize(.small) - } else { - ProgressView(value: transfer.progress, total: 1.0) - .progressViewStyle(.circular) - .controlSize(.small) } } .onHover { hovered in - withAnimation(.easeOut(duration: 0.25)) { + withAnimation(.snappy(duration: 0.25, extraBounce: 0.3)) { self.hovered = hovered } +// self.detailsShown = hovered + } + .onTapGesture(count: 2) { + guard transfer.completed, let url = transfer.fileURL else { + return + } + + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + .popover(isPresented: .constant(self.detailsShown && !self.transfer.done), arrowEdge: .trailing) { + let rows: [(String, String)] = [ + ("document", self.transfer.title), + ("info", self.transfer.displaySize), + (self.transfer.isUpload ? "arrow.up" : "arrow.down", self.transfer.displaySpeed ?? "--"), + ("clock", self.transfer.displayTimeRemaining ?? "--") + ] + + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(rows, id: \.0) { imageName, label in + GridRow { + Image(systemName: imageName) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .gridColumnAlignment(.trailing) + Text(label) + .monospacedDigit() + .gridColumnAlignment(.leading) + } + } + } + .frame(minWidth: 200, maxWidth: 350, alignment: .leading) + .padding() + } + } + + private var formattedProgressHelp: String { + if self.transfer.completed { + return "File transfer complete" + } + else if self.transfer.failed { + return "File transfer failed" + } + else if self.transfer.cancelled { + return "File transfer cancelled" + } + else if self.transfer.progress > 0.0 { + var parts: [String] = [] + + if let speed = self.transfer.displaySpeed { + parts.append(speed) + } + + if let timeRemaining = self.transfer.displayTimeRemaining { + parts.append(timeRemaining) + } + + if parts.count > 0 { + return parts.joined(separator: " • ") + } } - .help(formattedProgressHelp()) + return "" } } |