aboutsummaryrefslogtreecommitdiff
path: root/Hotline/iOS
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2023-12-19 20:38:13 -0800
committerDustin Mierau <dustin@mierau.me>2023-12-19 20:38:13 -0800
commit4fd69c02a3e7b581bb9229865336c315153f3b18 (patch)
treee6e11d872424196f2b4033077902126ad5556e40 /Hotline/iOS
parent5e87b5927cd931d46fb5f72fb035480a95969a9f (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.swift153
-rw-r--r--Hotline/iOS/FilesView.swift145
-rw-r--r--Hotline/iOS/MessageBoardView.swift74
-rw-r--r--Hotline/iOS/NewsView.swift235
-rw-r--r--Hotline/iOS/ServerView.swift51
-rw-r--r--Hotline/iOS/TrackerView.swift397
-rw-r--r--Hotline/iOS/UsersView.swift41
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()))
+}