From 029ac8556fd572fa27053adaeb6dd130141090a2 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 26 Sep 2021 20:12:38 -0700 Subject: [PATCH] Clean up messages detail view, automatically scroll to bottom of message list when loading --- Meshtastic Client.xcodeproj/project.pbxproj | 10 +- MeshtasticClient/MeshtasticClientApp.swift | 1 - MeshtasticClient/Model/MessageData.swift | 40 ++++++ MeshtasticClient/Model/MessageModel.swift | 56 +++++++- MeshtasticClient/Views/ContentView.swift | 12 +- .../Views/Helpers/MessageBubble.swift | 17 ++- .../Views/Messages/MessageDetail.swift | 120 +++++++++++++----- .../Views/Messages/MessageList.swift | 5 +- MeshtasticClient/Views/Nodes/NodeDetail.swift | 6 +- MeshtasticClient/Views/Nodes/NodeMap.swift | 4 +- MeshtasticClient/Views/Nodes/NodeRow.swift | 2 +- 11 files changed, 217 insertions(+), 56 deletions(-) create mode 100644 MeshtasticClient/Model/MessageData.swift diff --git a/Meshtastic Client.xcodeproj/project.pbxproj b/Meshtastic Client.xcodeproj/project.pbxproj index efa41cce..e0d8b147 100644 --- a/Meshtastic Client.xcodeproj/project.pbxproj +++ b/Meshtastic Client.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */; }; + DD23A51326FEF5D500D9B90C /* MessageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A51226FEF5D500D9B90C /* MessageData.swift */; }; DD47E3CC26F0E51D00029299 /* NodeDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3CB26F0E51D00029299 /* NodeDetail.swift */; }; DD47E3CE26F103C600029299 /* NodeList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3CD26F103C600029299 /* NodeList.swift */; }; DD47E3D026F1073F00029299 /* NodeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3CF26F1073F00029299 /* NodeRow.swift */; }; @@ -64,6 +65,7 @@ /* Begin PBXFileReference section */ DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralModel.swift; sourceTree = ""; }; + DD23A51226FEF5D500D9B90C /* MessageData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageData.swift; sourceTree = ""; }; DD47E3CB26F0E51D00029299 /* NodeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeDetail.swift; sourceTree = ""; }; DD47E3CD26F103C600029299 /* NodeList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeList.swift; sourceTree = ""; }; DD47E3CF26F1073F00029299 /* NodeRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeRow.swift; sourceTree = ""; }; @@ -260,6 +262,7 @@ DD836AEC26F858F900ABCC23 /* MeshData.swift */, DD836AEE26F85D8D00ABCC23 /* NodeInfoModel.swift */, DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */, + DD23A51226FEF5D500D9B90C /* MessageData.swift */, ); path = Model; sourceTree = ""; @@ -449,6 +452,7 @@ DD90860E26F69BAE00DC5189 /* NodeMap.swift in Sources */, DD47E3DB26F3901B00029299 /* MessageList.swift in Sources */, DDAF8C6926ED0D070058C060 /* deviceonly.pb.swift in Sources */, + DD23A51326FEF5D500D9B90C /* MessageData.swift in Sources */, DD836AED26F858F900ABCC23 /* MeshData.swift in Sources */, DDAF8C6B26ED0DD80058C060 /* environmental_measurement.pb.swift in Sources */, DD90860C26F684AF00DC5189 /* BatteryIcon.swift in Sources */, @@ -637,10 +641,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.14; + MARKETING_VERSION = 1.15; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -663,10 +668,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.14; + MARKETING_VERSION = 1.15; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/MeshtasticClient/MeshtasticClientApp.swift b/MeshtasticClient/MeshtasticClientApp.swift index 3e51d970..d39d19af 100644 --- a/MeshtasticClient/MeshtasticClientApp.swift +++ b/MeshtasticClient/MeshtasticClientApp.swift @@ -13,7 +13,6 @@ struct MeshtasticClientApp: App { @ObservedObject private var meshData: MeshData = MeshData() @ObservedObject private var bleManager: BLEManager = BLEManager() - //@ObservedObject var meshData: MeshData var body: some Scene { WindowGroup { ContentView() diff --git a/MeshtasticClient/Model/MessageData.swift b/MeshtasticClient/Model/MessageData.swift new file mode 100644 index 00000000..1e0b9b0b --- /dev/null +++ b/MeshtasticClient/Model/MessageData.swift @@ -0,0 +1,40 @@ +import Foundation + +class MessageData: ObservableObject { + + private static var documentsFolder: URL { + do { + return try FileManager.default.url(for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true) + } catch { + fatalError("Can't find documents directory.") + } + } + + private static var fileURL: URL { + return documentsFolder.appendingPathComponent("messages.data") + } + + @Published var messages: [MessageModel] = [] + + func load() { + DispatchQueue.global(qos: .background).async { [weak self] in + guard let data = try? Data(contentsOf: Self.fileURL) else { + #if DEBUG + DispatchQueue.main.async { + self?.messages = MessageModel.data + } + #endif + return + } + guard let messageList = try? JSONDecoder().decode([MessageModel].self, from: data) else { + fatalError("Can't decode saved node data.") + } + DispatchQueue.main.async { + self?.messages = messageList + } + } + } +} diff --git a/MeshtasticClient/Model/MessageModel.swift b/MeshtasticClient/Model/MessageModel.swift index ae8c2d1a..dd1f662d 100644 --- a/MeshtasticClient/Model/MessageModel.swift +++ b/MeshtasticClient/Model/MessageModel.swift @@ -6,13 +6,13 @@ // import Foundation -struct MessageModel : Identifiable +struct MessageModel : Identifiable, Codable { let id: UUID var messageId: UInt32 var messageTimestamp: Int64 - var fromUserId: String - var toUserId: String + var fromUserId: UInt32 + var toUserId: UInt32 var fromUserLongName: String var toUserLongName: String var fromUserShortName: String @@ -21,7 +21,7 @@ struct MessageModel : Identifiable var messagePayload: String var direction: String - init(id: UUID = UUID(), messageId: UInt32, messageTimeStamp: Int64, fromUserId: String, toUserId: String, fromUserLongName: String, toUserLongName: String, fromUserShortName: String, toUserShortName: String, receivedACK: Bool, messagePayload: String, direction: String) + init(id: UUID = UUID(), messageId: UInt32, messageTimeStamp: Int64, fromUserId: UInt32, toUserId: UInt32, fromUserLongName: String, toUserLongName: String, fromUserShortName: String, toUserShortName: String, receivedACK: Bool, messagePayload: String, direction: String) { self.id = id self.messageId = messageId @@ -38,3 +38,51 @@ struct MessageModel : Identifiable } } + +extension MessageModel { + + static var data: [MessageModel] { + [ + // Put dev test data here + MessageModel(messageId: 3773493287, messageTimeStamp: 1632407404, fromUserId: 4064715620, toUserId: 4294967295, fromUserLongName: "TLORA V1 #1", toUserLongName: "Unknown 1", fromUserShortName: "T#", toUserShortName: "U1", receivedACK: false, messagePayload: "I sent a super great message with amazing text", direction: "received"), + MessageModel(messageId: 3773493338, messageTimeStamp: 1632643652, fromUserId: 2930161432, toUserId: 4294967295, fromUserLongName: "TBEAM ARMY GREEN", toUserLongName: "Unknown 1", fromUserShortName: "TAG", toUserShortName: "U1", receivedACK: false, messagePayload: "It was the best message", direction: "received"), + MessageModel(messageId: 3773493338, messageTimeStamp: 1632643652, fromUserId: 2930161432, toUserId: 4294967295, fromUserLongName: "TBEAM ARMY GREEN", toUserLongName: "Unknown 1", fromUserShortName: "TAG", toUserShortName: "U1", receivedACK: false, messagePayload: "SwiftUI is great, but it has been lacking of specific native controls, even though that gets much better year by year. One of them was the text view. When SwiftUI was first released, it had no native equivalent of the text view; implementing a custom UIViewRepresentable type to contain UITextView was the only way to go. But since iOS 14, SwiftUI introduces TextEditor, a brand new view to write multi-line text.", direction: "received") + ] + } +} + +extension MessageModel { + struct Data { + var id: UUID + var messageId: UInt32 + var messageTimestamp: Int64 + var fromUserId: UInt32 + var toUserId: UInt32 + var fromUserLongName: String + var toUserLongName: String + var fromUserShortName: String + var toUserShortName: String + var receivedACK: Bool + var messagePayload: String + var direction: String + + } + + var data: Data { + return Data(id: id, messageId: messageId, messageTimestamp: messageTimestamp, fromUserId: fromUserId, toUserId: toUserId, fromUserLongName: fromUserLongName, toUserLongName: toUserLongName, fromUserShortName: fromUserShortName, toUserShortName: toUserShortName, receivedACK: receivedACK, messagePayload: messagePayload, direction: direction) + } + + mutating func update(from data: Data) { + messageId = data.messageId + messageTimestamp = data.messageTimestamp + fromUserId = data.fromUserId + toUserId = data.toUserId + fromUserLongName = data.fromUserLongName + toUserLongName = data.toUserLongName + fromUserShortName = data.fromUserShortName + toUserShortName = data.toUserShortName + receivedACK = data.receivedACK + messagePayload = data.messagePayload + direction = data.direction + } +} diff --git a/MeshtasticClient/Views/ContentView.swift b/MeshtasticClient/Views/ContentView.swift index d69fea4a..cbc02241 100644 --- a/MeshtasticClient/Views/ContentView.swift +++ b/MeshtasticClient/Views/ContentView.swift @@ -17,12 +17,12 @@ struct ContentView: View { var body: some View { TabView(selection: $selection) { - //MessageList() - // .tabItem { - // Label("Messages", systemImage: "text.bubble") - // .symbolRenderingMode(.hierarchical) - // } - // .tag(Tab.messages) + MessageList() + .tabItem { + Label("Messages", systemImage: "text.bubble") + .symbolRenderingMode(.hierarchical) + } + .tag(Tab.messages) NodeList() .tabItem { Label("Nodes", systemImage: "flipphone") diff --git a/MeshtasticClient/Views/Helpers/MessageBubble.swift b/MeshtasticClient/Views/Helpers/MessageBubble.swift index af0eacc9..243103bd 100644 --- a/MeshtasticClient/Views/Helpers/MessageBubble.swift +++ b/MeshtasticClient/Views/Helpers/MessageBubble.swift @@ -7,18 +7,27 @@ struct MessageBubble: View { var shortName: String var body: some View { - VStack(alignment: isCurrentUser ? .leading : .trailing) { - HStack { + VStack { + HStack (alignment: .top) { CircleText(text: shortName, color: isCurrentUser ? Color.blue : Color(.darkGray)).padding(.all, 5) - + VStack (alignment: .leading) { Text(contentMessage) .padding(10) .foregroundColor(.white) .background(isCurrentUser ? Color.blue : Color(.darkGray)) .cornerRadius(10) + HStack (spacing: 4) { + let messageDate = Date(timeIntervalSince1970: TimeInterval(time)) + + Text(messageDate, style: .date).font(.caption2).foregroundColor(.gray) + Text(messageDate, style: .time).font(.caption2).foregroundColor(.gray) + } + .padding(.bottom, 10) + } Spacer() - }.padding(isCurrentUser ? .leading : .trailing, 70) + } + }.padding(.bottom, 1) } } diff --git a/MeshtasticClient/Views/Messages/MessageDetail.swift b/MeshtasticClient/Views/Messages/MessageDetail.swift index e56eb3b4..1eb90f4a 100644 --- a/MeshtasticClient/Views/Messages/MessageDetail.swift +++ b/MeshtasticClient/Views/Messages/MessageDetail.swift @@ -4,42 +4,100 @@ import CoreLocation struct MessageDetail: View { + enum Field: Hashable { + case messageText + } + @State var typingMessage: String = "" + @FocusState private var focusedField: Field? + @ObservedObject var messageData: MessageData = MessageData() + @EnvironmentObject var bleManager: BLEManager + + @Namespace var topId + @Namespace var bottomId var body: some View { - // NavigationView { - - VStack(alignment: .leading) { - ScrollView { + + GeometryReader { bounds in + + VStack { + + ScrollViewReader { scrollView in - MessageBubble(contentMessage: "I sent a super great message with amazing text", isCurrentUser: true, time: 1, shortName: "GVH") - MessageBubble(contentMessage: "It was amazing to read such a fantastical text", isCurrentUser: false, time: 1, shortName: "RS1") - MessageBubble(contentMessage: "It was the best message", isCurrentUser: false, time: 1, shortName: "RDN") - MessageBubble(contentMessage: "This is a terse response to an amazing text", isCurrentUser: true, time: 1, shortName: "GVH") - MessageBubble(contentMessage: "yo", isCurrentUser: true, time: 1, shortName: "GVH") - MessageBubble(contentMessage: "I sent a super great message with amazing text", isCurrentUser: true, time: 1, shortName: "GVH") - MessageBubble(contentMessage: "It was amazing to read such a fantastical text", isCurrentUser: false, time: 1, shortName: "RS1") - MessageBubble(contentMessage: "It was the best message", isCurrentUser: false, time: 1, shortName: "RDN") - MessageBubble(contentMessage: "This is a terse response to an amazing text", isCurrentUser: true, time: 1, shortName: "GVH") - MessageBubble(contentMessage: "yo", isCurrentUser: true, time: 1, shortName: "GVH") - - - - - }.padding([.top, .leading]) - HStack (alignment: .bottom) { - - TextField("Message", text: $typingMessage) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .frame(minHeight: CGFloat(30)) - Button(action: sendMessage) { - Image(systemName: "arrow.up.circle.fill").font(.title).foregroundColor(.blue) + ScrollView { + Text("Hidden Top Anchor") + .hidden() + .frame(height: 0) + .id(topId) + + ForEach(messageData.messages.sorted(by: { $0.messageTimestamp < $1.messageTimestamp })) { message in + + MessageBubble(contentMessage: message.messagePayload, isCurrentUser: false, time: Int32(message.messageTimestamp), shortName: message.fromUserShortName) } - }.padding(5) + .onAppear(perform: { scrollView.scrollTo(bottomId) } ) + + Text("Hidden Bottom Anchor") + .hidden() + .frame(height: 0) + .id(bottomId) + } + .padding([.top, .leading]) + } + HStack { + + if focusedField != nil { + Button("Dismiss Keyboard") { + focusedField = nil + } + .fixedSize() + .frame(height: 15, alignment: .center) + .padding(.top, 10) + } + } + HStack (alignment: .top) { + + ZStack { + + TextEditor(text: $typingMessage) + .onChange(of: typingMessage, perform: { value in + let size = value.utf8.count + if size >= 200 { + print("too big!") + } + print(size) + }) + .padding(.horizontal) + .focused($focusedField, equals: .messageText) + .multilineTextAlignment(.leading) + .frame(minHeight: 120, maxHeight: 120) + + + Text(typingMessage).opacity(0).padding(.all, 2) + + } + .overlay(RoundedRectangle(cornerRadius: 20).stroke(.tertiary, lineWidth: 2)) + .padding(.top) + + Button(action: sendMessage) { + Image(systemName: "arrow.up.circle.fill").font(.largeTitle).foregroundColor(.blue) + } + .padding(.top) + + }.padding([.leading, .bottom]) } - .navigationTitle("CHANNEL - Primary") - .navigationBarTitleDisplayMode(.inline) - //} - //.navigationViewStyle//(StackNavigationViewStyle()) + } + .navigationTitle("CHANNEL - Primary") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(trailing: + + ZStack { + + ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedNode != nil) ? bleManager.connectedNode.user.longName : ((bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.name : "Unknown") ?? "Unknown") + + } + ) + .onAppear{ + messageData.load() + } } } diff --git a/MeshtasticClient/Views/Messages/MessageList.swift b/MeshtasticClient/Views/Messages/MessageList.swift index 345d34e1..3349bfe1 100644 --- a/MeshtasticClient/Views/Messages/MessageList.swift +++ b/MeshtasticClient/Views/Messages/MessageList.swift @@ -7,16 +7,15 @@ struct MessageList: View { @State var typingMessage: String = "" @EnvironmentObject var bleManager: BLEManager - @EnvironmentObject var meshData: MeshData var body: some View { NavigationView { GeometryReader { bounds in - List{ + NavigationLink(destination: MessageDetail()) { - NavigationLink(destination: MessageDetail()) { + List{ HStack { diff --git a/MeshtasticClient/Views/Nodes/NodeDetail.swift b/MeshtasticClient/Views/Nodes/NodeDetail.swift index bed77362..6842e427 100644 --- a/MeshtasticClient/Views/Nodes/NodeDetail.swift +++ b/MeshtasticClient/Views/Nodes/NodeDetail.swift @@ -57,7 +57,7 @@ struct NodeDetail: View { HStack { VStack(alignment: .center) { - Text("AKA").font(.title2) + Text("AKA").font(.title2).fixedSize() CircleText(text: node.user.shortName, color: Color.blue) .offset(y:10) } @@ -69,7 +69,7 @@ struct NodeDetail: View { .font(.title) .foregroundColor(.blue) .symbolRenderingMode(.hierarchical) - Text("SNR").font(.title2) + Text("SNR").font(.title2).fixedSize() Text(String(node.snr ?? 0)) .font(.title2) .foregroundColor(.gray) @@ -77,7 +77,7 @@ struct NodeDetail: View { Divider() VStack(alignment: .center) { BatteryIcon(batteryLevel: node.position.batteryLevel, font: .title, color: Color.blue) - Text("Battery").font(.title2) + Text("Battery").font(.title2).fixedSize() Text(String(node.position.batteryLevel!) + "%") .font(.title2) .foregroundColor(.gray) diff --git a/MeshtasticClient/Views/Nodes/NodeMap.swift b/MeshtasticClient/Views/Nodes/NodeMap.swift index cc2af207..0f5cf6f5 100644 --- a/MeshtasticClient/Views/Nodes/NodeMap.swift +++ b/MeshtasticClient/Views/Nodes/NodeMap.swift @@ -60,7 +60,9 @@ struct NodeMap: View { .navigationBarTitleDisplayMode(.inline) } .navigationViewStyle(StackNavigationViewStyle()) - + .onAppear{ + meshData.load() + } } } diff --git a/MeshtasticClient/Views/Nodes/NodeRow.swift b/MeshtasticClient/Views/Nodes/NodeRow.swift index 86da17d7..02309516 100644 --- a/MeshtasticClient/Views/Nodes/NodeRow.swift +++ b/MeshtasticClient/Views/Nodes/NodeRow.swift @@ -10,7 +10,7 @@ struct NodeRow: View { HStack() { CircleText(text: node.user.shortName, color: Color.blue).offset(y: 1).padding(.trailing, 5) - Text(node.user.longName).font(.title) + Text(node.user.longName).font(.title2) } .padding([.trailing])