diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 0d54f434..523c56ca 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -17,7 +17,7 @@ DD17E5DE277D49D400010EC2 /* storeforward.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17E5DC277D49D400010EC2 /* storeforward.pb.swift */; }; DD1925B728CDA5A400720036 /* CannedMessagesConfigEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1925B628CDA5A400720036 /* CannedMessagesConfigEnums.swift */; }; DD1925B928CDA93900720036 /* SerialConfigEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1925B828CDA93900720036 /* SerialConfigEnums.swift */; }; - DD1BF2F92776FE2E008C8D2F /* MessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BF2F82776FE2E008C8D2F /* MessageList.swift */; }; + DD1BF2F92776FE2E008C8D2F /* UserMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */; }; DD2160AF28C5552500C17253 /* MQTTConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2160AE28C5552500C17253 /* MQTTConfig.swift */; }; DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */; }; DD2553572855B02500E55709 /* LoRaConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2553562855B02500E55709 /* LoRaConfig.swift */; }; @@ -44,11 +44,13 @@ DD5394FC276993AD00AD86B1 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = DD5394FB276993AD00AD86B1 /* SwiftProtobuf */; }; DD5394FE276BA0EF00AD86B1 /* PositionEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5394FD276BA0EF00AD86B1 /* PositionEntityExtension.swift */; }; DD539502276DAA6A00AD86B1 /* MapLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD539501276DAA6A00AD86B1 /* MapLocation.swift */; }; + DD58C5F22919AD3C00D5BEFB /* ChannelEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD58C5F12919AD3C00D5BEFB /* ChannelEntityExtension.swift */; }; DD6193752862F6E600E59241 /* ExternalNotificationConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6193742862F6E600E59241 /* ExternalNotificationConfig.swift */; }; DD6193772862F90F00E59241 /* CannedMessagesConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6193762862F90F00E59241 /* CannedMessagesConfig.swift */; }; DD6193792863875F00E59241 /* SerialConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6193782863875F00E59241 /* SerialConfig.swift */; }; DD73FD1128750779000852D6 /* PositionLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD73FD1028750779000852D6 /* PositionLog.swift */; }; DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD769E0228D18BF0001A3F05 /* DeviceMetricsLog.swift */; }; + DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD798B062915928D005217CD /* ChannelMessageList.swift */; }; DD8169F9271F1A6100F4AB02 /* MeshLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8169F8271F1A6100F4AB02 /* MeshLogger.swift */; }; DD8169FB271F1F3A00F4AB02 /* MeshLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8169FA271F1F3A00F4AB02 /* MeshLog.swift */; }; DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8169FE272476C700F4AB02 /* LogDocument.swift */; }; @@ -129,7 +131,7 @@ DD17E5DC277D49D400010EC2 /* storeforward.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = storeforward.pb.swift; sourceTree = ""; }; DD1925B628CDA5A400720036 /* CannedMessagesConfigEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CannedMessagesConfigEnums.swift; sourceTree = ""; }; DD1925B828CDA93900720036 /* SerialConfigEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConfigEnums.swift; sourceTree = ""; }; - DD1BF2F82776FE2E008C8D2F /* MessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageList.swift; sourceTree = ""; }; + DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMessageList.swift; sourceTree = ""; }; DD2160AE28C5552500C17253 /* MQTTConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTConfig.swift; sourceTree = ""; }; DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralModel.swift; sourceTree = ""; }; DD2553562855B02500E55709 /* LoRaConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoRaConfig.swift; sourceTree = ""; }; @@ -155,11 +157,13 @@ DD4F23CC28779A3C001D37CB /* EnvironmentMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentMetricsLog.swift; sourceTree = ""; }; DD5394FD276BA0EF00AD86B1 /* PositionEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionEntityExtension.swift; sourceTree = ""; }; DD539501276DAA6A00AD86B1 /* MapLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLocation.swift; sourceTree = ""; }; + DD58C5F12919AD3C00D5BEFB /* ChannelEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEntityExtension.swift; sourceTree = ""; }; DD6193742862F6E600E59241 /* ExternalNotificationConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalNotificationConfig.swift; sourceTree = ""; }; DD6193762862F90F00E59241 /* CannedMessagesConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CannedMessagesConfig.swift; sourceTree = ""; }; DD6193782863875F00E59241 /* SerialConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConfig.swift; sourceTree = ""; }; DD73FD1028750779000852D6 /* PositionLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionLog.swift; sourceTree = ""; }; DD769E0228D18BF0001A3F05 /* DeviceMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMetricsLog.swift; sourceTree = ""; }; + DD798B062915928D005217CD /* ChannelMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMessageList.swift; sourceTree = ""; }; DD8169F8271F1A6100F4AB02 /* MeshLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshLogger.swift; sourceTree = ""; }; DD8169FA271F1F3A00F4AB02 /* MeshLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshLog.swift; sourceTree = ""; }; DD8169FE272476C700F4AB02 /* LogDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogDocument.swift; sourceTree = ""; }; @@ -491,7 +495,8 @@ isa = PBXGroup; children = ( DD882F5C2772E4640005BF05 /* Contacts.swift */, - DD1BF2F82776FE2E008C8D2F /* MessageList.swift */, + DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */, + DD798B062915928D005217CD /* ChannelMessageList.swift */, ); path = Messages; sourceTree = ""; @@ -532,6 +537,7 @@ DDC4D567275499A500A4208E /* Persistence.swift */, DD5394FD276BA0EF00AD86B1 /* PositionEntityExtension.swift */, DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */, + DD58C5F12919AD3C00D5BEFB /* ChannelEntityExtension.swift */, ); path = Persistence; sourceTree = ""; @@ -700,6 +706,7 @@ DD836AE726F6B38600ABCC23 /* Connect.swift in Sources */, DDAF8C6E26ED19040058C060 /* Extensions.swift in Sources */, DD3501892852FC3B000FC853 /* Settings.swift in Sources */, + DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */, DDA6B2EB28420A7B003E8C16 /* NodeAnnotation.swift in Sources */, DDC2E1A726CEB3400042C5E4 /* LocationHelper.swift in Sources */, DD5394FE276BA0EF00AD86B1 /* PositionEntityExtension.swift in Sources */, @@ -724,7 +731,7 @@ DD6193772862F90F00E59241 /* CannedMessagesConfig.swift in Sources */, DD41582628582E9B009B0E59 /* DeviceConfig.swift in Sources */, DD3CC6B528E33FD100FA9159 /* ShareChannels.swift in Sources */, - DD1BF2F92776FE2E008C8D2F /* MessageList.swift in Sources */, + DD1BF2F92776FE2E008C8D2F /* UserMessageList.swift in Sources */, DD3CC6C228EB9D4900FA9159 /* UpdateCoreData.swift in Sources */, DDB6ABE628B1406100384BA1 /* LoraConfigEnums.swift in Sources */, DDB2CC6E27F3EB47009C5FCC /* telemetry.pb.swift in Sources */, @@ -773,6 +780,7 @@ DD3CC6C028E7A60700FA9159 /* MessagingEnums.swift in Sources */, DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */, C9697F9D279336B700250207 /* LocalMBTileOverlay.swift in Sources */, + DD58C5F22919AD3C00D5BEFB /* ChannelEntityExtension.swift in Sources */, DD0F791B28713C8A00A6FDAD /* AdminMessageList.swift in Sources */, DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */, DD8169F9271F1A6100F4AB02 /* MeshLogger.swift in Sources */, diff --git a/Meshtastic/Enums/MessagingEnums.swift b/Meshtastic/Enums/MessagingEnums.swift index 637cfdc6..251fbb6f 100644 --- a/Meshtastic/Enums/MessagingEnums.swift +++ b/Meshtastic/Enums/MessagingEnums.swift @@ -9,3 +9,56 @@ enum BubblePosition { case left case right } + +enum Tapbacks: Int, CaseIterable, Identifiable { + + case heart = 0 + case thumbsUp = 1 + case thumbsDown = 2 + case haHa = 3 + case exclamation = 4 + case question = 5 + case poop = 6 + + var id: Int { self.rawValue } + var emojiString: String { + get { + switch self { + case .heart: + return "❤️" + case .thumbsUp: + return "👍" + case .thumbsDown: + return "👎" + case .haHa: + return "🤣" + case .exclamation: + return "‼️" + case .question: + return "❓" + case .poop: + return "💩" + } + } + } + var description: String { + get { + switch self { + case .heart: + return "Heart" + case .thumbsUp: + return "Thumbs Up" + case .thumbsDown: + return "Thumbs Down" + case .haHa: + return "HaHa" + case .exclamation: + return "Exclamation Mark" + case .question: + return "Question Mark" + case .poop: + return "Poop" + } + } + } +} diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 0c13dd73..0b70a265 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -593,25 +593,20 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph print("MAX PORT NUM OF 511") } - // MARK: Check for an All / Broadcast User + // MARK: Check for an All / Broadcast User and delete it as a transition to multi channel let fetchBCUserRequest: NSFetchRequest = NSFetchRequest.init(entityName: "UserEntity") fetchBCUserRequest.predicate = NSPredicate(format: "num == %lld", Int64(broadcastNodeNum)) - + do { let fetchedUser = try context?.fetch(fetchBCUserRequest) as! [UserEntity] - if fetchedUser.isEmpty { - // Save the broadcast user if it does not exist - let bcu: UserEntity = UserEntity(context: context!) - bcu.shortName = "ALL" - bcu.longName = "All - Broadcast" - bcu.hwModel = "UNSET" - bcu.num = Int64(broadcastNodeNum) - bcu.userId = "BROADCASTNODE" - print("💾 Saved the All - Broadcast User") + if fetchedUser.count > 0 { + + context?.delete(fetchedUser[0]) + print("🗑️ Deleted the All - Broadcast User") } - + } catch { - MeshLogger.log("💥 Error Saving the All - Broadcast User") + MeshLogger.log("💥 Error Deleting the All - Broadcast User") } // MARK: Share Location Position Update Timer @@ -648,7 +643,7 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph } } - public func sendMessage(message: String, toUserNum: Int64, isEmoji: Bool, replyID: Int64) -> Bool { + public func sendMessage(message: String, toUserNum: Int64, channel: Int32, isEmoji: Bool, replyID: Int64) -> Bool { var success = false @@ -694,26 +689,16 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph newMessage.messageId = Int64(UInt32.random(in: UInt32(UInt8.max).. 0 { + newMessage.toUser = fetchedUsers.first(where: { $0.num == toUserNum }) + } + newMessage.fromUser = fetchedUsers.first(where: { $0.num == fromUserNum }) newMessage.isEmoji = isEmoji newMessage.admin = false - + newMessage.channel = channel if replyID > 0 { - newMessage.replyID = replyID } - if newMessage.toUser == nil { - - let bcu: UserEntity = UserEntity(context: context!) - bcu.shortName = "ALL" - bcu.longName = "All - Broadcast" - bcu.hwModel = "UNSET" - bcu.num = Int64(broadcastNodeNum) - bcu.userId = "BROADCASTNODE" - newMessage.toUser = bcu - } - - newMessage.fromUser = fetchedUsers.first(where: { $0.num == fromUserNum }) newMessage.messagePayload = message let dataType = PortNum.textMessageApp @@ -725,7 +710,12 @@ class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeriph var meshPacket = MeshPacket() meshPacket.id = UInt32(newMessage.messageId) - meshPacket.to = UInt32(toUserNum) + if toUserNum > 0 { + meshPacket.to = UInt32(toUserNum) + } else { + meshPacket.to = 4294967295 + } + meshPacket.channel = UInt32(channel) meshPacket.from = UInt32(fromUserNum) meshPacket.decoded = dataMessage meshPacket.decoded.emoji = isEmoji ? 1 : 0 diff --git a/Meshtastic/Helpers/Extensions.swift b/Meshtastic/Helpers/Extensions.swift index 3ed14763..0c36df3c 100644 --- a/Meshtastic/Helpers/Extensions.swift +++ b/Meshtastic/Helpers/Extensions.swift @@ -68,4 +68,12 @@ extension String { UIGraphicsEndImageContext() return image } + + func camelCaseToWords() -> String { + return unicodeScalars.dropFirst().reduce(String(prefix(1))) { + return CharacterSet.uppercaseLetters.contains($1) + ? $0 + " " + String($1) + : $0 + String($1) + } + } } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 27a254ea..26c7c2b0 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -771,12 +771,12 @@ func channelPacket (channel: Channel, fromNum: Int64, context: NSManagedObjectCo newChannel.role = Int32(channel.role.rawValue) newChannel.psk = channel.settings.psk let mutableChannels = fetchedMyInfo[0].channels!.mutableCopy() as! NSMutableOrderedSet - if newChannel.index == 0 { - mutableChannels.removeAllObjects() + if mutableChannels.contains(newChannel) { + mutableChannels.replaceObject(at: Int(newChannel.index), with: newChannel) + } else { + mutableChannels.add(newChannel) } - mutableChannels.add(newChannel) fetchedMyInfo[0].channels = mutableChannels.copy() as? NSOrderedSet - //fetchedMyInfo[0].objectWillChange.send() do { try context.save() } catch { @@ -1260,13 +1260,6 @@ func textMessageAppPacket(packet: MeshPacket, connectedNode: Int64, context: NSM do { let fetchedUsers = try context.fetch(messageUsers) as! [UserEntity] - - if fetchedUsers.count <= 1 && fetchedUsers.first(where: { $0.num == packet.from }) == nil { - - print("Message from another mesh, unable to manage for now") - return - } - let newMessage = MessageEntity(context: context) newMessage.messageId = Int64(packet.id) newMessage.messageTimestamp = Int32(bitPattern: packet.rxTime) @@ -1277,52 +1270,46 @@ func textMessageAppPacket(packet: MeshPacket, connectedNode: Int64, context: NSM if packet.decoded.replyID > 0 { newMessage.replyID = Int64(packet.decoded.replyID) } - if packet.to == broadcastNodeNum && fetchedUsers.count == 1 { - // Save the broadcast user if it does not exist - let bcu: UserEntity = UserEntity(context: context) - bcu.shortName = "ALL" - bcu.longName = "All - Broadcast" - bcu.hwModel = "UNSET" - bcu.num = Int64(broadcastNodeNum) - bcu.userId = "BROADCASTNODE" - newMessage.toUser = bcu - } else { + if fetchedUsers.first(where: { $0.num == packet.to }) != nil && packet.to != 4294967295 { newMessage.toUser = fetchedUsers.first(where: { $0.num == packet.to }) } - newMessage.fromUser = fetchedUsers.first(where: { $0.num == packet.from }) + if fetchedUsers.first(where: { $0.num == packet.from }) != nil { + newMessage.fromUser = fetchedUsers.first(where: { $0.num == packet.from }) + } + newMessage.messagePayload = messageText newMessage.fromUser?.objectWillChange.send() newMessage.toUser?.objectWillChange.send() - var messageSaved = false + var messageSaved = false - do { + do { - try context.save() - MeshLogger.log("💾 Saved a new message for \(newMessage.messageId)") - messageSaved = true - - if messageSaved { - if newMessage.fromUser != nil { - // Create an iOS Notification for the received message and schedule it immediately - let manager = LocalNotificationManager() - manager.notifications = [ - Notification( - id: ("notification.id.\(newMessage.messageId)"), - title: "\(newMessage.fromUser?.longName ?? "Unknown")", - subtitle: "AKA \(newMessage.fromUser?.shortName ?? "???")", - content: messageText) - ] - manager.schedule() - MeshLogger.log("💬 iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "Unknown")") - } + try context.save() + MeshLogger.log("💾 Saved a new message for \(newMessage.messageId)") + messageSaved = true + + if messageSaved { + if newMessage.fromUser != nil { + // Create an iOS Notification for the received message and schedule it immediately + let manager = LocalNotificationManager() + manager.notifications = [ + Notification( + id: ("notification.id.\(newMessage.messageId)"), + title: "\(newMessage.fromUser?.longName ?? "Unknown")", + subtitle: "AKA \(newMessage.fromUser?.shortName ?? "???")", + content: messageText) + ] + manager.schedule() + MeshLogger.log("💬 iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "Unknown")") } - } catch { - context.rollback() - let nsError = error as NSError - MeshLogger.log("💥 Failed to save new MessageEntity \(nsError)") } + } catch { + context.rollback() + let nsError = error as NSError + MeshLogger.log("💥 Failed to save new MessageEntity \(nsError)") + } } catch { MeshLogger.log("💥 Fetch Message To and From Users Error") } diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModel.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModel.xcdatamodel/contents index f92b0c92..31ed3498 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModel.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -28,6 +28,9 @@ + + + @@ -84,8 +87,8 @@ - - + + @@ -236,7 +239,7 @@ - + \ No newline at end of file diff --git a/Meshtastic/Persistence/ChannelEntityExtension.swift b/Meshtastic/Persistence/ChannelEntityExtension.swift new file mode 100644 index 00000000..4a3bde42 --- /dev/null +++ b/Meshtastic/Persistence/ChannelEntityExtension.swift @@ -0,0 +1,15 @@ +// +// ChannelEntityExtension.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 11/7/22. +// +import Foundation + +extension ChannelEntity { + + var allPrivateMessages: [MessageEntity] { + + self.value(forKey: "allPrivateMessages") as! [MessageEntity] + } +} diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift new file mode 100644 index 00000000..ec47a979 --- /dev/null +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -0,0 +1,325 @@ +// +// UserMessageList.swift +// MeshtasticApple +// +// Created by Garth Vander Houwen on 12/24/21. +// + +import SwiftUI +import CoreData + +struct ChannelMessageList: View { + + @Environment(\.managedObjectContext) var context + @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var userSettings: UserSettings + + enum Field: Hashable { + case messageText + } + + // Keyboard State + @State var typingMessage: String = "" + @State private var totalBytes = 0 + var maxbytes = 228 + @FocusState var focusedField: Field? + + @ObservedObject var channel: ChannelEntity + @State var showDeleteMessageAlert = false + @State private var deleteMessageId: Int64 = 0 + @State private var replyMessageId: Int64 = 0 + @State private var sendPositionWithMessage: Bool = false + @State private var refreshId = UUID() + + var body: some View { + NavigationStack { + ScrollViewReader { scrollView in + ScrollView { + LazyVStack { + ForEach( channel.allPrivateMessages ) { (message: MessageEntity) in + let currentUser: Bool = (userSettings.preferredNodeNum == message.fromUser?.num ? true : false) + if message.replyID > 0 { + let messageReply = channel.allPrivateMessages.first(where: { $0.messageId == message.replyID }) + HStack { + Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.blue).font(.caption2) + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Color.blue, lineWidth: 0.5) + ) + Image(systemName: "arrowshape.turn.up.left.fill") + .symbolRenderingMode(.hierarchical) + .imageScale(.large).foregroundColor(.blue) + .padding(.trailing) + } + } + HStack (alignment: .top) { + if currentUser { Spacer(minLength:50) } + if !currentUser { + CircleText(text: message.fromUser?.shortName ?? "????", color: currentUser ? .accentColor : Color(.darkGray), circleSize: 44, fontSize: 14) + .padding(.all, 5) + .offset(y: -5) + } + VStack(alignment: currentUser ? .trailing : .leading) { + Text(message.messagePayload ?? "EMPTY MESSAGE") + .padding(10) + .foregroundColor(.white) + .background(currentUser ? Color.blue : Color(.darkGray)) + .cornerRadius(15) + .contextMenu { + VStack{ + Text("Channel: \(message.channel)") + } + Menu("Tapback response") { + ForEach(Tapbacks.allCases) { tb in + Button(action: { + if bleManager.sendMessage(message: tb.emojiString, toUserNum: 0, channel: channel.index, isEmoji: true, replyID: message.messageId) { + print("Sent \(tb.emojiString) Tapback") + self.context.refresh(channel, mergeChanges: true) + } else { print("\(tb.emojiString) Tapback Failed") } + + }) { + Text(tb.description) + let image = tb.emojiString.image() + Image(uiImage: image!) + } + } + } + Button(action: { + self.replyMessageId = message.messageId + self.focusedField = .messageText + print("I want to reply to \(message.messageId)") + }) { + Text("Reply") + Image(systemName: "arrowshape.turn.up.left.2.fill") + } + Button(action: { + UIPasteboard.general.string = message.messagePayload + }) { + Text("Copy") + Image(systemName: "doc.on.doc") + } + Menu("Message Details") { + VStack { + let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) + Text("Date \(messageDate, style: .date) \(messageDate.formattedDate(format: "h:mm:ss a"))").font(.caption2).foregroundColor(.gray) + } + if currentUser && message.receivedACK { + VStack { + Text("Received Ack \(message.receivedACK ? "✔️" : "")") + } + } else if currentUser && message.ackError == 0 { + // Empty Error + Text("Waiting. . .") + } else if currentUser && message.ackError > 0 { + let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) + Text("\(ackErrorVal?.display ?? "No Error" )").fixedSize(horizontal: false, vertical: true) + } + if currentUser { + VStack { + let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp)) + let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date()) + if ackDate >= sixMonthsAgo! { + Text((ackDate.formattedDate(format: "h:mm:ss a"))).font(.caption2).foregroundColor(.gray) + } else { + Text("Unknown Age").font(.caption2).foregroundColor(.gray) + } + } + } + if message.ackSNR != 0 { + VStack { + Text("Ack SNR \(String(message.ackSNR))") + .font(.caption2) + .foregroundColor(.gray) + } + } + } + Divider() + Button(role: .destructive, action: { + self.showDeleteMessageAlert = true + self.deleteMessageId = message.messageId + print(deleteMessageId) + }) { + Text("Delete") + Image(systemName: "trash") + } + } + + let tapbacks = message.value(forKey: "tapbacks") as! [MessageEntity] + if tapbacks.count > 0 { + VStack (alignment: .trailing) { + HStack { + ForEach( tapbacks ) { (tapback: MessageEntity) in + VStack { + let image = tapback.messagePayload!.image(fontSize: 20) + Image(uiImage: image!).font(.caption) + Text("\(tapback.fromUser?.shortName ?? "????")") + .font(.caption2) + .foregroundColor(.gray) + .fixedSize() + .padding(.bottom, 1) + } + } + } + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Color.gray, lineWidth: 1) + ) + } + } + HStack { + if currentUser && message.receivedACK { + // Ack Received + Text("Acknowledged").font(.caption2).foregroundColor(.gray) + } else if currentUser && message.ackError == 0 { + // Empty Error + Text("Waiting to be acknowledged. . .").font(.caption2).foregroundColor(.orange) + } else if currentUser && message.ackError > 0 { + let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) + Text("\(ackErrorVal?.display ?? "No Error" )").fixedSize(horizontal: false, vertical: true) + .font(.caption2).foregroundColor(.red) + } + } + } + .padding(.bottom) + .id(channel.allPrivateMessages.firstIndex(of: message)) + if !currentUser { + Spacer(minLength:50) + } + } + .padding([.leading, .trailing]) + .frame(maxWidth: .infinity) + .id(message.messageId) + .alert(isPresented: $showDeleteMessageAlert) { + Alert(title: Text("Are you sure you want to delete this message?"), message: Text("This action is permanent."), primaryButton: .destructive(Text("Delete")) { + print("OK button tapped") + if deleteMessageId > 0 { + let message = channel.allPrivateMessages.first(where: { $0.messageId == deleteMessageId }) + context.delete(message!) + do { + try context.save() + deleteMessageId = 0 + } catch { + print("Failed to delete message \(deleteMessageId)") + } + } + }, secondaryButton: .cancel()) + } + } + } + } + .padding([.top]) + .scrollDismissesKeyboard(.immediately) + .onAppear(perform: { + self.bleManager.context = context + refreshId = UUID() + if channel.allPrivateMessages.count > 0 { + scrollView.scrollTo(channel.allPrivateMessages.last!.messageId) + } + }) + .onChange(of: channel.allPrivateMessages, perform: { messages in + refreshId = UUID() + if channel.allPrivateMessages.count > 0 { + scrollView.scrollTo(channel.allPrivateMessages.last!.messageId) + } + }) + } + HStack(alignment: .top) { + + ZStack { + let kbType = UIKeyboardType(rawValue: UserDefaults.standard.object(forKey: "keyboardType") as? Int ?? 0) + TextField("Message", text: $typingMessage, axis: .vertical) + .onChange(of: typingMessage, perform: { value in + totalBytes = value.utf8.count + // Only mess with the value if it is too big + if totalBytes > maxbytes { + let firstNBytes = Data(typingMessage.utf8.prefix(maxbytes)) + if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) { + // Set the message back to the last place where it was the right size + typingMessage = maxBytesString + } else { + print("not a valid UTF-8 sequence") + } + } + }) + .keyboardType(kbType!) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Button("Dismiss Keyboard") { + focusedField = nil + } + .font(.subheadline) + Spacer() + Button { + let userLongName = bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.longName : "Unknown" + sendPositionWithMessage = true + if userSettings.meshtasticUsername.count > 0 { + + typingMessage = "📍 " + userSettings.meshtasticUsername + " has shared their position with you from node " + userLongName + + } else { + + typingMessage = "📍 " + userLongName + " has shared their position with you." + } + + } label: { + Image(systemName: "mappin.and.ellipse") + .symbolRenderingMode(.hierarchical) + .imageScale(.large).foregroundColor(.accentColor) + } + + ProgressView("Bytes: \(totalBytes) / \(maxbytes)", value: Double(totalBytes), total: Double(maxbytes)) + .frame(width: 130) + .padding(5) + .font(.subheadline) + .accentColor(.accentColor) + } + } + .padding(.horizontal, 8) + .focused($focusedField, equals: .messageText) + .multilineTextAlignment(.leading) + .frame(minHeight: 50) + + Text(typingMessage).opacity(0).padding(.all, 0) + + } + .overlay(RoundedRectangle(cornerRadius: 20).stroke(.tertiary, lineWidth: 1)) + .padding(.bottom, 15) + Button(action: { + if bleManager.sendMessage(message: typingMessage, toUserNum: 0, channel: channel.index, isEmoji: false, replyID: replyMessageId) { + typingMessage = "" + focusedField = nil + replyMessageId = 0 + if sendPositionWithMessage { + if bleManager.sendLocation(destNum: Int64(channel.index), wantAck: true) { + print("Location Sent") + } + } + } + }) { + Image(systemName: "arrow.up.circle.fill").font(.largeTitle).foregroundColor(.blue) + } + } + .padding(.all, 15) + } + .navigationViewStyle(.stack) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + HStack { + CircleText(text: String(channel.index), color: .blue, circleSize: 44, fontSize: 30).fixedSize() + Text(String(channel.name ?? "Unknown").camelCaseToWords()).font(.headline) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + ZStack { + ConnectedDevice( + bluetoothOn: bleManager.isSwitchedOn, + deviceConnected: bleManager.connectedPeripheral != nil, + name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "????") + } + } + } + } +} diff --git a/Meshtastic/Views/Messages/Contacts.swift b/Meshtastic/Views/Messages/Contacts.swift index dd802a0f..4238ad61 100644 --- a/Meshtastic/Views/Messages/Contacts.swift +++ b/Meshtastic/Views/Messages/Contacts.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CoreData struct Contacts: View { @@ -18,17 +19,7 @@ struct Contacts: View { animation: .default) private var users: FetchedResults - - - - private var prefferedNode: NodeInfoEntity? - - @FetchRequest( - sortDescriptors: [NSSortDescriptor(key: "num", ascending: true)], - animation: .default) - - private var nodes: FetchedResults - + @State var node: NodeInfoEntity? = nil @State private var selection: UserEntity? = nil // Nothing selected by default. @@ -36,96 +27,125 @@ struct Contacts: View { NavigationSplitView { List { - Section(header: Text("Primary Channel")) { - ForEach(users) { (user: UserEntity) in - - if user.num != bleManager.userSettings?.preferredNodeNum ?? 0 { + Section(header: Text("Channels (groups)")) { + // Display Contacts for the rest of the non admin channels + if node != nil { + ForEach(node!.myInfo!.channels?.array as! [ChannelEntity], id: \.self) { (channel: ChannelEntity) in + if channel.name?.lowercased() ?? "" != "admin" && channel.name?.lowercased() ?? "" != "gpio" { + VStack { + NavigationLink(destination: ChannelMessageList(channel: channel)) { - NavigationLink(destination: MessageList(user: user)) { - - if user.messageList.count > 0 { - - let mostRecent = user.num == bleManager.broadcastNodeNum ? user.messageList.last : user.messageList.last(where: { $0.toUser?.num ?? 0 != bleManager.broadcastNodeNum }) - let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 )))) - let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0 - let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0 - - HStack { - VStack(alignment: .leading) { - CircleText(text: user.shortName ?? "???", color: Color.blue) - .padding(.trailing, 5) - } - VStack { - HStack { - VStack { - Text(user.longName ?? "Unknown").font(.headline).fixedSize() - } - VStack { + let mostRecent = channel.allPrivateMessages.last + let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 )))) + let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0 + let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0 + HStack { + VStack(alignment: .leading) { + HStack { + CircleText(text: String(channel.index), color: Color.blue, circleSize: 52, fontSize: 40) + .padding(.trailing, 5) + VStack { + Text(String(channel.name ?? "Channel \(channel.index)").camelCaseToWords()).font(.headline) + } + .frame(maxWidth: .infinity, alignment: .leading) + if channel.allPrivateMessages.count > 0 { + VStack (alignment: .trailing) { + if lastMessageDay == currentDay { + Text(lastMessageTime, style: .time ) + .font(.callout) + .foregroundColor(.gray) + } else if lastMessageDay == (currentDay - 1) { + Text("Yesterday") + .font(.callout) + .foregroundColor(.gray) + } else if lastMessageDay < (currentDay - 1) && lastMessageDay > (currentDay - 5) { + Text(lastMessageTime.formattedDate(format: "MM/dd/yy")) + .font(.callout) + .foregroundColor(.gray) + } else if lastMessageDay < (currentDay - 1800) { + Text(lastMessageTime.formattedDate(format: "MM/dd/yy")) + .font(.callout) + .foregroundColor(.gray) + } + } + } + } + if channel.allPrivateMessages.count > 0 { + HStack(alignment: .top) { + Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")") + .truncationMode(.tail) + .foregroundColor(Color.gray) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 10) + .padding(.bottom, 10) + } + } + Section(header: Text("Direct Messages (Primary Channel)")) { + ForEach(users) { (user: UserEntity) in + if user.num != bleManager.userSettings?.preferredNodeNum ?? 0 { + NavigationLink(destination: UserMessageList(user: user)) { + let mostRecent = user.num == bleManager.broadcastNodeNum ? user.messageList.last : user.messageList.last(where: { $0.toUser?.num ?? 0 != bleManager.broadcastNodeNum }) + let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 )))) + let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0 + let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0 + HStack { + VStack(alignment: .leading) { + HStack { + CircleText(text: user.shortName ?? "???", color: Color.blue, circleSize: 52, fontSize: 16) + .padding(.trailing, 5) + VStack { + Text(user.longName ?? "Unknown").font(.headline) + } + .frame(maxWidth: .infinity, alignment: .leading) + + if user.messageList.count > 0 { + VStack (alignment: .trailing) { if lastMessageDay == currentDay { Text(lastMessageTime, style: .time ) - .font(.caption) + .font(.callout) .foregroundColor(.gray) } else if lastMessageDay == (currentDay - 1) { - Text("Yesterday") .font(.callout) .foregroundColor(.gray) - } else if lastMessageDay < (currentDay - 1) && lastMessageDay > (currentDay - 5) { - Text(lastMessageTime, style: .date) + Text(lastMessageTime.formattedDate(format: "MM/dd/yy")) + .font(.callout) + .foregroundColor(.gray) } else if lastMessageDay < (currentDay - 1800) { - Text(lastMessageTime, style: .date) + Text(lastMessageTime.formattedDate(format: "MM/dd/yy")) + .font(.callout) + .foregroundColor(.gray) } } - .frame(maxWidth: .infinity, alignment: .trailing) } + } + if user.messageList.count > 0 { HStack(alignment: .top) { Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")") - .frame(height: 50) .truncationMode(.tail) .foregroundColor(Color.gray) .frame(maxWidth: .infinity, alignment: .leading) } } - .padding(.top) - } - } else { - HStack { - VStack(alignment: .leading) { - CircleText(text: user.shortName ?? "???", color: Color.blue) - .padding(.trailing, 5) - } - VStack { - HStack { - VStack { - Text(user.longName ?? "Unknown").font(.headline).fixedSize() - } - VStack { - Text(" ") - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - HStack(alignment: .top) { - Text(" ") - .frame(height: 50 ) - .truncationMode(.tail) - .foregroundColor(Color.gray) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(.top) } } } + .padding(.top, 10) + .padding(.bottom, 10) } } } - Section(header: Text("Private Channels")) { - // Display Contacts for the rest of the non admin channels - - } - .hidden() } .tint(Color(UIColor.systemGray)) .navigationSplitViewStyle(.automatic) @@ -134,18 +154,37 @@ struct Contacts: View { .navigationBarItems(leading: MeshtasticLogo() ) + .onAppear { + self.bleManager.userSettings = userSettings + self.bleManager.context = context + + if userSettings.preferredNodeNum > 0 { + + let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(userSettings.preferredNodeNum)) + + do { + + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] + // Found a node, check it for a region + if !fetchedNode.isEmpty { + node = fetchedNode[0] + + } + } catch { + + } + } + + } } detail: { - if let user = selection { - - MessageList(user:user) + UserMessageList(user:user) } else { - Text("Select a user") } } - } } diff --git a/Meshtastic/Views/Messages/MessageList.swift b/Meshtastic/Views/Messages/MessageList.swift deleted file mode 100644 index 310326c6..00000000 --- a/Meshtastic/Views/Messages/MessageList.swift +++ /dev/null @@ -1,439 +0,0 @@ -// -// UserMessageList.swift -// MeshtasticApple -// -// Created by Garth Vander Houwen on 12/24/21. -// - -import SwiftUI -import CoreData - -struct MessageList: View { - - @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager - @EnvironmentObject var userSettings: UserSettings - - enum Field: Hashable { - case messageText - } - - // Keyboard State - @State var typingMessage: String = "" - @State private var totalBytes = 0 - var maxbytes = 228 - @FocusState var focusedField: Field? - - @ObservedObject var user: UserEntity - @State var showDeleteMessageAlert = false - @State private var deleteMessageId: Int64 = 0 - @State private var replyMessageId: Int64 = 0 - @State private var sendPositionWithMessage: Bool = false - @State private var refreshId = UUID() - - var body: some View { - NavigationStack { - ScrollViewReader { scrollView in - ScrollView { - LazyVStack { - ForEach( user.messageList ) { (message: MessageEntity) in - if user.num != userSettings.preferredNodeNum { - let currentUser: Bool = (userSettings.preferredNodeNum == message.fromUser?.num ? true : false) - if (user.num == bleManager.broadcastNodeNum || user.num != bleManager.broadcastNodeNum && message.toUser!.num != bleManager.broadcastNodeNum) { - if message.replyID > 0 { - let messageReply = user.messageList.first(where: { $0.messageId == message.replyID }) - HStack { - Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.blue).font(.caption2) - .padding(10) - .overlay( - RoundedRectangle(cornerRadius: 18) - .stroke(Color.blue, lineWidth: 0.5) - ) - Image(systemName: "arrowshape.turn.up.left.fill") - .symbolRenderingMode(.hierarchical) - .imageScale(.large).foregroundColor(.blue) - .padding(.trailing) - } - } - HStack (alignment: .top) { - if currentUser { Spacer(minLength:50) } - if !currentUser { - CircleText(text: message.fromUser?.shortName ?? "????", color: currentUser ? .accentColor : Color(.darkGray), circleSize: 44, fontSize: 14) - .padding(.all, 5) - .offset(y: -5) - } - VStack(alignment: currentUser ? .trailing : .leading) { - Text(message.messagePayload ?? "EMPTY MESSAGE") - .padding(10) - .foregroundColor(.white) - .background(currentUser ? Color.blue : Color(.darkGray)) - .cornerRadius(15) - .contextMenu { - Menu("Tapback response") { - - Button(action: { - if bleManager.sendMessage(message: "❤️", toUserNum: user.num, isEmoji: true, replyID: message.messageId) { - print("Sent ❤️ Tapback") - self.context.refresh(user, mergeChanges: true) - } else { print("❤️ Tapback Failed") } - - }) { - Text("Heart") - let image = "❤️".image() - Image(uiImage: image!) - } - Button(action: { - - if bleManager.sendMessage(message: "👍", toUserNum: user.num, isEmoji: true, replyID: message.messageId) { - - print("Sent 👍 Tapback") - self.context.refresh(user, mergeChanges: true) - - } else { print("👍 Tapback Failed")} - - }) { - Text("Thumbs Up") - let image = "👍".image() - Image(uiImage: image!) - } - Button(action: { - - if bleManager.sendMessage(message: "👎", toUserNum: user.num, isEmoji: true, replyID: message.messageId) { - - print("Sent 👎 Tapback") - self.context.refresh(user, mergeChanges: true) - - } else { print("👎 Tapback Failed") } - - }) { - Text("Thumbs Down") - let image = "👎".image() - Image(uiImage: image!) - } - Button(action: { - - if bleManager.sendMessage(message: "🤣", toUserNum: user.num, isEmoji: true, replyID: message.messageId) { - - print("Sent 🤣 Tapback") - self.context.refresh(user, mergeChanges: true) - - } else { print("🤣 Tapback Failed") } - - }) { - Text("HaHa") - let image = "🤣".image() - Image(uiImage: image!) - } - Button(action: { - - if bleManager.sendMessage(message: "‼️", toUserNum: user.num, isEmoji: true, replyID: message.messageId) { - - print("Sent ‼️ Tapback") - self.context.refresh(user, mergeChanges: true) - - } else { print("‼️ Tapback Failed") } - - }) { - Text("Exclamation Mark") - let image = "‼️".image() - Image(uiImage: image!) - } - Button(action: { - if bleManager.sendMessage(message: "❓", toUserNum: user.num, isEmoji: true, replyID: message.messageId) { - self.context.refresh(user, mergeChanges: true) - print("Sent ❓ Tapback") - } else { print("❓ Tapback Failed") } - }) { - Text("Question Mark") - let image = "❓".image() - Image(uiImage: image!) - } - Button(action: { - if bleManager.sendMessage(message: "💩", toUserNum: user.num, isEmoji: true, replyID: message.messageId) { - self.context.refresh(user, mergeChanges: true) - print("Sent 💩 Tapback") - } else { print("💩 Tapback Failed") } - }) { - Text("Poop") - let image = "💩".image() - Image(uiImage: image!) - } - } - Button(action: { - self.replyMessageId = message.messageId - self.focusedField = .messageText - - print("I want to reply to \(message.messageId)") - }) { - Text("Reply") - Image(systemName: "arrowshape.turn.up.left.2.fill") - } - Button(action: { - UIPasteboard.general.string = message.messagePayload - }) { - Text("Copy") - Image(systemName: "doc.on.doc") - } - Menu("Message Details") { - VStack { - let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) - Text("Date \(messageDate, style: .date) \(messageDate.formattedDate(format: "h:mm:ss a"))").font(.caption2).foregroundColor(.gray) - } - if currentUser && message.receivedACK { - - VStack { - - Text("Received Ack \(message.receivedACK ? "✔️" : "")") - } - - } else if currentUser && message.ackError == 0 { - - // Empty Error - Text("Waiting. . .") - - } else if currentUser && message.ackError > 0 { - - let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) - Text("\(ackErrorVal?.display ?? "No Error" )").fixedSize(horizontal: false, vertical: true) - } - - if currentUser { - - VStack { - - let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp)) - - let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date()) - if ackDate >= sixMonthsAgo! { - - Text((ackDate.formattedDate(format: "h:mm:ss a"))).font(.caption2).foregroundColor(.gray) - - } else { - - Text("Unknown Age").font(.caption2).foregroundColor(.gray) - } - } - } - - if message.ackSNR != 0 { - VStack { - - Text("Ack SNR \(String(message.ackSNR))") - .font(.caption2) - .foregroundColor(.gray) - } - } - } - Divider() - Button(role: .destructive, action: { - self.showDeleteMessageAlert = true - self.deleteMessageId = message.messageId - print(deleteMessageId) - }) { - Text("Delete") - Image(systemName: "trash") - } - } - - let tapbacks = message.value(forKey: "tapbacks") as! [MessageEntity] - if tapbacks.count > 0 { - - VStack (alignment: .trailing) { - - HStack { - - ForEach( tapbacks ) { (tapback: MessageEntity) in - - VStack { - - let image = tapback.messagePayload!.image(fontSize: 20) - Image(uiImage: image!).font(.caption) - Text("\(tapback.fromUser?.shortName ?? "????")") - .font(.caption2) - .foregroundColor(.gray) - .fixedSize() - .padding(.bottom, 1) - } - } - } - .padding(10) - .overlay( - RoundedRectangle(cornerRadius: 18) - .stroke(Color.gray, lineWidth: 1) - ) - } - } - HStack { - if currentUser && message.receivedACK { - // Ack Received - Text("Acknowledged").font(.caption2).foregroundColor(.gray) - } else if currentUser && message.ackError == 0 { - // Empty Error - Text("Waiting to be acknowledged. . .").font(.caption2).foregroundColor(.orange) - } else if currentUser && message.ackError > 0 { - let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) - Text("\(ackErrorVal?.display ?? "No Error" )").fixedSize(horizontal: false, vertical: true) - .font(.caption2).foregroundColor(.red) - } - } - } - .padding(.bottom) - .id(user.messageList.firstIndex(of: message)) - if !currentUser { - Spacer(minLength:50) - } - } - .padding([.leading, .trailing]) - .frame(maxWidth: .infinity) - .id(message.messageId) - .alert(isPresented: $showDeleteMessageAlert) { - Alert(title: Text("Are you sure you want to delete this message?"), message: Text("This action is permanent."), primaryButton: .destructive(Text("Delete")) { - print("OK button tapped") - if deleteMessageId > 0 { - let message = user.messageList.first(where: { $0.messageId == deleteMessageId }) - context.delete(message!) - do { - try context.save() - deleteMessageId = 0 - } catch { - print("Failed to delete message \(deleteMessageId)") - } - } - }, - secondaryButton: .cancel() - ) - } - } - } - } - } - } - .padding([.top]) - .scrollDismissesKeyboard(.immediately) - .onAppear(perform: { - self.bleManager.context = context - refreshId = UUID() - if user.messageList.count > 0 { - scrollView.scrollTo(user.messageList.last!.messageId) - } - }) - .onChange(of: user.messageList, perform: { messages in - refreshId = UUID() - if user.messageList.count > 0 { - scrollView.scrollTo(user.messageList.last!.messageId) - } - }) - } - HStack(alignment: .top) { - - ZStack { - let kbType = UIKeyboardType(rawValue: UserDefaults.standard.object(forKey: "keyboardType") as? Int ?? 0) - TextField("Message", text: $typingMessage, axis: .vertical) - .onChange(of: typingMessage, perform: { value in - totalBytes = value.utf8.count - // Only mess with the value if it is too big - if totalBytes > maxbytes { - let firstNBytes = Data(typingMessage.utf8.prefix(maxbytes)) - if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) { - // Set the message back to the last place where it was the right size - typingMessage = maxBytesString - } else { - print("not a valid UTF-8 sequence") - } - } - }) - .keyboardType(kbType!) - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - - Button("Dismiss Keyboard") { - focusedField = nil - } - .font(.subheadline) - - Spacer() - - Button { - let userLongName = bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.longName : "Unknown" - sendPositionWithMessage = true - if user.num == bleManager.broadcastNodeNum { - - if userSettings.meshtasticUsername.count > 0 { - - typingMessage = "📍 " + userSettings.meshtasticUsername + " has shared their position with the mesh from node " + userLongName - } else { - - typingMessage = "📍 " + userLongName + " has shared their position with the mesh." - } - - } else { - - if userSettings.meshtasticUsername.count > 0 { - - typingMessage = "📍 " + userSettings.meshtasticUsername + " has shared their position with you from node " + userLongName - - } else { - - typingMessage = "📍 " + userLongName + " has shared their position with you." - } - } - } label: { - Image(systemName: "mappin.and.ellipse") - .symbolRenderingMode(.hierarchical) - .imageScale(.large).foregroundColor(.accentColor) - } - - ProgressView("Bytes: \(totalBytes) / \(maxbytes)", value: Double(totalBytes), total: Double(maxbytes)) - .frame(width: 130) - .padding(5) - .font(.subheadline) - .accentColor(.accentColor) - } - } - .padding(.horizontal, 8) - .focused($focusedField, equals: .messageText) - .multilineTextAlignment(.leading) - .frame(minHeight: 50) - - Text(typingMessage).opacity(0).padding(.all, 0) - - } - .overlay(RoundedRectangle(cornerRadius: 20).stroke(.tertiary, lineWidth: 1)) - .padding(.bottom, 15) - Button(action: { - if bleManager.sendMessage(message: typingMessage, toUserNum: user.num, isEmoji: false, replyID: replyMessageId) { - typingMessage = "" - focusedField = nil - replyMessageId = 0 - if sendPositionWithMessage { - if bleManager.sendLocation(destNum: user.num, wantAck: true) { - print("Location Sent") - } - } - } - }) { - Image(systemName: "arrow.up.circle.fill").font(.largeTitle).foregroundColor(.blue) - } - } - .padding(.all, 15) - } - .navigationViewStyle(.stack) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - HStack { - CircleText(text: user.shortName ?? "???", color: .blue, circleSize: 42, fontSize: 16).fixedSize() - Text(user.longName ?? "Unknown").font(.headline).fixedSize() - } - } - ToolbarItem(placement: .navigationBarTrailing) { - ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "????") - } - } - } - } -} diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift new file mode 100644 index 00000000..c083b9da --- /dev/null +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -0,0 +1,341 @@ +// +// UserMessageList.swift +// MeshtasticApple +// +// Created by Garth Vander Houwen on 12/24/21. +// + +import SwiftUI +import CoreData + +struct UserMessageList: View { + + @Environment(\.managedObjectContext) var context + @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var userSettings: UserSettings + + enum Field: Hashable { + case messageText + } + + // Keyboard State + @State var typingMessage: String = "" + @State private var totalBytes = 0 + var maxbytes = 228 + @FocusState var focusedField: Field? + + @ObservedObject var user: UserEntity + @State var showDeleteMessageAlert = false + @State private var deleteMessageId: Int64 = 0 + @State private var replyMessageId: Int64 = 0 + @State private var sendPositionWithMessage: Bool = false + @State private var refreshId = UUID() + + var body: some View { + NavigationStack { + ScrollViewReader { scrollView in + ScrollView { + LazyVStack { + ForEach( user.messageList ) { (message: MessageEntity) in + if user.num != userSettings.preferredNodeNum { + let currentUser: Bool = (userSettings.preferredNodeNum == message.fromUser?.num ? true : false) + + if message.replyID > 0 { + let messageReply = user.messageList.first(where: { $0.messageId == message.replyID }) + HStack { + Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.blue).font(.caption2) + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Color.blue, lineWidth: 0.5) + ) + Image(systemName: "arrowshape.turn.up.left.fill") + .symbolRenderingMode(.hierarchical) + .imageScale(.large).foregroundColor(.blue) + .padding(.trailing) + } + } + HStack (alignment: .top) { + if currentUser { Spacer(minLength:50) } + if !currentUser { + CircleText(text: message.fromUser?.shortName ?? "????", color: currentUser ? .accentColor : Color(.darkGray), circleSize: 44, fontSize: 14) + .padding(.all, 5) + .offset(y: -5) + } + VStack(alignment: currentUser ? .trailing : .leading) { + Text(message.messagePayload ?? "EMPTY MESSAGE") + .padding(10) + .foregroundColor(.white) + .background(currentUser ? Color.blue : Color(.darkGray)) + .cornerRadius(15) + .contextMenu { + VStack{ + Text("Channel: \(message.channel)") + } + Menu("Tapback response") { + ForEach(Tapbacks.allCases) { tb in + Button(action: { + if bleManager.sendMessage(message: tb.emojiString, toUserNum: user.num, channel: 0, isEmoji: true, replyID: message.messageId) { + print("Sent \(tb.emojiString) Tapback") + self.context.refresh(user, mergeChanges: true) + } else { print("\(tb.emojiString) Tapback Failed") } + + }) { + Text(tb.description) + let image = tb.emojiString.image() + Image(uiImage: image!) + } + } + } + Button(action: { + self.replyMessageId = message.messageId + self.focusedField = .messageText + print("I want to reply to \(message.messageId)") + }) { + Text("Reply") + Image(systemName: "arrowshape.turn.up.left.2.fill") + } + Button(action: { + UIPasteboard.general.string = message.messagePayload + }) { + Text("Copy") + Image(systemName: "doc.on.doc") + } + Menu("Message Details") { + VStack { + let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) + Text("Date \(messageDate, style: .date) \(messageDate.formattedDate(format: "h:mm:ss a"))").font(.caption2).foregroundColor(.gray) + } + if currentUser && message.receivedACK { + VStack { + Text("Received Ack \(message.receivedACK ? "✔️" : "")") + } + } else if currentUser && message.ackError == 0 { + // Empty Error + Text("Waiting. . .") + } else if currentUser && message.ackError > 0 { + let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) + Text("\(ackErrorVal?.display ?? "No Error" )").fixedSize(horizontal: false, vertical: true) + } + if currentUser { + VStack { + let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp)) + let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date()) + if ackDate >= sixMonthsAgo! { + Text((ackDate.formattedDate(format: "h:mm:ss a"))).font(.caption2).foregroundColor(.gray) + } else { + Text("Unknown Age").font(.caption2).foregroundColor(.gray) + } + } + } + if message.ackSNR != 0 { + VStack { + Text("Ack SNR \(String(message.ackSNR))") + .font(.caption2) + .foregroundColor(.gray) + } + } + } + Divider() + Button(role: .destructive, action: { + self.showDeleteMessageAlert = true + self.deleteMessageId = message.messageId + print(deleteMessageId) + }) { + Text("Delete") + Image(systemName: "trash") + } + } + + let tapbacks = message.value(forKey: "tapbacks") as! [MessageEntity] + if tapbacks.count > 0 { + VStack (alignment: .trailing) { + HStack { + ForEach( tapbacks ) { (tapback: MessageEntity) in + VStack { + let image = tapback.messagePayload!.image(fontSize: 20) + Image(uiImage: image!).font(.caption) + Text("\(tapback.fromUser?.shortName ?? "????")") + .font(.caption2) + .foregroundColor(.gray) + .fixedSize() + .padding(.bottom, 1) + } + } + } + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Color.gray, lineWidth: 1) + ) + } + } + HStack { + if currentUser && message.receivedACK { + // Ack Received + Text("Acknowledged").font(.caption2).foregroundColor(.gray) + } else if currentUser && message.ackError == 0 { + // Empty Error + Text("Waiting to be acknowledged. . .").font(.caption2).foregroundColor(.orange) + } else if currentUser && message.ackError > 0 { + let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) + Text("\(ackErrorVal?.display ?? "No Error" )").fixedSize(horizontal: false, vertical: true) + .font(.caption2).foregroundColor(.red) + } + } + } + .padding(.bottom) + .id(user.messageList.firstIndex(of: message)) + if !currentUser { + Spacer(minLength:50) + } + } + .padding([.leading, .trailing]) + .frame(maxWidth: .infinity) + .id(message.messageId) + .alert(isPresented: $showDeleteMessageAlert) { + Alert(title: Text("Are you sure you want to delete this message?"), message: Text("This action is permanent."), primaryButton: .destructive(Text("Delete")) { + print("OK button tapped") + if deleteMessageId > 0 { + let message = user.messageList.first(where: { $0.messageId == deleteMessageId }) + context.delete(message!) + do { + try context.save() + deleteMessageId = 0 + } catch { + print("Failed to delete message \(deleteMessageId)") + } + } + }, secondaryButton: .cancel()) + } + + } + } + } + } + .padding([.top]) + .scrollDismissesKeyboard(.immediately) + .onAppear(perform: { + self.bleManager.context = context + refreshId = UUID() + if user.messageList.count > 0 { + scrollView.scrollTo(user.messageList.last!.messageId) + } + }) + .onChange(of: user.messageList, perform: { messages in + refreshId = UUID() + if user.messageList.count > 0 { + scrollView.scrollTo(user.messageList.last!.messageId) + } + }) + } + HStack(alignment: .top) { + + ZStack { + let kbType = UIKeyboardType(rawValue: UserDefaults.standard.object(forKey: "keyboardType") as? Int ?? 0) + TextField("Message", text: $typingMessage, axis: .vertical) + .onChange(of: typingMessage, perform: { value in + totalBytes = value.utf8.count + // Only mess with the value if it is too big + if totalBytes > maxbytes { + let firstNBytes = Data(typingMessage.utf8.prefix(maxbytes)) + if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) { + // Set the message back to the last place where it was the right size + typingMessage = maxBytesString + } else { + print("not a valid UTF-8 sequence") + } + } + }) + .keyboardType(kbType!) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Button("Dismiss Keyboard") { + focusedField = nil + } + .font(.subheadline) + Spacer() + Button { + let userLongName = bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.longName : "Unknown" + sendPositionWithMessage = true + if user.num == bleManager.broadcastNodeNum { + + if userSettings.meshtasticUsername.count > 0 { + + typingMessage = "📍 " + userSettings.meshtasticUsername + " has shared their position with the mesh from node " + userLongName + } else { + + typingMessage = "📍 " + userLongName + " has shared their position with the mesh." + } + + } else { + + if userSettings.meshtasticUsername.count > 0 { + + typingMessage = "📍 " + userSettings.meshtasticUsername + " has shared their position with you from node " + userLongName + + } else { + + typingMessage = "📍 " + userLongName + " has shared their position with you." + } + } + } label: { + Image(systemName: "mappin.and.ellipse") + .symbolRenderingMode(.hierarchical) + .imageScale(.large).foregroundColor(.accentColor) + } + + ProgressView("Bytes: \(totalBytes) / \(maxbytes)", value: Double(totalBytes), total: Double(maxbytes)) + .frame(width: 130) + .padding(5) + .font(.subheadline) + .accentColor(.accentColor) + } + } + .padding(.horizontal, 8) + .focused($focusedField, equals: .messageText) + .multilineTextAlignment(.leading) + .frame(minHeight: 50) + + Text(typingMessage).opacity(0).padding(.all, 0) + + } + .overlay(RoundedRectangle(cornerRadius: 20).stroke(.tertiary, lineWidth: 1)) + .padding(.bottom, 15) + Button(action: { + if bleManager.sendMessage(message: typingMessage, toUserNum: user.num, channel: 0, isEmoji: false, replyID: replyMessageId) { + typingMessage = "" + focusedField = nil + replyMessageId = 0 + if sendPositionWithMessage { + if bleManager.sendLocation(destNum: user.num, wantAck: true) { + print("Location Sent") + } + } + } + }) { + Image(systemName: "arrow.up.circle.fill").font(.largeTitle).foregroundColor(.blue) + } + } + .padding(.all, 15) + } + .navigationViewStyle(.stack) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + HStack { + CircleText(text: user.shortName ?? "???", color: .blue, circleSize: 44, fontSize: 14).fixedSize() + Text(user.longName ?? "Unknown").font(.headline) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + ZStack { + ConnectedDevice( + bluetoothOn: bleManager.isSwitchedOn, + deviceConnected: bleManager.connectedPeripheral != nil, + name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "????") + } + } + } + } +} diff --git a/Meshtastic/Views/Nodes/NodeDetail.swift b/Meshtastic/Views/Nodes/NodeDetail.swift index aa2dca85..6d052431 100644 --- a/Meshtastic/Views/Nodes/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/NodeDetail.swift @@ -431,8 +431,7 @@ struct NodeDetail: View { .offset( y:-40) } .edgesIgnoringSafeArea([.leading, .trailing]) - .navigationTitle((node.user != nil) ? String(node.user!.longName ?? "Unknown") : "Unknown") - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitle((node.user != nil) ? String(node.user!.longName ?? "Unknown") : "Unknown", displayMode: .inline) .padding(.bottom, 10) .navigationBarItems(trailing: diff --git a/Meshtastic/Views/Nodes/PositionLog.swift b/Meshtastic/Views/Nodes/PositionLog.swift index 87cec4a2..984d22bb 100644 --- a/Meshtastic/Views/Nodes/PositionLog.swift +++ b/Meshtastic/Views/Nodes/PositionLog.swift @@ -24,36 +24,30 @@ struct PositionLog: View { if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { //Add a table for mac and ipad - VStack { - Table(node.positions!.reversed() as! [PositionEntity]) { - TableColumn("Seq No") { position in - Text(String(position.seqNo)) - } - //.width(75) - TableColumn("Latitude") { position in - Text(String(format: "%.6f", position.latitude ?? 0)) - } - TableColumn("Longitude") { position in - Text(String(format: "%.6f", position.longitude ?? 0)) - } - TableColumn("Altitude") { position in - Text(String(position.altitude)) - } - //.width(75) - TableColumn("Sats") { position in - Text(String(position.satsInView)) - } - //.width(75) - TableColumn("Speed") { position in - Text(String(position.speed)) - } - //.width(75) - TableColumn("Heading") { position in - Text(String(position.heading)) - } - TableColumn("Time Stamp") { position in - Text(position.time?.formattedDate(format: "MM/dd/yy hh:mm") ?? "Unknown time") - } + Table(node.positions!.reversed() as! [PositionEntity]) { + TableColumn("SeqNo") { position in + Text(String(position.seqNo)) + } + TableColumn("Latitude") { position in + Text(String(format: "%.6f", position.latitude ?? 0)) + } + TableColumn("Longitude") { position in + Text(String(format: "%.6f", position.longitude ?? 0)) + } + TableColumn("Altitude") { position in + Text(String(position.altitude)) + } + TableColumn("Sats") { position in + Text(String(position.satsInView)) + } + TableColumn("Speed") { position in + Text(String(position.speed)) + } + TableColumn("Heading") { position in + Text(String(position.heading)) + } + TableColumn("Time Stamp") { position in + Text(position.time?.formattedDate(format: "MM/dd/yy hh:mm") ?? "Unknown time") } } diff --git a/Meshtastic/Views/Settings/ShareChannels.swift b/Meshtastic/Views/Settings/ShareChannels.swift index 7f88867b..4862835c 100644 --- a/Meshtastic/Views/Settings/ShareChannels.swift +++ b/Meshtastic/Views/Settings/ShareChannels.swift @@ -49,223 +49,221 @@ struct ShareChannels: View { var body: some View { - VStack { - GeometryReader { bounds in - let smallest = min(bounds.size.width, bounds.size.height) - ScrollView { - VStack { - if node != nil && node?.myInfo != nil { - Grid(alignment: .top, horizontalSpacing: 2) { + // VStack { + GeometryReader { bounds in + let smallest = min(bounds.size.width, bounds.size.height) + ScrollView { + if node != nil && node?.myInfo != nil { + Grid() { + GridRow { + Spacer() + Text("Include") + .font(.caption) + .fontWeight(.bold) + .padding(.trailing) + Text("Channel") + .font(.caption) + .fontWeight(.bold) + .padding(.trailing) + Text("Encrypted") + .font(.caption) + .fontWeight(.bold) + } + ForEach(node!.myInfo!.channels?.array as! [ChannelEntity], id: \.self) { (channel: ChannelEntity) in GridRow { Spacer() - Text("Include") - .font(.caption) - .fontWeight(.bold) - .padding(.trailing) - Text("Channel") - .font(.caption) - .fontWeight(.bold) - .padding(.trailing) - Text("Encrypted") - .font(.caption) - .fontWeight(.bold) - } - ForEach(node!.myInfo!.channels?.array as! [ChannelEntity], id: \.self) { (channel: ChannelEntity) in - GridRow { - Spacer() - if channel.index == 0 { - Toggle("Channel 0 Included", isOn: $includeChannel0) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text((channel.name!.isEmpty ? "Primary" : channel.name) ?? "Primary") - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") + if channel.index == 0 { + Toggle("Channel 0 Included", isOn: $includeChannel0) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Primary" : channel.name) ?? "Primary").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 1 && channel.role > 0 { + Toggle("Channel 1 Included", isOn: $includeChannel1) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 2 && channel.role > 0 { + Toggle("Channel 2 Included", isOn: $includeChannel2) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 3 && channel.role > 0 { + Toggle("Channel 3 Included", isOn: $includeChannel3) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 4 && channel.role > 0 { + Toggle("Channel 4 Included", isOn: $includeChannel4) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 5 && channel.role > 0 { + Toggle("Channel 5 Included", isOn: $includeChannel5) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 6 && channel.role > 0 { + Toggle("Channel 6 Included", isOn: $includeChannel6) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else if channel.index == 7 && channel.role > 0 { + Toggle("Channel 7 Included", isOn: $includeChannel7) + .toggleStyle(.switch) + .labelsHidden() + .disabled(channel.role == 1) + Text(((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)").camelCaseToWords()).fixedSize() + if channel.psk?.hexDescription.count ?? 0 < 3 { + Image(systemName: "lock.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") .foregroundColor(.green) - } - } else if channel.index == 1 && channel.role > 0 { - Toggle("Channel 1 Included", isOn: $includeChannel1) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)") - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 2 && channel.role > 0 { - Toggle("Channel 2 Included", isOn: $includeChannel2) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)") - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 3 && channel.role > 0 { - Toggle("Channel 3 Included", isOn: $includeChannel3) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)") - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 4 && channel.role > 0 { - Toggle("Channel 4 Included", isOn: $includeChannel4) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)") - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 5 && channel.role > 0 { - Toggle("Channel 5 Included", isOn: $includeChannel5) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)") - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 6 && channel.role > 0 { - Toggle("Channel 6 Included", isOn: $includeChannel6) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)") - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - } else if channel.index == 7 && channel.role > 0 { - Toggle("Channel 7 Included", isOn: $includeChannel7) - .toggleStyle(.switch) - .labelsHidden() - .disabled(channel.role == 1) - Text((channel.name!.isEmpty ? "Channel\(channel.index)" : channel.name) ?? "Channel\(channel.index)") - if channel.psk?.hexDescription.count ?? 0 < 3 { - Image(systemName: "lock.slash") - .foregroundColor(.red) - } else { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } } - Spacer() } - .padding(0) - } - } - - let qrImage = qrCodeImage.generateQRCode(from: channelsUrl) - VStack { - if node != nil { - ShareLink("Share QR Code & Link", - item: Image(uiImage: qrImage), - subject: Text("Meshtastic Node \(node?.user?.shortName ?? "????") has shared channels with you"), - message: Text(channelsUrl), - preview: SharePreview("Meshtastic Node \(node?.user?.shortName ?? "????") has shared channels with you", - image: Image(uiImage: qrImage)) - ) - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding(.bottom) - - Image(uiImage: qrImage) - .resizable() - .scaledToFit() - .frame( - minWidth: smallest * 0.95, - maxWidth: smallest * 0.95, - minHeight: smallest * 0.95, - maxHeight: smallest * 0.95, - alignment: .top - ) - Button { - isPresentingHelp = true - } label: { - Label("Help Me!", systemImage: "lifepreserver") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.small) + Spacer() } } } - + + let qrImage = qrCodeImage.generateQRCode(from: channelsUrl) + VStack { + if node != nil { + ShareLink("Share QR Code & Link", + item: Image(uiImage: qrImage), + subject: Text("Meshtastic Node \(node?.user?.shortName ?? "????") has shared channels with you"), + message: Text(channelsUrl), + preview: SharePreview("Meshtastic Node \(node?.user?.shortName ?? "????") has shared channels with you", + image: Image(uiImage: qrImage)) + ) + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) + + Image(uiImage: qrImage) + .resizable() + .scaledToFit() + .frame( + minWidth: smallest * 0.95, + maxWidth: smallest * 0.95, + minHeight: smallest * 0.95, + maxHeight: smallest * 0.95, + alignment: .top + ) + Button { + isPresentingHelp = true + } label: { + Label("Help Me!", systemImage: "lifepreserver") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.small) + } + } } - } - .sheet(isPresented: $isPresentingHelp) { - VStack { - Text("Meshtastic Channels").font(.title) - Text("A Meshtastic LoRa Mesh network can have up to 8 distinct channels.") - .font(.headline) - .padding(.bottom) - Text("Primary Channel").font(.title2) - Text("The first channel is the Primary channel and is where much of the mesh activity takes place. DM's are only available on the primary channel and it can not be disabled.") - .font(.callout) - .padding([.leading,.trailing,.bottom]) - Text("Admin Channel").font(.title2) - Text("A channel with the name 'admin' is the Admin channel and can be used to remotely administer nodes on your mesh, text messages can not be sent over the admin channel.") - .font(.callout) - .padding([.leading,.trailing,.bottom]) - Text("Private Channels").font(.title2) - Text("The other channels can be used for private group converations. Each of these groups has its own encryption key.") - .font(.callout) - .padding([.leading,.trailing,.bottom]) - Divider() - } - .padding() - .presentationDetents([.large]) - .presentationDragIndicator(.automatic) - } - .navigationTitle("Generate QR Code") - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(trailing: - ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "????") - }) - .onAppear { - bleManager.context = context - GenerateChannelSet() - } - .onChange(of: includeChannel1) { includeCh1 in GenerateChannelSet() } - .onChange(of: includeChannel2) { includeCh2 in GenerateChannelSet() } - .onChange(of: includeChannel3) { includeCh3 in GenerateChannelSet() } - .onChange(of: includeChannel4) { includeCh4 in GenerateChannelSet() } - .onChange(of: includeChannel5) { includeCh5 in GenerateChannelSet() } - .onChange(of: includeChannel6) { includeCh6 in GenerateChannelSet() } - .onChange(of: includeChannel7) { includeCh7 in GenerateChannelSet() } + + //} } + .sheet(isPresented: $isPresentingHelp) { + VStack { + Text("Meshtastic Channels").font(.title) + Text("A Meshtastic LoRa Mesh network can have up to 8 distinct channels.") + .font(.headline) + .padding(.bottom) + Text("Primary Channel").font(.title2) + Text("The first channel is the Primary channel and is where much of the mesh activity takes place. DM's are only available on the primary channel and it can not be disabled.") + .font(.callout) + .padding([.leading,.trailing,.bottom]) + Text("Admin Channel").font(.title2) + Text("A channel with the name 'admin' is the Admin channel and can be used to remotely administer nodes on your mesh, text messages can not be sent over the admin channel.") + .font(.callout) + .padding([.leading,.trailing,.bottom]) + Text("Private Channels").font(.title2) + Text("The other channels can be used for private group converations. Each of these groups has its own encryption key.") + .font(.callout) + .padding([.leading,.trailing,.bottom]) + Divider() + } + .padding() + .presentationDetents([.large]) + .presentationDragIndicator(.automatic) + } + .navigationTitle("Generate QR Code") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(trailing: + ZStack { + ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "????") + }) + .onAppear { + bleManager.context = context + GenerateChannelSet() + } + .onChange(of: includeChannel1) { includeCh1 in GenerateChannelSet() } + .onChange(of: includeChannel2) { includeCh2 in GenerateChannelSet() } + .onChange(of: includeChannel3) { includeCh3 in GenerateChannelSet() } + .onChange(of: includeChannel4) { includeCh4 in GenerateChannelSet() } + .onChange(of: includeChannel5) { includeCh5 in GenerateChannelSet() } + .onChange(of: includeChannel6) { includeCh6 in GenerateChannelSet() } + .onChange(of: includeChannel7) { includeCh7 in GenerateChannelSet() } } + // } } func GenerateChannelSet() { channelSet = ChannelSet()