diff options
| author | Dustin Mierau <dustin@mierau.me> | 2023-12-19 20:38:13 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2023-12-19 20:38:13 -0800 |
| commit | 4fd69c02a3e7b581bb9229865336c315153f3b18 (patch) | |
| tree | e6e11d872424196f2b4033077902126ad5556e40 /Hotline/macOS | |
| parent | 5e87b5927cd931d46fb5f72fb035480a95969a9f (diff) | |
Beginnings of a UI for macOS as well as some visual changes to iOS client. :)
Diffstat (limited to 'Hotline/macOS')
| -rw-r--r-- | Hotline/macOS/ChatView.swift | 191 | ||||
| -rw-r--r-- | Hotline/macOS/FilesView.swift | 174 | ||||
| -rw-r--r-- | Hotline/macOS/MessageBoardView.swift | 55 | ||||
| -rw-r--r-- | Hotline/macOS/MessageView.swift | 136 | ||||
| -rw-r--r-- | Hotline/macOS/NewsView.swift | 248 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 197 | ||||
| -rw-r--r-- | Hotline/macOS/TrackerView.swift | 191 |
7 files changed, 1192 insertions, 0 deletions
diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift new file mode 100644 index 0000000..5e6faaf --- /dev/null +++ b/Hotline/macOS/ChatView.swift @@ -0,0 +1,191 @@ +import SwiftUI + +enum FocusedField: Int, Hashable { + case chatInput +} + +struct ChatView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.colorScheme) var colorScheme + + @State var input: String = "" + @State private var scrollPos: Int? + @State private var contentHeight: CGFloat = 0 + + @FocusState private var focusedField: FocusedField? + + @Namespace var bottomID + + var body: some View { + NavigationStack { + ScrollViewReader { reader in + + VStack(alignment: .leading, spacing: 0) { + + // MARK: Scroll View + GeometryReader { gm in + ScrollView(.vertical) { + LazyVStack(alignment: .leading) { + ForEach(model.chat) { msg in + + // MARK: Agreement + if msg.type == .agreement { + if !msg.text.isEmpty { + HStack { + VStack(alignment: .leading) { + Text(msg.text) + .textSelection(.enabled) + .padding() + .opacity(0.75) + } + .frame(minWidth: 40, maxWidth: 400, alignment: .center) + .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow).cornerRadius(24)) + .padding() + } + .frame(maxWidth: .infinity) + .padding() + } + } + // MARK: Status + else if msg.type == .status { + HStack { + Spacer() + Text(msg.text) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.disabled) + .opacity(0.3) + Spacer() + } + .padding() + } + else { + HStack(alignment: .firstTextBaseline) { + if let username = msg.username { + Text("**\(username):** \(msg.text)") + .textSelection(.enabled) + } + else { + Text(msg.text) + .textSelection(.enabled) + } + Spacer() + } + .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + } + } + EmptyView().id(bottomID) + } + // .padding(.bottom, 12) + } + // .padding(.bottom, 60) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .defaultScrollAnchor(.bottom) + .onChange(of: model.chat.count) { + withAnimation { + reader.scrollTo(bottomID, anchor: .bottom) + } + print("SCROLLED TO BOTTOM") + } + .onAppear { + print("SCROLLED TO BOTTOM ON APPEAR") + reader.scrollTo(bottomID, anchor: .bottom) +// focusedField = .chatInput + } + .onChange(of: gm.size) { + reader.scrollTo(bottomID, anchor: .bottom) + } + } + } + + // MARK: Input Divider + Divider() + .padding(.bottom, 0) + + // MARK: Input Bar + HStack(alignment: .lastTextBaseline, spacing: 0) { + TextField("", text: $input, axis: .vertical) + .focused($focusedField, equals: .chatInput) + .textFieldStyle(.plain) + .lineLimit(1...5) + .multilineTextAlignment(.leading) +// .frame(maxWidth: .infinity) + .onSubmit { + if !self.input.isEmpty { + model.sendChat(self.input) + } + self.input = "" + } + .frame(maxWidth: .infinity) + .padding() + } + .frame(maxWidth: .infinity, minHeight: 28) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 8, trailing: 16)) + .overlay(alignment: .leadingLastTextBaseline) { + Image(systemName: "chevron.right").opacity(0.4).offset(x: 16) + } + .onContinuousHover { phase in + switch phase { + case .active(_): + NSCursor.iBeam.set() + case .ended: + NSCursor.arrow.set() + break + } + } + .onTapGesture { + focusedField = .chatInput + } + + +// .overlay(alignment: .bottom) { +// // MARK: Input Bar +// VStack(alignment: .leading, spacing: 0) { +// +// } +// } + // } + + // VStack(alignment: .center) { + // Spacer() + + + // .onKeyPress(keys: [.return]) { event in + // print(event) + // guard + // event.phase == .down, + // event.key == .return else { + // return .ignored + // } + // + // if event.modifiers.contains(.shift) { + // print("TRYING TO ADD NEW LINE") + // self.input += "\n" + // return .handled + // } + // + // if !self.input.isEmpty { + // model.sendChat(self.input) + // } + // self.input = "" + // + // return .handled + // } + // .onSubmit { + // if !self.input.isEmpty { + // model.sendChat(self.input) + // } + // self.input = "" + // } + + } + } + .background(Color(nsColor: .textBackgroundColor)) + // } + } +} + +#Preview { + ChatView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift new file mode 100644 index 0000000..534dd10 --- /dev/null +++ b/Hotline/macOS/FilesView.swift @@ -0,0 +1,174 @@ +import SwiftUI +import UniformTypeIdentifiers + +@Observable +class FileSelection: Equatable { + var selectedFile: FileInfo? = nil + + static func == (lhs: FileSelection, rhs: FileSelection) -> Bool { + return lhs.selectedFile == rhs.selectedFile + } +} + + +struct FileView: View { + @Environment(Hotline.self) private var model: Hotline + + @State var expanded = false + @State var loading = false + + var file: FileInfo + let depth: Int + + var body: some View { + HStack { + if file.isFolder { + Button { + if file.isFolder { + file.expanded.toggle() + } + } label: { + Image(systemName: file.expanded ? "chevron.down" : "chevron.right") + .renderingMode(.template) + .frame(width: 10, height: 10) + .aspectRatio(contentMode: .fit) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 12) + } + else { + HStack { + + }.frame(width: 12) + } + HStack(alignment: .center) { + if file.isFolder { + Image(systemName: "folder.fill") + } + else { + fileIcon(name: file.name) + .resizable() + .scaledToFill() + .frame(width: 16, height: 16) + } + } + .frame(width: 15) + Text(file.name).lineLimit(1).truncationMode(.tail) + Spacer() + if file.isFolder { + if loading { + ProgressView().controlSize(.small).padding(.trailing, 4) + } + Text("^[\(file.fileSize) file](inflect: true)") + .foregroundStyle(.secondary) + .lineLimit(1) + } + else { + Text(formattedFileSize(file.fileSize)).foregroundStyle(.secondary).lineLimit(1) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.leading, CGFloat(depth * (12 + 10))) + .onChange(of: file.expanded) { + loading = false + if file.isFolder { + print("EXPANDED \(file.name)? \(expanded)") + if file.expanded { + Task { + loading = true + let _ = await model.getFileList(path: file.path) + loading = false + } + } + } + } + + if file.expanded { + ForEach(file.children!, id: \.self) { childFile in + FileView(file: childFile, depth: self.depth + 1).tag(file.id) +// .environment(self.selectedFile) +// .frame(height: 34) + } + } + } + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + // let bcf = ByteCountFormatter() + FileView.byteFormatter.allowedUnits = [.useAll] + FileView.byteFormatter.countStyle = .file + return FileView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } + + private func fileIcon(name: String) -> Image { + let fileExtension = (name as NSString).pathExtension + return Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: fileExtension) ?? UTType.content)) + } +} + +struct FilesView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var selection: FileInfo? + + var body: some View { + NavigationStack { + List(model.files, id: \.self, selection: $selection) { file in + FileView(file: file, depth: 0).tag(file.id) +// .environment(self.fileSelection) +// .frame(height: 34) + } + .environment(\.defaultMinListRowHeight, 34) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .task { + if !model.filesLoaded { + let _ = await model.getFileList() + } + } + .contextMenu(forSelectionType: FileInfo.self) { items in + // ... + } primaryAction: { items in + print("ITEMS?", items) + guard let clickedFile = items.first else { + return + } + + self.selection = clickedFile + if clickedFile.isFolder { + clickedFile.expanded.toggle() + } + } + .onKeyPress(.rightArrow) { + if let s = selection, s.isFolder { + s.expanded = true + return .handled + } + return .ignored + } + .onKeyPress(.leftArrow) { + if let s = selection, s.isFolder { + s.expanded = false + return .handled + } + return .ignored + } + .overlay { + if !model.filesLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + } + } +} + +#Preview { + FilesView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/MessageBoardView.swift b/Hotline/macOS/MessageBoardView.swift new file mode 100644 index 0000000..d72b122 --- /dev/null +++ b/Hotline/macOS/MessageBoardView.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct MessageBoardView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var initialLoadComplete = false + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(model.messageBoard, id: \.self) { + Text($0) + .lineLimit(100) + .padding() + .textSelection(.enabled) + Divider() + } + } + Spacer() + } + .task { + if !model.messageBoardLoaded { + let _ = await model.getMessageBoard() +// self.initialLoadComplete = true + print("INITIAL LOAD?") + } + } + .overlay { + if !model.messageBoardLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .background(Color(nsColor: .textBackgroundColor)) + } + .toolbar { + ToolbarItem(placement:.primaryAction) { + Button { + + } label: { + Image(systemName: "square.and.pencil") + } + } + } + } +} + +#Preview { + MessageBoardView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift new file mode 100644 index 0000000..19b48f2 --- /dev/null +++ b/Hotline/macOS/MessageView.swift @@ -0,0 +1,136 @@ +import SwiftUI + +//extension View { +// func endEditing() { +// UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) +// } +//} + +struct MessageView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.colorScheme) var colorScheme + + @State var input: String = "" + @State private var scrollPos: Int? + @State private var contentHeight: CGFloat = 0 + + @Namespace var bottomID + + let userID: UInt + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + ScrollViewReader { reader in + ScrollView { + LazyVStack(alignment: .leading) { +// ForEach(model.chat) { msg in +// if msg.type == .agreement { +// VStack(alignment: .leading) { +// VStack(alignment: .leading, spacing: 0) { +// Text(msg.text) +// .textSelection(.enabled) +// .padding() +// .opacity(0.75) +// HStack { +// Spacer() +// Text((model.serverTitle) + " Server Agreement") +// .font(.caption) +// .fontWeight(.medium) +// .opacity(0.4) +// .lineLimit(1) +// .truncationMode(.middle) +// Spacer() +// } +// .padding() +// .background(colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.9)) +// } +// .background(colorScheme == .dark ? Color(white: 0.1) : Color(white: 0.96)) +// .cornerRadius(16) +// .frame(maxWidth: .infinity) +// } +// .padding() +// } +// else if msg.type == .status { +// HStack { +// Spacer() +// Text(msg.text) +// .lineLimit(1) +// .truncationMode(.middle) +// .opacity(0.3) +// Spacer() +// } +// .padding() +// } +// else { +// HStack(alignment: .firstTextBaseline) { +// if let username = msg.username { +// Text("**\(username):** \(msg.text)") +// } +// else { +// Text(msg.text) +// .textSelection(.enabled) +// } +// Spacer() +// } +// .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) +// } +// } + EmptyView().id(bottomID) + } + .padding(.bottom, 12) + } + .defaultScrollAnchor(.bottom) + .onChange(of: model.chat.count) { + withAnimation { + reader.scrollTo(bottomID, anchor: .bottom) + } + print("SCROLLED TO BOTTOM") + } + .onAppear { + print("SCROLLED TO BOTTOM ON APPEAR") + reader.scrollTo(bottomID, anchor: .bottom) + } + .scrollDismissesKeyboard(.interactively) + .onTapGesture { +// self.endEditing() + } + .textSelection(.enabled) + } + + Divider() + + HStack(alignment: .top) { + Image(systemName: "chevron.right").opacity(0.4) + TextField("", text: $input, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...5) + .onSubmit { +// if !self.input.isEmpty { +// model.sendChat(self.input) +// } + self.input = "" + } + .frame(maxWidth: .infinity) + Button { +// if !self.input.isEmpty { +// model.sendChat(self.input) +// } + self.input = "" + } label: { + Image(systemName: self.input.isEmpty ? "arrow.up.circle" : "arrow.up.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 24.0, height: 24.0) + .opacity(self.input.isEmpty ? 0.4 : 1.0) + } + }.padding() + } + } + } +} + +#Preview { + ChatView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/NewsView.swift b/Hotline/macOS/NewsView.swift new file mode 100644 index 0000000..b5752d3 --- /dev/null +++ b/Hotline/macOS/NewsView.swift @@ -0,0 +1,248 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct NewsItemView: View { + @Environment(Hotline.self) private var model: Hotline + + @State var expanded = false + + var news: NewsInfo + let depth: Int + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MM/dd/yy, h:mm a" + dateFormatter.timeZone = .gmt + return dateFormatter + }() + + var body: some View { + HStack { + if news.type == .bundle || news.type == .category { + Button { + news.expanded.toggle() + } label: { + Image(systemName: news.expanded ? "chevron.down" : "chevron.right") + .renderingMode(.template) + .frame(width: 10, height: 10) + .aspectRatio(contentMode: .fit) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 12) + } + if news.type == .article { + HStack(alignment: .center) { + Image(systemName: "quote.opening") + } + .frame(width: 15) + } + Text(news.name) + .fontWeight((news.type == .bundle || news.type == .category) ? .bold : .regular) + .lineLimit(1) + .truncationMode(.tail) + if news.type == .article && news.articleUsername != nil { + Text(news.articleUsername!).foregroundStyle(.secondary).lineLimit(1) + } + if news.type == .bundle || news.type == .category { + Text("^[\(news.count) \(news.type == .bundle ? "Category" : "Post")](inflect: true)") + +// Text("\(news.count) \(news.type == .bundle ? "Categories" : "Posts")") + .foregroundStyle(.secondary) + .lineLimit(1) + .padding([.leading, .trailing], 8) + .padding([.top, .bottom], 2) + .background(Capsule(style: .circular).stroke(.secondary.opacity(0.3), lineWidth: 1)) + } + Spacer() + if news.type == .article && news.articleUsername != nil { + if let d = news.articleDate { + Text(NewsItemView.dateFormatter.string(from: d)).foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.leading, CGFloat(depth * (12 + 10))) + .onChange(of: news.expanded) { +// if news.isFolder { + print("EXPANDED \(news.name)? \(expanded)") + if news.type == .bundle || news.type == .category { + Task { + await model.getNewsList(at: news.path) +// await model.getFileList(path: file.path) + } + } +// } + } + + if news.expanded { + ForEach(news.children, id: \.self) { childNews in + NewsItemView(news: childNews, depth: self.depth + 1).tag(childNews.id) + } + } + } + +// static let byteFormatter = ByteCountFormatter() +// +// private func formattedFileSize(_ fileSize: UInt) -> String { +// // let bcf = ByteCountFormatter() +// FileView.byteFormatter.allowedUnits = [.useAll] +// FileView.byteFormatter.countStyle = .file +// return FileView.byteFormatter.string(fromByteCount: Int64(fileSize)) +// } +// +// private func fileIcon(name: String) -> Image { +// let fileExtension = (name as NSString).pathExtension +// return Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: fileExtension) ?? UTType.content)) +// } +} + +struct NewsView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var selection: NewsInfo? + @State private var articleText: String? + + var body: some View { + NavigationStack { + VSplitView { + + // MARK: News Browser + List(model.news, id: \.self, selection: $selection) { newsItem in + NewsItemView(news: newsItem, depth: 0).tag(newsItem.id) + } + + .frame(maxWidth: .infinity, minHeight: 100, idealHeight: 100) + .environment(\.defaultMinListRowHeight, 34) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .task { + if !model.newsLoaded { + let _ = await model.getNewsList() + } + } + .contextMenu(forSelectionType: NewsInfo.self) { items in + // ... + } primaryAction: { items in + print("ITEMS?", items) + guard let clickedNews = items.first else { + return + } + + self.selection = clickedNews + if clickedNews.type == .bundle || clickedNews.type == .category { + clickedNews.expanded.toggle() + } + } + .onChange(of: selection) { + if + let article = selection, + article.type == .article { + self.articleText = nil + if +// let article = self.articleSelection.selectedArticle, + let articleFlavor = article.articleFlavors?.first, + let articleID = article.articleID { + Task { + if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { + self.articleText = articleText + } + } + } + } + } + .onKeyPress(.rightArrow) { + if let s = selection, s.type == .bundle || s.type == .category { + s.expanded = true + return .handled + } + return .ignored + } + .onKeyPress(.leftArrow) { + if let s = selection, s.type == .bundle || s.type == .category { + s.expanded = false + return .handled + } + return .ignored + } + .overlay { + if !model.newsLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + + // MARK: Article Viewer + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if let news = selection { + if news.type == .article { + + Text(news.name).font(.title) + .textSelection(.enabled) + .padding(.bottom, 8) + + if let poster = news.articleUsername, let postDate = news.articleDate { + HStack(alignment: .firstTextBaseline) { + Text(poster) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .padding(.bottom, 16) + Spacer() + Text("\(NewsItemView.dateFormatter.string(from: postDate))") + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .padding(.bottom, 16) + } + } + + Divider() + + if let newsText = self.articleText { + Text(newsText) + .textSelection(.enabled) + .lineSpacing(9) + .padding(.top, 16) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .frame(maxWidth: .infinity, minHeight: 200) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + } + .toolbar { + ToolbarItem(placement:.primaryAction) { + Button { + + } label: { + Image(systemName: "square.and.pencil") + } + } + + ToolbarItem(placement:.primaryAction) { + Button { + + } label: { + Image(systemName: "arrowshape.turn.up.left") + } + } + } + } +} + +#Preview { + NewsView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift new file mode 100644 index 0000000..eca6f54 --- /dev/null +++ b/Hotline/macOS/ServerView.swift @@ -0,0 +1,197 @@ +import SwiftUI + +enum MenuItemType { + case banner + case chat + case news + case messageBoard + case files + case tasks + case user +} + +struct MenuItem: Identifiable, Hashable { + let id: UUID + let name: String + let image: String + let type: MenuItemType + let userID: UInt? + let serverVersion: UInt? + + init(name: String, image: String, type: MenuItemType, userID: UInt? = nil, serverVersion: UInt? = nil) { + self.id = UUID() + self.name = name + self.image = image + self.type = type + self.userID = userID + self.serverVersion = serverVersion + } + + static func == (lhs: MenuItem, rhs: MenuItem) -> Bool { + if lhs.type == .user && rhs.type == .user { + return lhs.userID == rhs.userID + } + return lhs.id == rhs.id + } +} + +struct ListItemView: View { + let icon: String + let title: String + + var body: some View { + HStack { + Image(systemName: icon) + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + Text(title) +// Spacer() + } + //.tag(item.tag) + } +} + +private func connectionStatusToProgress(status: HotlineClientStatus) -> Double { + switch status { + case .disconnected: + return 0.0 + case .connecting: + return 0.1 + case .connected: + return 0.25 + case .loggingIn: + return 0.5 + case .loggedIn: + return 1.0 + } +} + +struct ServerView: View { + @Environment(Hotline.self) private var model: Hotline + @State private var selectedCategoryId: MenuItem.ID? + + let server: Server + + @State private var selection: MenuItem? = ServerView.menuItems.first + + static var menuItems = [ + MenuItem(name: "Chat", image: "bubble", type: .chat), + MenuItem(name: "News", image: "newspaper", type: .news, serverVersion: 150), + MenuItem(name: "Board", image: "note.text", type: .messageBoard), + MenuItem(name: "Files", image: "folder", type: .files), + MenuItem(name: "Tasks", image: "arrow.up.circle", type: .tasks), + ] + + + +// @State private var selection: String? + + var body: some View { + NavigationSplitView { +// Divider() + + List(selection: $selection) { + + HStack { + Text(server.name ?? "") + .fontWeight(.medium) + .lineLimit(2) + .font(.title3) + .multilineTextAlignment(.center) + .padding() + } + .selectionDisabled() + .frame(maxWidth: .infinity, minHeight: 60) + .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow).cornerRadius(16)) +// .background(.white.opacity(0.2)) + .cornerRadius(10) +// .shadow(color: .black.opacity(0.1), radius: 3, y: 2) + .tag(MenuItem(name: "title", image: "", type: .banner)) + .padding(.bottom, 16) + + if model.status != .loggedIn { + ProgressView(value: connectionStatusToProgress(status: model.status)) + .padding() + .selectionDisabled() + } + + if model.status == .loggedIn { + ForEach(ServerView.menuItems) { menuItem in + if let minServerVersion = menuItem.serverVersion { + if let v = model.serverVersion, v >= minServerVersion { + ListItemView(icon: menuItem.image, title: menuItem.name) + .tag(menuItem) + } + } + else { + ListItemView(icon: menuItem.image, title: menuItem.name) + .tag(menuItem) + } + } + + if model.users.count > 0 { + Section("Users") { + ForEach(model.users) { user in + HStack { + Text("🙂") + .font(.headline) + if user.status.contains(.admin) { + Text(user.name) + .foregroundStyle(.red, .red.opacity(0.3)) + } + else if user.status.contains(.idle) { + Text(user.name) + .opacity(0.5) + } + else { + Text(user.name) + } + Spacer() + } + .tag(MenuItem(name: user.name, image: "", type: .user, userID: user.id)) + } + } + } + } + } + } detail: { + if let selection = self.selection { + switch selection.type { + case .banner: + EmptyView() + case .chat: + ChatView() + case .news: + NewsView() + case .messageBoard: + MessageBoardView() + case .files: + FilesView() + case .tasks: + EmptyView() + case .user: + if let selectionUserID = selection.userID { + MessageView(userID: selectionUserID) + } + } + } + } + .navigationTitle("") + .onAppear { + Task { + await model.login(server: self.server, login: "", password: "", username: "bolt", iconID: 128) + } + } + .onDisappear { + print("DISCONNECTING FROM SERVER") + Task { + model.disconnect() + } + } + } +} + +//#Preview { +// ServerView(server: Server(name: "", description: "", address: "", port: 0)) +//} diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift new file mode 100644 index 0000000..684dd6a --- /dev/null +++ b/Hotline/macOS/TrackerView.swift @@ -0,0 +1,191 @@ +import SwiftUI + +struct TrackerView: View { + + // @Environment(\.modelContext) private var modelContext + // @Query private var items: [Item] + +// @Environment(Hotline.self) private var model: Hotline + @Environment(\.colorScheme) var colorScheme + @Environment(\.openWindow) private var openWindow + + private var client = HotlineTrackerClient() + + @MainActor + func updateServers() async { + let fetchedServers: [HotlineServer] = await self.client.fetchServers(address: "hltracker.com", port: Tracker.defaultPort) + + var newServers: [Server] = [] + + for s in fetchedServers { + if let serverName = s.name { + newServers.append(Server(name: serverName, description: s.description, address: s.address, port: Int(s.port), users: Int(s.users))) + } + } + + self.servers = newServers + } + +// private var model = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) + + // @State private var tracker = Tracker(address: "hltracker.com", service: trackerService) + + @State private var servers: [Server] = [] +// @State private var selectedServer: Server? + + @State private var selection: Server.ID? = nil + + @State private var scrollOffset: CGFloat = CGFloat.zero + @State private var initialLoadComplete = false + @State private var refreshing = false + @State private var topBarOpacity: Double = 1.0 + @State private var connectVisible = false + @State private var connectDismissed = true + @State private var serverVisible = false + + func shouldDisplayDescription(server: Server) -> Bool { + guard let desc = server.description else { + return false + } + + return desc.count > 0 && desc != server.name + } + + func connectionStatusToProgress(status: HotlineClientStatus) -> Double { + switch status { + case .disconnected: + return 0.0 + case .connecting: + return 0.1 + case .connected: + return 0.25 + case .loggingIn: + return 0.5 + case .loggedIn: + return 1.0 + } + } + + func inverseLerp(lower: Double, upper: Double, v: Double) -> Double { + return (v - lower) / (upper - lower) + } + +// func updateServers() async { + // "hltracker.com" + // "tracker.preterhuman.net" + // "hotline.ubersoft.org" + // "tracked.nailbat.com" + // "hotline.duckdns.org" + // "tracked.agent79.org" +// self.servers = await model.getServerList(tracker: "hltracker.com") +// } + + var body: some View { + // ZStack(alignment: .center) { + // VStack(alignment: .center) { + // ZStack(alignment: .top) { + // HStack(alignment: .center) { + // Button { + // connectVisible = true + // connectDismissed = false + // } label: { + // Text(Image(systemName: "gearshape.fill")) + // .symbolRenderingMode(.hierarchical) + // .foregroundColor(.primary) + // .font(.title2) + // .padding(.leading, 16) + // } + // .sheet(isPresented: $connectVisible) { + // connectDismissed = true + // } content: { + //// TrackerConnectView() + // } + // Spacer() + // } + // .frame(height: 40.0) + // Image("Hotline") + // .resizable() + // .renderingMode(.template) + // .foregroundColor(Color(hex: 0xE10000)) + // .scaledToFit() + // .frame(width: 40.0, height: 40.0) + // HStack(alignment: .center) { + // Spacer() + // Button { + // connectVisible = true + // connectDismissed = false + // } label: { + // Text(Image(systemName: "point.3.connected.trianglepath.dotted")) + // .symbolRenderingMode(.hierarchical) + // .foregroundColor(.primary) + // .font(.title2) + // .padding(.trailing, 16) + // } + // .sheet(isPresented: $connectVisible) { + // connectDismissed = true + // } content: { + // TrackerConnectView() + // } + // } + // .frame(height: 40.0) + // } + // .padding() + // + // Spacer() + // } + // .opacity(inverseLerp(lower: -50, upper: 0, v: scrollOffset)) + // .opacity(scrollOffset > 65 ? 0.0 : 1.0) + // .opacity(topBarOpacity) + // .zIndex(scrollOffset > 0 ? 1 : 3) + Table(of: Server.self, selection: $selection) { + TableColumn("Name") { server in + HStack { + Text(Image(systemName: "globe.americas.fill")) + Text(server.name!) + } + } + .width(min: 80, ideal: 150) + + TableColumn("Status") { server in + if server.users > 0 { + Text("\(server.users)") + } + else { + Text("") + } + + } + .width(50) + .alignment(.center) + + TableColumn("Description") { server in + Text(server.description ?? "") + } + } rows: { + ForEach(self.servers) { server in + TableRow(server) + } + } + .contextMenu(forSelectionType: Server.ID.self) { items in + // ... + } primaryAction: { items in + guard + let selectionID = items.first, + let selectedServer = self.servers.first(where: { $0.id == selectionID }) + else { + return + } + + openWindow(value: selectedServer) + } + .navigationTitle("Servers") + .task { + await updateServers() + initialLoadComplete = true + } + } +} + +#Preview { + TrackerView() +} |