From e3392bb948ad53889ad334140952f2acf614668e Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 6 Dec 2023 22:52:09 -0800 Subject: Some work on news. More UI tweaks across the board. --- Hotline.xcodeproj/project.pbxproj | 16 ++- .../AppIcon.appiconset/App Icon.png | Bin 8399 -> 8190 bytes .../Assets.xcassets/Hotline.imageset/Contents.json | 55 ++++++++ .../Hotline.imageset/hotline-black.svg | 3 + .../Hotline.imageset/hotline-white.svg | 3 + .../Assets.xcassets/Image.imageset/Contents.json | 20 +++ Hotline/Hotline/HotlineClient.swift | 26 +++- Hotline/Hotline/HotlineProtocol.swift | 49 ++++++- Hotline/Hotline/HotlineTrackerClient.swift | 48 ++++--- Hotline/Utility/DataExtensions.swift | 86 ------------ Hotline/Utility/FoundationExtensions.swift | 96 +++++++++++++ Hotline/Utility/ObservableScrollView.swift | 38 ++++++ Hotline/Utility/SwiftUIExtensions.swift | 7 + Hotline/Views/ChatView.swift | 6 +- Hotline/Views/FilesView.swift | 2 + Hotline/Views/NewsView.swift | 89 ++++++++++--- Hotline/Views/TrackerView.swift | 148 +++++++++++++-------- Hotline/Views/UserListView.swift | 16 +-- 18 files changed, 511 insertions(+), 197 deletions(-) create mode 100644 Hotline/Assets.xcassets/Hotline.imageset/Contents.json create mode 100644 Hotline/Assets.xcassets/Hotline.imageset/hotline-black.svg create mode 100644 Hotline/Assets.xcassets/Hotline.imageset/hotline-white.svg create mode 100644 Hotline/Assets.xcassets/Image.imageset/Contents.json delete mode 100644 Hotline/Utility/DataExtensions.swift create mode 100644 Hotline/Utility/FoundationExtensions.swift create mode 100644 Hotline/Utility/ObservableScrollView.swift create mode 100644 Hotline/Utility/SwiftUIExtensions.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 863147e..10bf487 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -18,12 +18,14 @@ DA4F2BFF2B1A54FF00D8ADDC /* HotlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BFE2B1A54FF00D8ADDC /* HotlineView.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; }; DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA77253E2B21176D006C5ABB /* NewsView.swift */; }; + DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7725402B21435B006C5ABB /* ObservableScrollView.swift */; }; DA9CAFBB2B126D5700CDA197 /* HotlineApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFBA2B126D5700CDA197 /* HotlineApp.swift */; }; DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFBC2B126D5700CDA197 /* TrackerView.swift */; }; DA9CAFC12B126D5800CDA197 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA9CAFC02B126D5800CDA197 /* Assets.xcassets */; }; DA9CAFC42B126D5800CDA197 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */; }; DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */; }; - DABFCC292B1530DC009F40D2 /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* DataExtensions.swift */; }; + DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */; }; + DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -38,13 +40,15 @@ DA4F2BFE2B1A54FF00D8ADDC /* HotlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineView.swift; sourceTree = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; DA77253E2B21176D006C5ABB /* NewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsView.swift; sourceTree = ""; }; + DA7725402B21435B006C5ABB /* ObservableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableScrollView.swift; sourceTree = ""; }; DA9CAFB72B126D5700CDA197 /* Hotline.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hotline.app; sourceTree = BUILT_PRODUCTS_DIR; }; DA9CAFBA2B126D5700CDA197 /* HotlineApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineApp.swift; sourceTree = ""; }; DA9CAFBC2B126D5700CDA197 /* TrackerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerView.swift; sourceTree = ""; }; DA9CAFC02B126D5800CDA197 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineTrackerClient.swift; sourceTree = ""; }; - DABFCC282B1530DC009F40D2 /* DataExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = ""; }; + DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationExtensions.swift; sourceTree = ""; }; + DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -125,7 +129,9 @@ DABFCC272B1530BE009F40D2 /* Utility */ = { isa = PBXGroup; children = ( - DABFCC282B1530DC009F40D2 /* DataExtensions.swift */, + DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */, + DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */, + DA7725402B21435B006C5ABB /* ObservableScrollView.swift */, ); path = Utility; sourceTree = ""; @@ -204,13 +210,15 @@ DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */, DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */, DA0D698B2B1E5A5200C71DF5 /* HotlineState.swift in Sources */, + DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */, + DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, DA9CAFBB2B126D5700CDA197 /* HotlineApp.swift in Sources */, DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */, DA4F2BFF2B1A54FF00D8ADDC /* HotlineView.swift in Sources */, DA4F2BF62B1668A700D8ADDC /* HotlineClient.swift in Sources */, - DABFCC292B1530DC009F40D2 /* DataExtensions.swift in Sources */, + DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UserListView.swift in Sources */, diff --git a/Hotline/Assets.xcassets/AppIcon.appiconset/App Icon.png b/Hotline/Assets.xcassets/AppIcon.appiconset/App Icon.png index 02d1c69..c81665b 100644 Binary files a/Hotline/Assets.xcassets/AppIcon.appiconset/App Icon.png and b/Hotline/Assets.xcassets/AppIcon.appiconset/App Icon.png differ diff --git a/Hotline/Assets.xcassets/Hotline.imageset/Contents.json b/Hotline/Assets.xcassets/Hotline.imageset/Contents.json new file mode 100644 index 0000000..ebc37a0 --- /dev/null +++ b/Hotline/Assets.xcassets/Hotline.imageset/Contents.json @@ -0,0 +1,55 @@ +{ + "images" : [ + { + "filename" : "hotline-black.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "hotline-white.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Hotline/Assets.xcassets/Hotline.imageset/hotline-black.svg b/Hotline/Assets.xcassets/Hotline.imageset/hotline-black.svg new file mode 100644 index 0000000..5c98c36 --- /dev/null +++ b/Hotline/Assets.xcassets/Hotline.imageset/hotline-black.svg @@ -0,0 +1,3 @@ + + + diff --git a/Hotline/Assets.xcassets/Hotline.imageset/hotline-white.svg b/Hotline/Assets.xcassets/Hotline.imageset/hotline-white.svg new file mode 100644 index 0000000..967ea62 --- /dev/null +++ b/Hotline/Assets.xcassets/Hotline.imageset/hotline-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/Hotline/Assets.xcassets/Image.imageset/Contents.json b/Hotline/Assets.xcassets/Image.imageset/Contents.json new file mode 100644 index 0000000..a19a549 --- /dev/null +++ b/Hotline/Assets.xcassets/Image.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index f108c79..9d62811 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -51,12 +51,12 @@ class HotlineClient { ]) var connectionStatus: HotlineClientStatus = .disconnected - var agreement: String? var users: [UInt16:HotlineUser] = [:] var userList: [HotlineUser] = [] var chatMessages: [HotlineChat] = [] var messageBoardMessages: [String] = [] var fileList: [HotlineFile] = [] + var newsCategories: [HotlineNewsCategory] = [] var userName: String = "bolt" var userIconID: UInt16 = 128 @@ -118,13 +118,14 @@ class HotlineClient { } func reset() { + self.transactionLog = [:] DispatchQueue.main.async { self.chatMessages = [] - self.agreement = nil self.users = [:] self.userList = [] self.messageBoardMessages = [] self.fileList = [] + self.newsCategories = [] } } @@ -370,8 +371,11 @@ class HotlineClient { self.sendTransaction(t, callback: callback) } - func sendGetNewsArticles(callback: (() -> Void)? = nil) { - let t = HotlineTransaction(type: .getNewsArticleNameList) + func sendGetNewsArticles(path: [String]? = nil, callback: (() -> Void)? = nil) { + var t = HotlineTransaction(type: .getNewsArticleNameList) + if path != nil { + t.setFieldPath(type: .newsPath, val: path!) + } self.sendTransaction(t, callback: callback) } @@ -383,6 +387,11 @@ class HotlineClient { self.sendTransaction(t, callback: callback) } +// func sendGetNews(callback: (() -> Void)? = nil) { +// let t = HotlineTransaction(type: .getNewsFile) +// self.sendTransaction(t, callback: callback) +// } + // MARK: - Incoming private func processReply(_ transaction: HotlineTransaction) { @@ -460,10 +469,15 @@ class HotlineClient { self.fileList = files } case .getNewsCategoryNameList: + var categories: [HotlineNewsCategory] = [] for fi in transaction.getFieldList(type: .newsCategoryListData15) { let c = fi.getNewsCategory() + categories.append(c) print("CATEGORY: \(c)") } + DispatchQueue.main.async { + self.newsCategories = categories + } default: break } @@ -508,7 +522,7 @@ class HotlineClient { print("HotlineClient ❌") self.disconnect() case .showAgreement: - if let noAgreementField = transaction.getField(type: .noServerAgreement) { + if let _ = transaction.getField(type: .noServerAgreement) { print("NO AGREEMENT?") } if let agreementParam = transaction.getField(type: .data) { @@ -518,7 +532,7 @@ class HotlineClient { print("\n--------------------------\n\n") DispatchQueue.main.async { self.chatMessages.insert(HotlineChat(text: agreementText, type: .agreement), at: 0) - self.agreement = agreementText +// self.agreement = agreementText } // self.sendAgree() { // self.sendGetUserList() diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 0cf3d06..a0e129e 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -55,16 +55,18 @@ struct HotlineNewsCategory: Identifiable, Hashable { if self.type == 2 { // Read bundle properties self.count = data.readUInt16(at: 2)! - - let nameSize = data.readUInt8(at: 4)! - self.name = data.readString(at: 5, length: Int(nameSize))! + let (n, _) = data.readPString(at: 4) + self.name = n! } else if self.type == 3 { // Read category properties self.count = data.readUInt16(at: 2)! - - let nameSize = data.readUInt8(at: 2 + 2 + 4 + 4)! - self.name = data.readString(at: 2 + 2 + 4 + 4 + 1, length: Int(nameSize))! +// let guid = data.readUInt32(at: 4)! +// print("CATEGORY GUID: \(guid)") +// let addSN = data.readUInt32(at: 20)! +// let removeSN = data.readUInt32(at: 24)! + let (n, _) = data.readPString(at: 28) + self.name = n! } else { self.count = 0 @@ -221,6 +223,37 @@ struct HotlineTransactionField { init(type: HotlineTransactionFieldType, string: String, encrypt: Bool) { self.init(type: type, string: string, encoding: .ascii, encrypt: encrypt) } + + init(type: HotlineTransactionFieldType, path: String) { + var components: [String] = [] + + for component in path.components(separatedBy: "/") { + if !component.isEmpty { + components.append(component) + } + } + + self.init(type: type, pathComponents: components) + } + + init(type: HotlineTransactionFieldType, pathComponents: [String]) { + var pathData = Data() + + pathData.appendUInt16(UInt16(pathComponents.count)) + for name in pathComponents { + pathData.appendUInt16(0) + + var nameData = name.data(using: .ascii, allowLossyConversion: true) + if nameData == nil { + nameData = Data() + } + + pathData.appendUInt8(UInt8(nameData!.count)) + pathData.append(nameData!) + } + + self.init(type: type, dataSize: UInt16(pathData.count), data: pathData) + } func getUInt8() -> UInt8? { return self.data.readUInt8(at: 0) @@ -325,6 +358,10 @@ struct HotlineTransaction { self.fields.append(HotlineTransactionField(type: type, string: val)) } + mutating func setFieldPath(type: HotlineTransactionFieldType, val: [String]) { + self.fields.append(HotlineTransactionField(type: type, pathComponents: val)) + } + func getField(type: HotlineTransactionFieldType) -> HotlineTransactionField? { return self.fields.first { p in p.type == type diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 4ae252c..78f681f 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -246,24 +246,40 @@ class HotlineTrackerClient { let ip_3 = self.bytes.readUInt8(at: cursor + 2), let ip_4 = self.bytes.readUInt8(at: cursor + 3), let port = self.bytes.readUInt16(at: cursor + 4), - let userCount = self.bytes.readUInt16(at: cursor + 6), - let nameLengthByte = self.bytes.readUInt8(at: cursor + 10) { + let userCount = self.bytes.readUInt16(at: cursor + 6) { +// let nameLengthByte = self.bytes.readUInt8(at: cursor + 10) { - let nameLength = Int(nameLengthByte) - if let name = self.bytes.readString(at: cursor + 11, length: nameLength) { - if let descLengthByte = self.bytes.readUInt8(at: cursor + 11 + nameLength) { - let descLength = Int(descLengthByte) - if let desc = self.bytes.readString(at: cursor + 11 + nameLength + 1, length: descLength) { - let server = HotlineServer(address: "\(ip_1).\(ip_2).\(ip_3).\(ip_4)", port: port, users: userCount, name: name, description: desc) - - print("SERVER: \(server)") - - foundServers.append(server) - - cursor += 11 + nameLength + 1 + descLength - } - } + let (serverName, nameByteCount) = self.bytes.readPString(at: cursor + 10) + let (serverDescription, descByteCount) = self.bytes.readPString(at: cursor + 10 + nameByteCount) + + if let name = serverName, + let desc = serverDescription { + let server = HotlineServer(address: "\(ip_1).\(ip_2).\(ip_3).\(ip_4)", port: port, users: userCount, name: name, description: desc) + + print("SERVER: \(server)") + + foundServers.append(server) + + } + + cursor += 10 + nameByteCount + descByteCount + +// let nameLength = Int(nameLengthByte) +// if let name = self.bytes.readString(at: cursor + 11, length: nameLength) { +// if let descLengthByte = self.bytes.readUInt8(at: cursor + 11 + nameLength) { +// let descLength = Int(descLengthByte) +// if let desc = self.bytes.readString(at: cursor + 11 + nameLength + 1, length: descLength) { +// let server = HotlineServer(address: "\(ip_1).\(ip_2).\(ip_3).\(ip_4)", port: port, users: userCount, name: name, description: desc) +// +// print("SERVER: \(server)") +// +// foundServers.append(server) +// +// cursor += 11 + nameLength + 1 + descLength +// } +// } +// } } print(cursor) diff --git a/Hotline/Utility/DataExtensions.swift b/Hotline/Utility/DataExtensions.swift deleted file mode 100644 index aefd204..0000000 --- a/Hotline/Utility/DataExtensions.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation - -enum Endianness { - case big - case little -} - -extension Data { - init(_ val: UInt8) { - self.init() - self.appendUInt8(val) - } - - init(_ val: UInt16) { - self.init() - self.appendUInt16(val) - } - - init(_ val: UInt32) { - self.init() - self.appendUInt32(val) - } - - func readUInt8(at offset: Int) -> UInt8? { - guard offset >= 0, offset + 1 <= self.count else { - return nil - } - return self[offset] - } - - func readUInt16(at offset: Int) -> UInt16? { - guard offset >= 0, offset + 2 <= self.count else { - return nil - } - - return (UInt16(self[offset]) << 8) + UInt16(self[offset + 1]) - } - - func readUInt32(at offset: Int) -> UInt32? { - guard offset >= 0, offset + 4 <= self.count else { - return nil - } - - return (UInt32(self[offset]) << 24) + (UInt32(self[offset + 1]) << 16) + (UInt32(self[offset + 2]) << 8) + UInt32(self[offset + 3]) - } - - func readData(at offset: Int, length: Int) -> Data? { - guard offset >= 0, offset + length <= self.count else { - return nil - } - return self.subdata(in: offset..<(offset + length)) - } - - func readString(at offset: Int, length: Int) -> String? { - var str: String? - - str = String(data: self[offset..<(offset + length)], encoding: .utf8) - if str == nil { - str = String(data: self[offset..<(offset + length)], encoding: .ascii) - } - - return str - } - - - mutating func appendUInt8(_ value: UInt8, endianness: Endianness = .big) { - var val = endianness == .big ? value.bigEndian : value.littleEndian - append(&val, count: MemoryLayout.size) - } - - mutating func appendUInt16(_ value: UInt16, endianness: Endianness = .big) { - var val = endianness == .big ? value.bigEndian : value.littleEndian - Swift.withUnsafeBytes(of: &val) { buffer in - append(buffer.bindMemory(to: UInt8.self)) - } -// append(&val, count: MemoryLayout.size) - } - - mutating func appendUInt32(_ value: UInt32, endianness: Endianness = .big) { - var val = endianness == .big ? value.bigEndian : value.littleEndian - Swift.withUnsafeBytes(of: &val) { buffer in - append(buffer.bindMemory(to: UInt8.self)) - } -// append(&val, count: MemoryLayout.size) - } -} diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift new file mode 100644 index 0000000..c6ca251 --- /dev/null +++ b/Hotline/Utility/FoundationExtensions.swift @@ -0,0 +1,96 @@ +import Foundation + +enum Endianness { + case big + case little +} + +extension Data { + init(_ val: UInt8) { + self.init() + self.appendUInt8(val) + } + + init(_ val: UInt16) { + self.init() + self.appendUInt16(val) + } + + init(_ val: UInt32) { + self.init() + self.appendUInt32(val) + } + + func readUInt8(at offset: Int) -> UInt8? { + guard offset >= 0, offset + 1 <= self.count else { + return nil + } + return self[offset] + } + + func readUInt16(at offset: Int) -> UInt16? { + guard offset >= 0, offset + 2 <= self.count else { + return nil + } + + return (UInt16(self[offset]) << 8) + UInt16(self[offset + 1]) + } + + func readUInt32(at offset: Int) -> UInt32? { + guard offset >= 0, offset + 4 <= self.count else { + return nil + } + + return (UInt32(self[offset]) << 24) + (UInt32(self[offset + 1]) << 16) + (UInt32(self[offset + 2]) << 8) + UInt32(self[offset + 3]) + } + + func readData(at offset: Int, length: Int) -> Data? { + guard offset >= 0, offset + length <= self.count else { + return nil + } + return self.subdata(in: offset..<(offset + length)) + } + + func readString(at offset: Int, length: Int) -> String? { + var str: String? + + str = String(data: self[offset..<(offset + length)], encoding: .utf8) + if str == nil { + str = String(data: self[offset..<(offset + length)], encoding: .ascii) + } + + return str + } + + func readPString(at offset: Int) -> (String?, Int) { + let len = Int(self.readUInt8(at: offset)!) + return (self.readString(at: offset+1, length: len), 1 + len) + } + + func readLongPString(at offset: Int) -> (String?, Int) { + let len = Int(self.readUInt16(at: offset)!) + return (self.readString(at: offset+2, length: len), 2 + len) + } + + + mutating func appendUInt8(_ value: UInt8, endianness: Endianness = .big) { + var val = endianness == .big ? value.bigEndian : value.littleEndian + append(&val, count: MemoryLayout.size) + } + + mutating func appendUInt16(_ value: UInt16, endianness: Endianness = .big) { + var val = endianness == .big ? value.bigEndian : value.littleEndian + Swift.withUnsafeBytes(of: &val) { buffer in + append(buffer.bindMemory(to: UInt8.self)) + } +// append(&val, count: MemoryLayout.size) + } + + mutating func appendUInt32(_ value: UInt32, endianness: Endianness = .big) { + var val = endianness == .big ? value.bigEndian : value.littleEndian + Swift.withUnsafeBytes(of: &val) { buffer in + append(buffer.bindMemory(to: UInt8.self)) + } +// append(&val, count: MemoryLayout.size) + } +} diff --git a/Hotline/Utility/ObservableScrollView.swift b/Hotline/Utility/ObservableScrollView.swift new file mode 100644 index 0000000..a6f46cc --- /dev/null +++ b/Hotline/Utility/ObservableScrollView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct ScrollViewOffsetPreferenceKey: PreferenceKey { + typealias Value = CGFloat + static var defaultValue = CGFloat.zero + static func reduce(value: inout Value, nextValue: () -> Value) { + value += nextValue() + } +} + +struct ObservableScrollView: View where Content : View { + @Namespace var scrollSpace + @Binding var scrollOffset: CGFloat + let content: () -> Content + + init(scrollOffset: Binding, + @ViewBuilder content: @escaping () -> Content) { + _scrollOffset = scrollOffset + self.content = content + } + + var body: some View { + ScrollView { + content() + .background(GeometryReader { geo in + let offset = -geo.frame(in: .named(scrollSpace)).minY + Color.clear + .preference(key: ScrollViewOffsetPreferenceKey.self, + value: offset) + }) + + } + .coordinateSpace(name: scrollSpace) + .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in + scrollOffset = value + } + } +} diff --git a/Hotline/Utility/SwiftUIExtensions.swift b/Hotline/Utility/SwiftUIExtensions.swift new file mode 100644 index 0000000..3b850e0 --- /dev/null +++ b/Hotline/Utility/SwiftUIExtensions.swift @@ -0,0 +1,7 @@ +import SwiftUI + +extension Color { + init(hex: Int, opacity: Double = 1.0) { + self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) + } +} diff --git a/Hotline/Views/ChatView.swift b/Hotline/Views/ChatView.swift index dc6e1d4..1aee6b2 100644 --- a/Hotline/Views/ChatView.swift +++ b/Hotline/Views/ChatView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ChatView: View { @Environment(HotlineClient.self) private var hotline + @Environment(\.colorScheme) var colorScheme @State var input: String = "" @State private var scrollPos: Int? @@ -17,13 +18,12 @@ struct ChatView: View { LazyVStack(alignment: .leading) { ForEach(hotline.chatMessages) { msg in if msg.type == .agreement { - VStack(alignment: .leading) { Text(msg.text) .padding() .opacity(0.75) - .background(Color(white: 0.96)) - .cornerRadius(20) + .background(colorScheme == .dark ? Color(white: 0.1) : Color(white: 0.96)) + .cornerRadius(16) } .padding() } diff --git a/Hotline/Views/FilesView.swift b/Hotline/Views/FilesView.swift index 1b35695..3b2b236 100644 --- a/Hotline/Views/FilesView.swift +++ b/Hotline/Views/FilesView.swift @@ -57,6 +57,8 @@ struct FilesView: View { else { HStack(alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) { Image(uiImage: fileIcon(name: tree.name)) + .renderingMode(.template) + .foregroundColor(.accentColor) } .frame(minWidth: 25) Text(tree.name).bold() diff --git a/Hotline/Views/NewsView.swift b/Hotline/Views/NewsView.swift index 5f2dbe9..9c4d34a 100644 --- a/Hotline/Views/NewsView.swift +++ b/Hotline/Views/NewsView.swift @@ -5,32 +5,89 @@ struct NewsView: View { @Environment(HotlineClient.self) private var hotline @State private var fetched = false + @State private var selectedCategory: HotlineNewsCategory? = nil + @State private var topListHeight: CGFloat = 200 + @State private var dividerHeight: CGFloat = 30 + + var topList: some View { + + // Your list content goes here + List { + ForEach(hotline.newsCategories, id: \.self) { cat in + DisclosureGroup { + ProgressView(value: 0.4) + .task { + print("EXPANDED?", cat.name) + hotline.sendGetNewsArticles(path: [cat.name]) { + print("OK") + } + } + } label: { + Text(cat.name) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.tail) + } + } + } +// .listStyle(.plain) + } + + var bottomList: some View { + // Your list content goes here + ScrollView(.vertical) { + HStack(alignment: .top, spacing: 0) { + Text("HELLO") + .multilineTextAlignment(.leading) + Spacer() + } + .padding() + } + } var body: some View { -// @Bindable var config = appState NavigationStack { - ScrollView { - LazyVStack(alignment: .leading) { -// ForEach(hotline.messageBoardMessages, id: \.self) { -// Text($0) -// .lineLimit(100) -// .padding() -// .textSelection(.enabled) -// Divider() -// } + VStack(spacing: 0) { + topList + .frame(height: topListHeight) + VStack(alignment: .center) { + Divider() + Spacer() + HStack(alignment: .center) { + Image(systemName: "line.3.horizontal") + .opacity(0.2) + .font(.system(size: 14)) + } + Spacer() +// Divider() } - Spacer() + .background(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) + // bottomListHeight = max(min(bottomListHeight - delta, 400), 0) + } + ) + bottomList } .task { if !fetched { hotline.sendGetNewsCategories() { fetched = true } + + // hotline.sendGetNewsArticles(path: ["News"]) { + // print("GOT ARTICLES?") + // } } } - .refreshable { - hotline.sendGetNewsCategories() - } + // .refreshable { + // hotline.sendGetNewsCategories() + // } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { @@ -52,8 +109,8 @@ struct NewsView: View { } label: { Image(systemName: "square.and.pencil") -// .symbolRenderingMode(.hierarchical) -// .foregroundColor(.secondary) + // .symbolRenderingMode(.hierarchical) + // .foregroundColor(.secondary) } } diff --git a/Hotline/Views/TrackerView.swift b/Hotline/Views/TrackerView.swift index 3ed66dd..7f59118 100644 --- a/Hotline/Views/TrackerView.swift +++ b/Hotline/Views/TrackerView.swift @@ -11,6 +11,7 @@ struct TrackerView: View { @Environment(\.colorScheme) var colorScheme @State private var selectedServer: HotlineServer? + @State var scrollOffset: CGFloat = CGFloat.zero func shouldDisplayDescription(server: HotlineServer) -> Bool { guard let name = server.name, let desc = server.description else { @@ -36,83 +37,126 @@ struct TrackerView: View { } } + func inverseLerp(lower: Double, upper: Double, v: Double) -> Double { + return (v - lower) / (upper - lower) + } + var body: some View { @Bindable var config = appState @Bindable var client = hotline - ScrollView { - LazyVStack(alignment: .leading) { - ForEach(tracker.servers) { server in - VStack(alignment: .leading) { - HStack(alignment: .firstTextBaseline) { - Image(systemName: "globe.americas.fill").font(.title3) - VStack(alignment: .leading) { - Text(server.name!).font(.title3).fontWeight(.medium) - if shouldDisplayDescription(server: server) { + ZStack(alignment: .center) { + VStack(alignment: .center) { + ZStack(alignment: .top) { + Image("Hotline") + .resizable() + .renderingMode(.template) + .foregroundColor(Color(hex: 0xE10000)) + .scaledToFit() + .frame(width: 40.0, height: 40.0) + HStack(alignment: .center) { + Spacer() + Button { + // hotline.disconnect() + } label: { + Text(Image(systemName: "point.3.connected.trianglepath.dotted")) + .symbolRenderingMode(.hierarchical) + .foregroundColor(.primary) + .font(.title2) + .padding(.trailing, 16) + } + } + .frame(height: 40.0) + } + .padding() + .opacity(scrollOffset > 80 ? 0 : 1.0) +// .padding(.top, 5) + .opacity(inverseLerp(lower: -80, upper: 0, v: scrollOffset)) +// .opacity(inverseLerp(lower: 20, upper: 0, v: scrollOffset)) + + Spacer() + } + ObservableScrollView(scrollOffset: $scrollOffset) { + LazyVStack(alignment: .leading) { + ForEach(tracker.servers) { server in + VStack(alignment: .leading) { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "globe.americas.fill").font(.title3) + VStack(alignment: .leading) { + Text(server.name!).font(.title3).fontWeight(.medium) + if shouldDisplayDescription(server: server) { + Spacer() + Text(server.description!).opacity(0.5).font(.system(size: 16)) + } Spacer() - Text(server.description!).opacity(0.4).font(.system(size: 16)) + Text("\(server.address)").opacity(0.3).font(.system(size: 13)) } Spacer() - Text("\(server.address)").opacity(0.2).font(.system(size: 13)) - } - Spacer() - Text("\(server.users)").opacity(0.2).font(.system(size: 16)).fontWeight(.medium) - } - if server == selectedServer { - Spacer(minLength: 16) - - if hotline.server == server && hotline.connectionStatus != .disconnected { - ProgressView("", value: connectionStatusToProgress(status: hotline.connectionStatus)) + Text("\(server.users)").opacity(0.3).font(.system(size: 16)).fontWeight(.medium) } - else { - Button("Connect") { - hotline.connect(to: server) + if server == selectedServer { + Spacer(minLength: 16) + + if hotline.server == server && hotline.connectionStatus != .disconnected { + ProgressView(value: connectionStatusToProgress(status: hotline.connectionStatus)) + .frame(minHeight: 10) + .accentColor(colorScheme == .dark ? .white : .black) + } + else { + Button("Connect") { + hotline.connect(to: server) + } + .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) } - .bold() - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - .frame(maxWidth: .infinity) - .foregroundColor(.black) - .background(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(0.4) - ) - .cornerRadius(10.0) } } - } - .multilineTextAlignment(.leading) - .padding() - .background(colorScheme == .dark ? Color(white: 0.1) : .white) - .cornerRadius(20) - .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 + .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)) + } + .refreshable { + await withCheckedContinuation { continuation in + tracker.fetch() { + continuation.resume() + } } - .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } + } .fullScreenCover(isPresented: Binding(get: { return hotline.connectionStatus == .loggedIn }, set: { _ in }), onDismiss: { hotline.disconnect() }) { ServerView() } - .background(colorScheme == .dark ? .black : Color(white: 0.95)) + .background(Color(uiColor: UIColor.systemGroupedBackground)) .frame(maxWidth: .infinity) .task { tracker.fetch() } - .refreshable { - await withCheckedContinuation { continuation in - tracker.fetch() { - continuation.resume() - } - } - } - .navigationTitle("Tracker") - .navigationBarTitleDisplayMode(.inline) } } diff --git a/Hotline/Views/UserListView.swift b/Hotline/Views/UserListView.swift index cae1d0c..e0fa7fb 100644 --- a/Hotline/Views/UserListView.swift +++ b/Hotline/Views/UserListView.swift @@ -5,15 +5,15 @@ struct UserListView: View { var body: some View { NavigationStack { - VStack(spacing: 0) { - List(hotline.userList) { u in - HStack(alignment: .firstTextBaseline) { - Text(u.name).bold().foregroundStyle(u.isAdmin ? Color.red : Color.black).opacity(u.isIdle ? 0.5 : 1.0) - } - } - .listStyle(.plain) - .padding() + List(hotline.userList) { u in + Text(u.name) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(u.isAdmin ? Color(hex: 0xE10000) : Color.accentColor) + .opacity(u.isIdle ? 0.5 : 1.0) } +// .listStyle(.grouped) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { -- cgit