diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-11-07 16:16:00 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-11-07 16:16:00 -0800 |
| commit | e1566598c96601ebcf3229aab22fc7a593ee1904 (patch) | |
| tree | 4486a58aad0df5df17ebcca0209d48dcfa3d6a5e /Hotline | |
| parent | a55318fa8d643160900bec3e6b14e7404c63497a (diff) | |
Further UI tweraks. Transfers button in toolbar. Better empty states in places. Fix race condition with transactions ids (yikes)!
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json | 23 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png | bin | 0 -> 883 bytes | |||
| -rw-r--r-- | Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png | bin | 0 -> 2039 bytes | |||
| -rw-r--r-- | Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png | bin | 0 -> 3584 bytes | |||
| -rw-r--r-- | Hotline/macOS/TrackerView.swift | 785 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerBookmarkServerView.swift | 38 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerBookmarkSheet.swift | 119 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerItemView.swift | 73 |
8 files changed, 253 insertions, 785 deletions
diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json b/Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json new file mode 100644 index 0000000..490868e --- /dev/null +++ b/Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Section Transfers.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Section Transfers@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Section Transfers@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png Binary files differnew file mode 100644 index 0000000..cbbd304 --- /dev/null +++ b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png Binary files differnew file mode 100644 index 0000000..2e8836b --- /dev/null +++ b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png Binary files differnew file mode 100644 index 0000000..a8086b7 --- /dev/null +++ b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift deleted file mode 100644 index 6fab091..0000000 --- a/Hotline/macOS/TrackerView.swift +++ /dev/null @@ -1,785 +0,0 @@ -import SwiftUI -import SwiftData -import Foundation -import UniformTypeIdentifiers - -enum TrackerSelection: Hashable { - case bookmark(Bookmark) - case bookmarkServer(BookmarkServer) - - var server: Server? { - switch self { - case .bookmark(let b): return b.server - case .bookmarkServer(let t): return t.server - } - } -} - -struct TrackerView: View { - @Environment(\.colorScheme) private var colorScheme - @Environment(\.openWindow) private var openWindow - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.modelContext) private var modelContext - - @State private var refreshing = false - @State private var trackerSheetPresented: Bool = false - @State private var trackerSheetBookmark: Bookmark? = nil - @State private var serverSheetBookmark: Bookmark? = nil - @State private var attemptedPrepopulate: Bool = false - @State private var fileDropActive = false - @State private var bookmarkExportActive = false - @State private var bookmarkExport: BookmarkDocument? = nil - @State private var expandedTrackers: Set<Bookmark> = [] - @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] - @State private var loadingTrackers: Set<Bookmark> = [] - @State private var fetchTasks: [Bookmark: Task<Void, Never>] = [:] - @State private var searchText: String = "" - @State private var isSearching = false - - @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark] - @Binding var selection: TrackerSelection? - - private var filteredBookmarks: [Bookmark] { - guard !self.searchText.isEmpty else { - return self.bookmarks - } - - let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init) - - return self.bookmarks.filter { bookmark in - // Always show tracker bookmarks (filter only their servers) - if bookmark.type == .tracker { - return true - } - - // Filter server bookmarks by search text - return self.bookmarkMatchesSearch(bookmark, searchWords: searchWords) - } - } - - private func bookmarkMatchesSearch(_ bookmark: Bookmark, searchWords: [String]) -> Bool { - let searchableText = "\(bookmark.name) \(bookmark.address)".lowercased() - - // All search words must match - return searchWords.allSatisfy { word in - searchableText.contains(word) - } - } - - private func filteredServers(for bookmark: Bookmark) -> [BookmarkServer] { - let servers = self.trackerServers[bookmark] ?? [] - print("TrackerView.filteredServers: Looking up servers for \(bookmark.name), found \(servers.count) servers") - - guard !self.searchText.isEmpty else { - return servers - } - - let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init) - - return servers.filter { server in - let searchableText = "\(server.name ?? "") \(server.address) \(server.description ?? "")".lowercased() - - // All search words must match - return searchWords.allSatisfy { word in - searchableText.contains(word) - } - } - } - - var body: some View { - List(selection: $selection) { - ForEach(filteredBookmarks, id: \.self) { bookmark in - TrackerItemView( - bookmark: bookmark, - isExpanded: self.expandedTrackers.contains(bookmark), - isLoading: self.loadingTrackers.contains(bookmark), - count: self.trackerServers[bookmark]?.count ?? 0 - ) { - self.toggleExpanded(for: bookmark) - } - .tag(TrackerSelection.bookmark(bookmark)) - - if bookmark.type == .tracker && self.expandedTrackers.contains(bookmark) { - ForEach(self.filteredServers(for: bookmark), id: \.self) { trackedServer in - TrackerBookmarkServerView(server: trackedServer) - .moveDisabled(true) - .deleteDisabled(true) - .tag(TrackerSelection.bookmarkServer(trackedServer)) - .padding(.leading, 16 + 8 + 10) - } - } - } - .onMove { movedIndexes, destinationIndex in - Bookmark.move(movedIndexes, to: destinationIndex, context: modelContext) - } - .onDelete { deletedIndexes in - Bookmark.delete(at: deletedIndexes, context: modelContext) - } - } - .onDeleteCommand { - switch self.selection { - case .bookmark(let bookmark): - Bookmark.delete(bookmark, context: modelContext) - default: - break - } - -// if let bookmark = selection, -// bookmark.type != .temporary { -// Bookmark.delete(bookmark, context: modelContext) -// } - } - .environment(\.defaultMinListRowHeight, 34) - .listStyle(.inset) - .alternatingRowBackgrounds(.enabled) - .onChange(of: AppState.shared.cloudKitReady) { - if attemptedPrepopulate { - print("Tracker: Already attempted to prepopulate bookmarks") - return - } - - print("Tracker: Prepopulating bookmarks") - - attemptedPrepopulate = true - - // Make sure default bookmarks are there when empty. - Bookmark.populateDefaults(context: modelContext) - } - .onAppear { -// Bookmark.deleteAll(context: modelContext) - } - .contextMenu(forSelectionType: TrackerSelection.self) { items in - if let item = items.first { - switch item { - case .bookmark(let bookmark): - self.bookmarkContextMenu(bookmark) - case .bookmarkServer(let server): - self.bookmarkServerContextMenu(server) - } - } - } primaryAction: { items in - guard let clickedItem = items.first else { - return - } - - switch clickedItem { - case .bookmark(let bookmark): - if bookmark.type == .server { - if let s = bookmark.server { - openWindow(id: "server", value: s) - } - } - else if bookmark.type == .tracker { - if NSEvent.modifierFlags.contains(.option) { - trackerSheetBookmark = bookmark - } - else { - self.toggleExpanded(for: bookmark) - } - } - - case .bookmarkServer(let bookmarkServer): - openWindow(id: "server", value: bookmarkServer.server) - } - } - .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in - switch result { - case .success(let fileURL): - print("Hotline Bookmark: Successfully exported:", fileURL) - case .failure(let err): - print("Hotline Bookmark: Failed to export:", err) - } - - bookmarkExport = nil - bookmarkExportActive = false - }, onCancellation: {}) - .onKeyPress(.rightArrow) { - switch self.selection { - case .bookmark(let bookmark): - if bookmark.type == .tracker { - self.setExpanded(true, for: bookmark) - return .handled - } - default: - break - } - - return .ignored - } - .onKeyPress(.leftArrow) { - switch self.selection { - case .bookmark(let bookmark): - if bookmark.type == .tracker { - self.setExpanded(false, for: bookmark) - return .handled - } - default: - break - } - - return .ignored - } - .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in - for provider in providers { - let _ = provider.loadDataRepresentation(for: UTType.fileURL) { dataRepresentation, err in - // HOTLINE CREATOR CODE: 1213484099 - // HOTLINE BOOKMARK TYPE CODE: 1213489773 - - if let filePathData = dataRepresentation, - let filePath = String(data: filePathData, encoding: .utf8), - let fileURL = URL(string: filePath) { - - print("Hotline Bookmark: Dropped from ", fileURL.path(percentEncoded: false)) - - DispatchQueue.main.async { - if let newBookmark = Bookmark(fileURL: fileURL) { - print("Hotline Bookmark: Added bookmark.") - Bookmark.add(newBookmark, context: modelContext) - } - else { - print("Hotline Bookmark: Failed to parse.") - } - } - } - } - } - - return true - } - .sheet(item: $trackerSheetBookmark) { item in - TrackerBookmarkSheet(item) - } - .sheet(isPresented: $trackerSheetPresented) { - TrackerBookmarkSheet() - } - .sheet(item: $serverSheetBookmark) { item in - ServerBookmarkSheet(item) - } - .navigationTitle("Servers") - .toolbar { - if #available(macOS 26.0, *) { - ToolbarItem(placement: .navigation) { - self.hotlineLogoImage - } - .sharedBackgroundVisibility(.hidden) - } - else { - ToolbarItem(placement: .navigation) { - self.hotlineLogoImage - } - } - - ToolbarItem(placement: .primaryAction) { - Button { - self.refreshing = true - self.refresh() - self.refreshing = false - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .disabled(refreshing) - .help("Refresh Trackers") - } - - ToolbarItem(placement: .primaryAction) { - Button { - trackerSheetPresented = true - } label: { - Label("Add Tracker", systemImage: "point.3.filled.connected.trianglepath.dotted") - } - .help("Add Tracker") - } - - ToolbarItem(placement: .primaryAction) { - Button { - openWindow(id: "server") - } label: { - Label("Connect to Server", systemImage: "globe.americas.fill") - } - .help("Connect to Server") - } - } - .onOpenURL(perform: { url in - if let s = Server(url: url) { - openWindow(id: "server", value: s) - } - }) - .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") - .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) - } - - private var hotlineLogoImage: some View { - Image("Hotline") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(Color(hex: 0xE10000)) - .frame(width: 9) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } - - @ViewBuilder - func bookmarkServerContextMenu(_ server: BookmarkServer) -> some View { - Button { - let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil) - Bookmark.add(newBookmark, context: modelContext) - } label: { - Label("Bookmark", systemImage: "bookmark") - } - - Divider() - - Button { - NSPasteboard.general.clearContents() - let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" - NSPasteboard.general.setString(displayAddress, forType: .string) - } label: { - Label("Copy Address", systemImage: "doc.on.doc") - } - } - - @ViewBuilder - func bookmarkContextMenu(_ bookmark: Bookmark) -> some View { - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(bookmark.displayAddress, forType: .string) - } label: { - Label("Copy Address", systemImage: "doc.on.doc") - } - - Divider() - - if bookmark.type == .tracker { - Button { - trackerSheetBookmark = bookmark - } label: { - Label("Edit Tracker...", systemImage: "pencil") - } - } - - if bookmark.type == .server { - Button { - serverSheetBookmark = bookmark - } label: { - Label("Edit Bookmark...", systemImage: "pencil") - } - - Button { - bookmarkExport = BookmarkDocument(bookmark: bookmark) - bookmarkExportActive = true - } label: { - Label("Export Bookmark...", systemImage: "square.and.arrow.down") - } - } - - Divider() - - Button { - Bookmark.delete(bookmark, context: modelContext) - } label: { - Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") - } - } - - - func refresh() { - // When a tracker is selected, refresh only that tracker. - if let trackerSelection = self.selection { - switch trackerSelection { - case .bookmark(let bookmark): - if bookmark.type == .tracker { - if self.expandedTrackers.contains(bookmark) { - // Already expanded, cancel old fetch and start new one - self.fetchTasks[bookmark]?.cancel() - let task = Task { - await self.fetchServers(for: bookmark) - } - self.fetchTasks[bookmark] = task - } else { - // Not expanded, expand it (which also fetches) - self.setExpanded(true, for: bookmark) - } - return - } - break - default: - break - } - } - - // Otherwise refresh/expand all trackers. - for bookmark in self.bookmarks { - if bookmark.type == .tracker { - if self.expandedTrackers.contains(bookmark) { - // Already expanded, cancel old fetch and start new one - self.fetchTasks[bookmark]?.cancel() - let task = Task { - await self.fetchServers(for: bookmark) - } - self.fetchTasks[bookmark] = task - } else { - // Not expanded, expand it (which also fetches) - self.setExpanded(true, for: bookmark) - } - } - } - } - - func toggleExpanded(for bookmark: Bookmark) { - guard bookmark.type == .tracker else { return } - self.setExpanded(!self.expandedTrackers.contains(bookmark), for: bookmark) - } - - func setExpanded(_ expanded: Bool, for bookmark: Bookmark) { - guard bookmark.type == .tracker else { return } - - if expanded && !self.expandedTrackers.contains(bookmark) { - self.expandedTrackers.insert(bookmark) - let task = Task { - await self.fetchServers(for: bookmark) - } - self.fetchTasks[bookmark] = task - } else if !expanded && self.expandedTrackers.contains(bookmark) { - // Cancel ongoing fetch and clear data - self.fetchTasks[bookmark]?.cancel() - self.fetchTasks[bookmark] = nil - self.expandedTrackers.remove(bookmark) - self.trackerServers[bookmark] = nil - self.loadingTrackers.remove(bookmark) - } - } - - private func fetchServers(for bookmark: Bookmark) async { - print("TrackerView.fetchServers: Starting fetch for bookmark: \(bookmark.name)") - self.loadingTrackers.insert(bookmark) - let servers = await bookmark.fetchServers() - print("TrackerView.fetchServers: Got \(servers.count) servers from bookmark.fetchServers()") - await MainActor.run { - print("TrackerView.fetchServers: Assigning \(servers.count) servers to trackerServers[\(bookmark.name)]") - self.trackerServers[bookmark] = servers - self.loadingTrackers.remove(bookmark) - self.fetchTasks[bookmark] = nil // Clean up completed task - print("TrackerView.fetchServers: trackerServers now has \(self.trackerServers.count) entries") - print("TrackerView.fetchServers: Verification - trackerServers[\(bookmark.name)] now has \(self.trackerServers[bookmark]?.count ?? -1) servers") - } - } -} - -struct TrackerBookmarkSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - - @State private var bookmark: Bookmark? = nil - @State private var trackerAddress: String = "" - @State private var trackerName: String = "" - - init() { - - } - - init(_ editingBookmark: Bookmark) { - _bookmark = .init(initialValue: editingBookmark) - _trackerAddress = .init(initialValue: editingBookmark.displayAddress) - _trackerName = .init(initialValue: editingBookmark.name) - } - - var body: some View { - VStack(alignment: .leading) { - Form { - if self.bookmark == nil { - HStack(alignment: .top, spacing: 10) { - GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) - .frame(width: 28, height: 28) - - VStack(alignment: .leading) { - Text("Add a Hotline Tracker") - - Text("Enter the address and name of a Hotline Tracker you want to add.") - .foregroundStyle(.secondary) - .font(.subheadline) - } - } - } - else { - HStack(alignment: .top, spacing: 10) { - GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) - .frame(width: 28, height: 28) - - VStack(alignment: .leading) { - Text("Edit Hotline Tracker") - - Text("Change the address and name of your Hotline Tracker.") - .foregroundStyle(.secondary) - .font(.subheadline) - } - } - } - - Group { - TextField(text: $trackerAddress) { - Text("Address") - } - TextField(text: $trackerName, prompt: Text("Optional")) { - Text("Name") - } - } -// .textFieldStyle(.roundedBorder) - .controlSize(.large) - } - .formStyle(.grouped) - } - .frame(width: 350) - .fixedSize(horizontal: true, vertical: true) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button { - self.saveTracker() - } label: { - if self.bookmark != nil { - Text("Save") - } - else { - Text("Add") - } - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.trackerName = "" - self.trackerAddress = "" - - self.dismiss() - } - } - } - } - - private func saveTracker() { - var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines) - let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress) - - if displayName.isEmpty { - displayName = host - } - - if !displayName.isEmpty && !host.isEmpty { - if !host.isEmpty { - if self.bookmark != nil { - // We're editing an existing bookmark. - self.bookmark?.name = displayName - self.bookmark?.address = host - self.bookmark?.port = port - } - else { - // We're creating a new bookmark. - let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port) - Bookmark.add(newBookmark, context: modelContext) - } - - self.trackerName = "" - self.trackerAddress = "" - - self.dismiss() - } - } - } -} - -struct ServerBookmarkSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - - @State private var bookmark: Bookmark - @State private var serverName: String = "" - @State private var serverAddress: String = "" - @State private var serverLogin: String = "" - @State private var serverPassword: String = "" - - init(_ editingBookmark: Bookmark) { - _bookmark = .init(initialValue: editingBookmark) - _serverName = .init(initialValue: editingBookmark.name) - _serverAddress = .init(initialValue: editingBookmark.displayAddress) - _serverLogin = .init(initialValue: editingBookmark.login ?? "") - _serverPassword = .init(initialValue: editingBookmark.password ?? "") - } - - var body: some View { - VStack(alignment: .leading) { - Form { - Group { - TextField(text: $serverName) { - Text("Name:") - } - .padding(.bottom) - - TextField(text: $serverAddress) { - Text("Address:") - } - TextField(text: $serverLogin, prompt: Text("Optional")) { - Text("Login:") - } - SecureField(text: $serverPassword, prompt: Text("Optional")) { - Text("Password:") - } - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - } - } - .frame(width: 300) - .fixedSize(horizontal: true, vertical: true) - .padding() - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Save Bookmark") { - let displayName = self.serverName.trimmingCharacters(in: .whitespacesAndNewlines) - let (host, port) = Server.parseServerAddressAndPort(self.serverAddress) - let login = self.serverLogin.trimmingCharacters(in: .whitespacesAndNewlines) - let password = self.serverPassword - - if !displayName.isEmpty && !host.isEmpty { - self.bookmark.name = displayName - self.bookmark.address = host - self.bookmark.port = port - self.bookmark.login = login.isEmpty ? nil : login - self.bookmark.password = password.isEmpty ? nil : password - - self.dismiss() - } - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.dismiss() - } - } - } - } -} - -struct TrackerBookmarkServerView: View { - let server: BookmarkServer - - var body: some View { - HStack(alignment: .center, spacing: 6) { - Image("Server") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16, alignment: .center) - Text(self.server.name ?? "Server").lineLimit(1).truncationMode(.tail) - if let serverDescription = self.server.description { - Text(serverDescription) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - Spacer(minLength: 0) - if self.server.users > 0 { - Text(String(self.server.users)) - .foregroundStyle(.secondary) - .lineLimit(1) - - Circle() - .fill(.fileComplete) - .frame(width: 7, height: 7) - .keyframeAnimator(initialValue: 1.0, repeating: true) { content, opacity in - content.opacity(opacity) - } keyframes: { _ in - CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second - CubicKeyframe(0.6, duration: 0.5) // Fade out quickly - CubicKeyframe(1.0, duration: 0.5) // Fade in quickly - } - .padding(.trailing, 6) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -struct TrackerItemView: View { - let bookmark: Bookmark - let isExpanded: Bool - let isLoading: Bool - let count: Int - let onToggleExpanded: () -> Void - @Environment(\.appearsActive) private var appearsActive - - var body: some View { - HStack(alignment: .center, spacing: 6) { - if bookmark.type == .tracker { - Button { - self.onToggleExpanded() - } label: { - Text(Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right")) - .bold() - .font(.system(size: 10)) - .opacity(0.5) - .frame(alignment: .center) - } - .buttonStyle(.plain) - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 2) - } - - switch bookmark.type { - case .tracker: - Image("Tracker") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16, alignment: .center) - Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail) - if isLoading { - ProgressView() - .padding([.leading, .trailing], 2) - .controlSize(.small) - } - Spacer(minLength: 0) - if isExpanded && count > 0 { - HStack(spacing: 4) { - Text(String(count)) - - SpinningGlobeView() - .fontWeight(.semibold) - .frame(width: 12, height: 12) - } - .padding(.horizontal, 6) - .padding(.vertical, 2) - .foregroundStyle(.secondary) -// .background(.quinary) - .clipShape(.capsule) - } - case .server: - Image(systemName: "bookmark.fill") - .resizable() - .scaledToFit() - .foregroundStyle(Color.secondary) - .frame(width: 11, height: 11, alignment: .center) - .opacity(0.75) - .padding(.leading, 3) - .padding(.trailing, 2) - Image("Server") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16, alignment: .center) - Text(bookmark.name).lineLimit(1).truncationMode(.tail) - Spacer(minLength: 0) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -#if DEBUG -private struct TrackerViewPreview: View { - @State var selection: TrackerSelection? = nil - - var body: some View { - TrackerView(selection: $selection) - } -} - -#Preview { - TrackerViewPreview() -} -#endif diff --git a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift new file mode 100644 index 0000000..dc42ea9 --- /dev/null +++ b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift @@ -0,0 +1,38 @@ +struct TrackerBookmarkServerView: View { + let server: BookmarkServer + + var body: some View { + HStack(alignment: .center, spacing: 6) { + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(self.server.name ?? "Server").lineLimit(1).truncationMode(.tail) + if let serverDescription = self.server.description { + Text(serverDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 0) + if self.server.users > 0 { + Text(String(self.server.users)) + .foregroundStyle(.secondary) + .lineLimit(1) + + Circle() + .fill(.fileComplete) + .frame(width: 7, height: 7) + .keyframeAnimator(initialValue: 1.0, repeating: true) { content, opacity in + content.opacity(opacity) + } keyframes: { _ in + CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second + CubicKeyframe(0.6, duration: 0.5) // Fade out quickly + CubicKeyframe(1.0, duration: 0.5) // Fade in quickly + } + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +}
\ No newline at end of file diff --git a/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift new file mode 100644 index 0000000..4943016 --- /dev/null +++ b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift @@ -0,0 +1,119 @@ +struct TrackerBookmarkSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var bookmark: Bookmark? = nil + @State private var trackerAddress: String = "" + @State private var trackerName: String = "" + + init() { + + } + + init(_ editingBookmark: Bookmark) { + _bookmark = .init(initialValue: editingBookmark) + _trackerAddress = .init(initialValue: editingBookmark.displayAddress) + _trackerName = .init(initialValue: editingBookmark.name) + } + + var body: some View { + VStack(alignment: .leading) { + Form { + if self.bookmark == nil { + HStack(alignment: .top, spacing: 10) { + GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Add a Hotline Tracker") + + Text("Enter the address and name of a Hotline Tracker you want to add.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + else { + HStack(alignment: .top, spacing: 10) { + GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Edit Hotline Tracker") + + Text("Change the address and name of your Hotline Tracker.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + + Group { + TextField(text: $trackerAddress) { + Text("Address") + } + TextField(text: $trackerName, prompt: Text("Optional")) { + Text("Name") + } + } +// .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + .formStyle(.grouped) + } + .frame(width: 350) + .fixedSize(horizontal: true, vertical: true) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + self.saveTracker() + } label: { + if self.bookmark != nil { + Text("Save") + } + else { + Text("Add") + } + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.trackerName = "" + self.trackerAddress = "" + + self.dismiss() + } + } + } + } + + private func saveTracker() { + var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines) + let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress) + + if displayName.isEmpty { + displayName = host + } + + if !displayName.isEmpty && !host.isEmpty { + if !host.isEmpty { + if self.bookmark != nil { + // We're editing an existing bookmark. + self.bookmark?.name = displayName + self.bookmark?.address = host + self.bookmark?.port = port + } + else { + // We're creating a new bookmark. + let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port) + Bookmark.add(newBookmark, context: modelContext) + } + + self.trackerName = "" + self.trackerAddress = "" + + self.dismiss() + } + } + } +}
\ No newline at end of file diff --git a/Hotline/macOS/Trackers/TrackerItemView.swift b/Hotline/macOS/Trackers/TrackerItemView.swift new file mode 100644 index 0000000..59caad5 --- /dev/null +++ b/Hotline/macOS/Trackers/TrackerItemView.swift @@ -0,0 +1,73 @@ +struct TrackerItemView: View { + let bookmark: Bookmark + let isExpanded: Bool + let isLoading: Bool + let count: Int + let onToggleExpanded: () -> Void + @Environment(\.appearsActive) private var appearsActive + + var body: some View { + HStack(alignment: .center, spacing: 6) { + if bookmark.type == .tracker { + Button { + self.onToggleExpanded() + } label: { + Text(Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .opacity(0.5) + .frame(alignment: .center) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 2) + } + + switch bookmark.type { + case .tracker: + Image("Tracker") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail) + if isLoading { + ProgressView() + .padding([.leading, .trailing], 2) + .controlSize(.small) + } + Spacer(minLength: 0) + if isExpanded && count > 0 { + HStack(spacing: 4) { + Text(String(count)) + + SpinningGlobeView() + .fontWeight(.semibold) + .frame(width: 12, height: 12) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundStyle(.secondary) +// .background(.quinary) + .clipShape(.capsule) + } + case .server: + Image(systemName: "bookmark.fill") + .resizable() + .scaledToFit() + .foregroundStyle(Color.secondary) + .frame(width: 11, height: 11, alignment: .center) + .opacity(0.75) + .padding(.leading, 3) + .padding(.trailing, 2) + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(bookmark.name).lineLimit(1).truncationMode(.tail) + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +}
\ No newline at end of file |