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/iOS | |
| parent | 5e87b5927cd931d46fb5f72fb035480a95969a9f (diff) | |
Beginnings of a UI for macOS as well as some visual changes to iOS client. :)
Diffstat (limited to 'Hotline/iOS')
| -rw-r--r-- | Hotline/iOS/ChatView.swift | 153 | ||||
| -rw-r--r-- | Hotline/iOS/FilesView.swift | 145 | ||||
| -rw-r--r-- | Hotline/iOS/MessageBoardView.swift | 74 | ||||
| -rw-r--r-- | Hotline/iOS/NewsView.swift | 235 | ||||
| -rw-r--r-- | Hotline/iOS/ServerView.swift | 51 | ||||
| -rw-r--r-- | Hotline/iOS/TrackerView.swift | 397 | ||||
| -rw-r--r-- | Hotline/iOS/UsersView.swift | 41 |
7 files changed, 1096 insertions, 0 deletions
diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift new file mode 100644 index 0000000..e8c7754 --- /dev/null +++ b/Hotline/iOS/ChatView.swift @@ -0,0 +1,153 @@ +import SwiftUI + +extension View { + func endEditing() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} + +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 + + @Namespace var bottomID + + 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() + } + } + + Divider() + + HStack(alignment: .top) { + Image(systemName: "chevron.right").opacity(0.4) + TextField("", text: $input, axis: .vertical) + .autocapitalization(.none) + .lineLimit(1...5) + .onSubmit { + if !self.input.isEmpty { + model.sendChat(self.input) + // hotline.sendChat(message: self.input) + } + self.input = "" + } + .frame(maxWidth: .infinity) + Button { + if !self.input.isEmpty { + model.sendChat(self.input) + // hotline.sendChat(message: 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() + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(model.serverTitle) + .font(.headline) + } + ToolbarItem(placement: .navigationBarLeading) { + Button { + model.disconnect() + } label: { + Text(Image(systemName: "xmark.circle.fill")) + .symbolRenderingMode(.hierarchical) + .font(.title2) + .foregroundColor(.secondary) + } + } + } + + } + } +} + +#Preview { + ChatView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/iOS/FilesView.swift b/Hotline/iOS/FilesView.swift new file mode 100644 index 0000000..5815ca2 --- /dev/null +++ b/Hotline/iOS/FilesView.swift @@ -0,0 +1,145 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FileView: View { + @Environment(Hotline.self) private var model: Hotline + + @State var expanded = false + + var file: FileInfo + + var body: some View { + if file.isFolder { + DisclosureGroup(isExpanded: $expanded) { + ForEach(file.children!) { childFile in + FileView(file: childFile) + .frame(height: 44) + } + } label: { + HStack { + HStack(alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) { + Image(systemName: "folder.fill") + } + .frame(minWidth: 25) + Text(file.name).fontWeight(.medium).lineLimit(1).truncationMode(.tail) + Spacer() + Text("\(file.fileSize)").foregroundStyle(.secondary).lineLimit(1) + } + } + .onChange(of: expanded) { + if !expanded { + return + } + + Task { + await model.getFileList(path: file.path) + } + } + } + else { + HStack { + HStack(alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) { + fileIcon(name: file.name) + .renderingMode(.template) + } + .frame(minWidth: 25) + Text(file.name).lineLimit(1).truncationMode(.tail) + Spacer() + Text(formattedFileSize(file.fileSize)).foregroundStyle(.secondary).lineLimit(1) + } + } + } + + 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 { + // func utTypeForFilename(_ filename: String) -> UTType? { + let fileExtension = (name as NSString).pathExtension + if let fileType = UTType(filenameExtension: fileExtension) { + print("\(name) \(fileExtension) = \(fileType)") + + if fileType.isSubtype(of: .movie) { + return Image(systemName: "play.rectangle") +// return UIImage(systemName: "play.rectangle")! + } + else if fileType.isSubtype(of: .image) { + return Image(systemName: "photo") +// return UIImage(systemName: "photo")! + } + else if fileType.isSubtype(of: .archive) { + return Image(systemName: "doc.zipper") +// return UIImage(systemName: "doc.zipper")! + } + else if fileType.isSubtype(of: .text) { + return Image(systemName: "doc.text") +// return UIImage(systemName: "doc.text")! + } + else { + return Image(systemName: "doc") +// return UIImage(systemName: "doc")! + } + } + + return Image(systemName: "doc") + } +} + +struct FilesView: View { + @Environment(Hotline.self) private var model: Hotline + + @State var initialLoadComplete = false + + var body: some View { + NavigationStack { + List(model.files) { file in + FileView(file: file) + .frame(height: 44) + } + .task { + if !initialLoadComplete { + let _ = await model.getFileList() + initialLoadComplete = true + } + } + .overlay { + if !initialLoadComplete { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .listStyle(.plain) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(model.serverTitle) + .font(.headline) + } + ToolbarItem(placement: .navigationBarLeading) { + Button { + model.disconnect() + } label: { + Text(Image(systemName: "xmark.circle.fill")) + .symbolRenderingMode(.hierarchical) + .font(.title2) + .foregroundColor(.secondary) + } + } + } + } + } +} + +#Preview { + FilesView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/iOS/MessageBoardView.swift b/Hotline/iOS/MessageBoardView.swift new file mode 100644 index 0000000..0d3968f --- /dev/null +++ b/Hotline/iOS/MessageBoardView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct MessageBoardView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var initialLoadComplete = false + @State private var fetched = 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 !initialLoadComplete { + let _ = await model.getMessageBoard() + initialLoadComplete = true + } + } + .overlay { + if !initialLoadComplete { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .refreshable { + let _ = await model.getMessageBoard() + initialLoadComplete = true + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(model.serverTitle) + .font(.headline) + } + ToolbarItem(placement: .navigationBarLeading) { + Button { + model.disconnect() + } label: { + Text(Image(systemName: "xmark.circle.fill")) + .symbolRenderingMode(.hierarchical) + .font(.title2) + .foregroundColor(.secondary) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + + } label: { + Image(systemName: "square.and.pencil") + } + } + } + } + + } +} + +#Preview { + MessageBoardView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/iOS/NewsView.swift b/Hotline/iOS/NewsView.swift new file mode 100644 index 0000000..d3e0bbd --- /dev/null +++ b/Hotline/iOS/NewsView.swift @@ -0,0 +1,235 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct NewsItemView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(NewsItemSelection.self) private var selectedArticle: NewsItemSelection + + let news: NewsInfo + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MM/dd/yy, h:mm a" + dateFormatter.timeZone = .gmt + return dateFormatter + }() + + @State var expanded = false + + var body: some View { + if news.count > 0 { + DisclosureGroup(isExpanded: $expanded) { + ForEach(news.children) { childNews in + NewsItemView(news: childNews) + .frame(height: 38) + .environment(self.selectedArticle) + } + } label: { + HStack { + Text(news.name) + .fontWeight(.bold) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + if news.count > 0 { + Text("\(news.count)") + .foregroundStyle(.secondary) + } + } + } + .onChange(of: expanded) { + if !expanded { + return + } + + Task { + await model.getNewsList(at: news.path) + } + } + } + else { + HStack { + Text(Image(systemName: "quote.opening")) + Text(news.name) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + if news.count > 0 { + Text("\(news.count)") + .foregroundStyle(.secondary) + } + } + .onTapGesture { + if news.type == .article { + print("SELECTED", news.name) + selectedArticle.selectedArticle = news + } + } + } + } +} + +@Observable +class NewsItemSelection: Equatable { + var selectedArticle: NewsInfo? = nil + + static func == (lhs: NewsItemSelection, rhs: NewsItemSelection) -> Bool { + return lhs.selectedArticle == rhs.selectedArticle + } +} + +struct NewsView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.colorScheme) var colorScheme + + @State private var fetched = false + @State private var selectedCategory: NewsInfo? = nil + @State private var topListHeight: CGFloat = 200 + @State private var dividerHeight: CGFloat = 30 + + @State private var articleSelection = NewsItemSelection() + @State private var articleText = "" + +// @State private var selectedArticleID: UInt? + + var articleList: some View { + VStack(spacing: 0) { + if model.news.count == 0 { + Text("No News Available") + .font(.headline) + .opacity(0.3) + } + else { + List(model.news) { category in + NewsItemView(news: category) + .frame(height: 38) + .environment(self.articleSelection) + } + .scrollBounceBehavior(.basedOnSize) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .systemGroupedBackground)) + + // .listStyle(.plain) + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + articleList + .frame(height: topListHeight) + .frame(minHeight: topListHeight) + .onChange(of: self.articleSelection.selectedArticle) { + self.articleText = "" + 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 + } + } + } +// print("SELECTED ARTICLE", articleSelection.selectedArticle?.name) + } + + // Movable Divider + VStack(alignment: .center) { + Divider() + Spacer() + HStack(alignment: .center) { + Rectangle() + .fill(.tertiary) + .frame(width: 50, height: 6, alignment: .center) + .cornerRadius(10) + } + Spacer() + } + .background(colorScheme == .dark ? Color(white: 0.1) : Color(uiColor: UIColor.systemBackground)) + .frame(maxWidth: .infinity) + .frame(height: dividerHeight) + .gesture( + DragGesture() + .onChanged { gesture in + let delta = gesture.translation.height + topListHeight = max(min(topListHeight + delta, 500), 50) + } + ) + + // Reader View + ScrollView(.vertical) { + VStack(alignment: .leading) { + + if let news = self.articleSelection.selectedArticle { + if let poster = news.articleUsername, let postDate = news.articleDate { + HStack(alignment: .firstTextBaseline) { + Text(poster) + .foregroundStyle(.secondary) + .font(.subheadline) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .padding() + Spacer() + Text("\(NewsItemView.dateFormatter.string(from: postDate))") + .foregroundStyle(.secondary) + .font(.subheadline) + .lineLimit(1) + .textSelection(.enabled) + .padding() + } + .background(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8)).fill(Color(uiColor: .tertiarySystemFill))) +// Capsule(style: .circular).fill(.secondary)) +// .padding(.bottom, 16) + .padding(.bottom, 8) + } + + Text(news.name).font(.title3) + .textSelection(.enabled) + + Divider() + } + + Text(self.articleText) + .multilineTextAlignment(.leading) + .padding(.top, 16) + } + .padding() + } + .scrollBounceBehavior(.basedOnSize) + .background(colorScheme == .dark ? Color(white: 0.1) : Color(uiColor: UIColor.systemBackground)) + } + .task { + if !fetched { + let _ = await model.getNewsList() + fetched = true + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(model.serverTitle) + .font(.headline) + } + ToolbarItem(placement: .navigationBarLeading) { + Button { + model.disconnect() + } label: { + Text(Image(systemName: "xmark.circle.fill")) + .symbolRenderingMode(.hierarchical) + .font(.title2) + .foregroundColor(.secondary) + } + } + } + } + } +} + +#Preview { + MessageBoardView() + .environment(HotlineState()) + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/iOS/ServerView.swift b/Hotline/iOS/ServerView.swift new file mode 100644 index 0000000..09ab877 --- /dev/null +++ b/Hotline/iOS/ServerView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct ServerView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.colorScheme) var colorScheme + + enum Tab { + case chat, users, news, messageBoard, files + } + + var body: some View { + TabView { + ChatView() + .tabItem { + Image(systemName: "bubble") + } + .tag(Tab.chat) + + UsersView() + .tabItem { + Image(systemName: "person.2") + } + .tag(Tab.users) + + if let v = model.serverVersion, v >= 150 { + NewsView() + .tabItem { + Image(systemName: "newspaper") + } + .tag(Tab.news) + } + + MessageBoardView() + .tabItem { + Image(systemName: "book.closed") + } + .tag(Tab.messageBoard) + + FilesView() + .tabItem { + Image(systemName: "folder").tint(.black) + } + .tag(Tab.files) + } + .accentColor(colorScheme == .dark ? .white : .black) + } +} + +#Preview { + ServerView() +} diff --git a/Hotline/iOS/TrackerView.swift b/Hotline/iOS/TrackerView.swift new file mode 100644 index 0000000..6204fd2 --- /dev/null +++ b/Hotline/iOS/TrackerView.swift @@ -0,0 +1,397 @@ +import SwiftUI + +struct TrackerConnectView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var colorScheme + + @State private var server: Server? + @State private var address = "" + @State private var login = "" + @State private var password = "" + @State private var connecting = false + + 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 + } + } + + var body: some View { + VStack(alignment: .leading) { + if !connecting { + TextField("Server Address", text: $address) + .keyboardType(.URL) + .disableAutocorrection(true) + .frame(height: 48) + .textFieldStyle(.plain) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) + } + TextField("Login", text: $login) + .disableAutocorrection(true) + .frame(height: 48) + .textFieldStyle(.plain) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) + } + SecureField("Password", text: $password) + .disableAutocorrection(true) + .textFieldStyle(.plain) + .frame(height: 48) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) + } + } + else { + ProgressView(value: connectionStatusToProgress(status: model.status)) + .frame(minHeight: 10) + .accentColor(colorScheme == .dark ? .white : .black) + } + + Spacer() + + HStack { + Button { + dismiss() + server = nil + model.disconnect() + } label: { + Text("Cancel") + } + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(colorScheme == .dark ? .white : .black) + .background( + colorScheme == .dark ? + LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) + : + LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) + ) + .overlay( + RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) + ) + .cornerRadius(10.0) + Button { + let s = Server(name: nil, description: nil, address: address, port: Server.defaultPort, users: 0) + server = s + connecting = true + Task { + let loggedIn = await model.login(server: s, login: login, password: password, username: "bolt", iconID: 128) + if !loggedIn { + connecting = false + } + } + } label: { + Text("Connect") + } + .disabled(connecting) + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(colorScheme == .dark ? .white : .black) + .background( + colorScheme == .dark ? + LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) + : + LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) + ) + .overlay( + RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) + ) + .cornerRadius(10.0) + } + .padding() + } + .padding() + .onChange(of: model.status) { + print("MODEL STATUS CHANGED") + if model.server != nil && server != nil && model.server! == server! { + if model.status == .loggedIn { + dismiss() + } + else { + connecting = (model.status != .disconnected) + } + } + } + .toolbar { + ToolbarItem(placement: .principal) { + Text("Connect to Server") + .font(.headline) + } + } +// .presentationBackground(.regularMaterial, in: Color(uiColor: .systemGroupedBackground)) + .presentationBackground { + Color.clear + .background(Material.regular) + } + .presentationDetents([.height(300), .large]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(20) + // .background(Color(uiColor: .systemGroupedBackground)) + } +} + +struct TrackerView: View { + + // @Environment(\.modelContext) private var modelContext + // @Query private var items: [Item] + + @Environment(Hotline.self) private var model: Hotline + @Environment(\.colorScheme) var colorScheme + + // @State private var tracker = Tracker(address: "hltracker.com", service: trackerService) + + @State private var servers: [Server] = [] + @State private var selectedServer: Server? + @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) + ObservableScrollView(scrollOffset: $scrollOffset) { + LazyVStack(alignment: .leading) { + ForEach(self.servers) { server in + VStack(alignment: .leading) { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "globe.americas.fill").font(.title3) + VStack(alignment: .leading) { + Text(server.name ?? server.address).font(.title3).fontWeight(.medium) + if shouldDisplayDescription(server: server) { + Spacer() + Text(server.description!).opacity(0.5).font(.system(size: 16)) + } + Spacer() + Text("\(server.address):" + String(format: "%i", server.port)).opacity(0.3).font(.system(size: 13)) + } + Spacer() + if server.users > 0 { + Text("\(server.users)").opacity(0.3).font(.system(size: 16)).fontWeight(.medium) + } + } + if server == selectedServer { + Spacer(minLength: 16) + + if model.status != .disconnected && model.server != nil && model.server! == server { + ProgressView(value: connectionStatusToProgress(status: model.status)) + .frame(minHeight: 10) + .accentColor(colorScheme == .dark ? .white : .black) + } + else { + Button("Connect") { + Task { + await model.login(server: server, login: "", password: "", username: "bolt", iconID: 128) + } + } + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(colorScheme == .dark ? .white : .black) + .background( + colorScheme == .dark ? + LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) + : + LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) + ) + .overlay( + RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) + ) + .cornerRadius(10.0) + } + } + } + .multilineTextAlignment(.leading) + .padding() + .background(colorScheme == .dark ? Color(white: 0.12) : .white) + .cornerRadius(16) + .shadow(color: Color(white: 0.0, opacity: 0.1), radius: 16, x: 0, y: 10) + .onTapGesture { + withAnimation(.bouncy(duration: 0.25, extraBounce: 0.2)) { + selectedServer = server + } + } + } + .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + } + .padding(EdgeInsets(top: 75, leading: 0, bottom: 0, trailing: 0)) + } + .zIndex(2) + .overlay { + if !initialLoadComplete { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .refreshable { + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.1)) { + topBarOpacity = 0.0 + } + initialLoadComplete = true + } + + model.disconnectTracker() + await updateServers() + + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 1.0).delay(0.75)) { + topBarOpacity = 1.0 + } + } + } + + + } + .fullScreenCover(isPresented: Binding(get: { return (connectDismissed && serverVisible) }, set: { _ in }), onDismiss: { + model.disconnect() + }) { + ServerView() + } + .onChange(of: model.status) { + serverVisible = (model.status == .loggedIn) + } + .background(Color(uiColor: UIColor.systemGroupedBackground)) + .frame(maxWidth: .infinity) + .task { + await updateServers() + initialLoadComplete = true + } + .onOpenURL(perform: { url in + guard url.scheme == "hotline" else { + return + } + + if let address = url.host() { + let login = url.user(percentEncoded: false) ?? "" + let password = url.password(percentEncoded: false) ?? "" + let port = url.port ?? Server.defaultPort + + Task { + model.disconnect() + let _ = await model.login(server: Server(name: nil, description: nil, address: address, port: port, users: 0), login: login, password: password, username: "bolt", iconID: 128) + } + + // TODO: Find a better way to show login status when trying to connect outside of the Tracker server list. Perhaps this opens the connect sheet prefilled. + } + }) + } +} + +#Preview { + TrackerView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/iOS/UsersView.swift b/Hotline/iOS/UsersView.swift new file mode 100644 index 0000000..d1f3318 --- /dev/null +++ b/Hotline/iOS/UsersView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct UsersView: View { + @Environment(Hotline.self) private var model: Hotline + + var body: some View { + NavigationStack { + List(model.users) { u in + Text("🤖 \(u.name)") + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(u.status.contains(.admin) ? Color(hex: 0xE10000) : Color.accentColor) + .opacity(u.status.contains(.idle) ? 0.5 : 1.0) + } + .scrollBounceBehavior(.basedOnSize) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(model.serverTitle) + .font(.headline) + } + ToolbarItem(placement: .navigationBarLeading) { + Button { + model.disconnect() + } label: { + Text(Image(systemName: "xmark.circle.fill")) + .symbolRenderingMode(.hierarchical) + .font(.title2) + .foregroundColor(.secondary) + } + } + } + } + } +} + +#Preview { + ChatView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} |