mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge branch '2.7.6' into tcp-improvements
# Conflicts: # Localizable.xcstrings # Meshtastic/Accessory/Protocols/Device.swift # Meshtastic/Views/Connect/Connect.swift
This commit is contained in:
commit
9e44aeeb0e
24 changed files with 380 additions and 171 deletions
|
|
@ -2098,7 +2098,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.7.5;
|
||||
MARKETING_VERSION = 2.7.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -2133,7 +2133,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.7.5;
|
||||
MARKETING_VERSION = 2.7.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -2165,7 +2165,7 @@
|
|||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.7.5;
|
||||
MARKETING_VERSION = 2.7.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -2198,7 +2198,7 @@
|
|||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.7.5;
|
||||
MARKETING_VERSION = 2.7.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
|
|
@ -106,8 +106,11 @@ extension AccessoryManager {
|
|||
return
|
||||
}
|
||||
|
||||
// Check if we're in database retrieval mode to defer saves for performance
|
||||
let isRetrievingDatabase = if case .retrievingDatabase = self.state { true } else { false }
|
||||
|
||||
// TODO: nodeInfoPacket's channel: parameter is not used
|
||||
if let nodeInfo = nodeInfoPacket(nodeInfo: nodeInfo, channel: 0, context: context) {
|
||||
if let nodeInfo = nodeInfoPacket(nodeInfo: nodeInfo, channel: 0, context: context, deferSave: isRetrievingDatabase) {
|
||||
if let activeDevice = activeConnection?.device, activeDevice.num == nodeInfo.num {
|
||||
if let user = nodeInfo.user {
|
||||
updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName ?? "?")
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ extension AccessoryManager {
|
|||
}
|
||||
}
|
||||
// Set initial unread message badge states
|
||||
appState.unreadChannelMessages = fetchedNodeInfo[0].myInfo?.unreadMessages ?? 0
|
||||
appState.unreadDirectMessages = fetchedNodeInfo[0].user?.unreadMessages ?? 0
|
||||
appState.unreadChannelMessages = fetchedNodeInfo[0].myInfo?.unreadMessages(context: context) ?? 0
|
||||
appState.unreadDirectMessages = fetchedNodeInfo[0].user?.unreadMessages(context: context, skipLastMessageCheck: true) ?? 0 // skipLastMessageCheck=true because we don't update lastMessage on our own connected node
|
||||
}
|
||||
if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].rangeTestConfig?.enabled == true {
|
||||
wantRangeTestPackets = true
|
||||
|
|
|
|||
|
|
@ -665,6 +665,17 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
self.firstDatabaseNodeInfoContinuation = nil
|
||||
}
|
||||
|
||||
// Perform a single batch save after database retrieval completes
|
||||
// This significantly improves performance on reconnect
|
||||
do {
|
||||
try context.save()
|
||||
Logger.data.info("💾 [Database] Batch saved all node info after database retrieval")
|
||||
} catch {
|
||||
context.rollback()
|
||||
let nsError = error as NSError
|
||||
Logger.data.error("💥 [Database] Error saving batch node info: \(nsError, privacy: .public)")
|
||||
}
|
||||
|
||||
default:
|
||||
Logger.transport.error("[Accessory] Unknown nonce completed: \(configCompleteID)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,8 +78,24 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe
|
|||
// Save the resolved service locally for later
|
||||
services[service.name] = ResolvedService(id: idString, service: service, host: host, port: port)
|
||||
|
||||
let name: String
|
||||
if let txtRecords = service.txtRecordData().map({NetService.dictionary(fromTXTRecord: $0)}) {
|
||||
var nodeNameString = ""
|
||||
if let shortNameData = txtRecords["shortname"] {
|
||||
nodeNameString += String(decoding: shortNameData, as: UTF8.self)
|
||||
}
|
||||
if let nodeId = txtRecords["id"], nodeId.count > 4 {
|
||||
if nodeNameString.count > 0 {
|
||||
nodeNameString += "_"
|
||||
}
|
||||
nodeNameString += String(decoding: nodeId.suffix(4), as: UTF8.self)
|
||||
}
|
||||
name = nodeNameString
|
||||
} else {
|
||||
name = "\(service.name) (\(ip))"
|
||||
}
|
||||
let device = Device(id: idString,
|
||||
name: "\(service.name) (\(ip))",
|
||||
name: name,
|
||||
transportType: .tcp,
|
||||
identifier: "\(host):\(port)")
|
||||
continuation?.yield(.deviceFound(device))
|
||||
|
|
|
|||
|
|
@ -9,22 +9,45 @@ import CoreData
|
|||
import MeshtasticProtobufs
|
||||
|
||||
extension ChannelEntity {
|
||||
var messagePredicate: NSPredicate {
|
||||
return NSPredicate(format: "channel == %ld AND toUser == nil AND isEmoji == false", self.index)
|
||||
}
|
||||
|
||||
var messageFetchRequest: NSFetchRequest<MessageEntity> {
|
||||
let fetchRequest = MessageEntity.fetchRequest()
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
|
||||
fetchRequest.predicate = messagePredicate
|
||||
return fetchRequest
|
||||
}
|
||||
|
||||
var allPrivateMessages: [MessageEntity] {
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
let fetchRequest = MessageEntity.fetchRequest()
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
|
||||
fetchRequest.predicate = NSPredicate(format: "channel == %ld AND toUser == nil AND isEmoji == false", self.index)
|
||||
let fetchRequest = messageFetchRequest
|
||||
|
||||
return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
|
||||
}
|
||||
|
||||
var unreadMessages: Int {
|
||||
var mostRecentPrivateMessage: MessageEntity? {
|
||||
// Most recent channel message (descending, limit 1)
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
let fetchRequest = messageFetchRequest
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: false)]
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let unreadMessages = allPrivateMessages.filter { ($0 as AnyObject).read == false }
|
||||
return unreadMessages.count
|
||||
return (try? context.fetch(fetchRequest))?.first
|
||||
}
|
||||
|
||||
func unreadMessages(context: NSManagedObjectContext) -> Int {
|
||||
let fetchRequest = messageFetchRequest
|
||||
fetchRequest.sortDescriptors = [] // sort is irrelevant.
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])
|
||||
|
||||
return (try? context.count(for: fetchRequest)) ?? 0
|
||||
}
|
||||
|
||||
// Backwards-compatible property (uses viewContext)
|
||||
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) }
|
||||
|
||||
var protoBuf: Channel {
|
||||
var channel = Channel()
|
||||
channel.index = self.index
|
||||
|
|
|
|||
|
|
@ -6,23 +6,39 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
extension MyInfoEntity {
|
||||
var messagePredicate: NSPredicate {
|
||||
return NSPredicate(format: "toUser == nil AND isEmoji == false")
|
||||
}
|
||||
|
||||
var messageFetchRequest: NSFetchRequest<MessageEntity> {
|
||||
let fetchRequest = MessageEntity.fetchRequest()
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
|
||||
fetchRequest.predicate = messagePredicate
|
||||
return fetchRequest
|
||||
}
|
||||
|
||||
var messageList: [MessageEntity] {
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
let fetchRequest = MessageEntity.fetchRequest()
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
|
||||
fetchRequest.predicate = NSPredicate(format: "toUser == nil")
|
||||
let fetchRequest = messageFetchRequest
|
||||
|
||||
return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
|
||||
return (try? context.fetch(messageFetchRequest)) ?? [MessageEntity]()
|
||||
}
|
||||
|
||||
var unreadMessages: Int {
|
||||
let unreadMessages = messageList.filter { ($0 as AnyObject).read == false && ($0 as AnyObject).isEmoji == false }
|
||||
return unreadMessages.count
|
||||
func unreadMessages(context: NSManagedObjectContext) -> Int {
|
||||
// Returns the count of unread *channel* messages
|
||||
let fetchRequest = messageFetchRequest
|
||||
fetchRequest.sortDescriptors = [] // sort is irrelevant.
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])
|
||||
|
||||
return (try? context.count(for: fetchRequest)) ?? 0
|
||||
}
|
||||
|
||||
// Backwards-compatible property (uses viewContext)
|
||||
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) }
|
||||
|
||||
var hasAdmin: Bool {
|
||||
let adminChannel = channels?.filter { ($0 as AnyObject).name?.lowercased() == "admin" }
|
||||
return adminChannel?.count ?? 0 > 0
|
||||
|
|
|
|||
|
|
@ -96,10 +96,40 @@ extension PositionEntity {
|
|||
}
|
||||
return pointAnn
|
||||
}
|
||||
|
||||
var isPreciseLocation: Bool {
|
||||
precisionBits == 32 || precisionBits == 0
|
||||
}
|
||||
|
||||
var fuzzedNodeCoordinate: CLLocationCoordinate2D? {
|
||||
// With reduced precisionBits, many nodes can overlap on the map, making them unclickable.
|
||||
// Use a hash of the position ID to fuzz coordinate slightly so that these nodes can be distinguished at the higest zoom levels. This allows them to be clicked individually.
|
||||
if latitudeI != 0 && longitudeI != 0 {
|
||||
// Derive two uniform pseudorandom numbers [0,1) from id.hashValue
|
||||
let u1 = Double(id.hashValue & 0xFFFF) / 65536.0
|
||||
let u2 = Double((id.hashValue >> 16) & 0xFFFF) / 65536.0
|
||||
|
||||
// Angle and radius
|
||||
let offsetAngle = 2.0 * .pi * u1
|
||||
let offsetRadius = 0.00001 * sqrt(u2) // 1.0e-5 degrees at equator is about 1.11 m or 4 ft
|
||||
|
||||
let dLat = sin(offsetAngle) * offsetRadius
|
||||
let dLon = cos(offsetAngle) * offsetRadius
|
||||
|
||||
let coord = CLLocationCoordinate2D(
|
||||
latitude: latitude! + dLat,
|
||||
longitude: longitude! + dLon
|
||||
)
|
||||
return coord
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PositionEntity: MKAnnotation {
|
||||
public var coordinate: CLLocationCoordinate2D { nodeCoordinate ?? LocationsHandler.DefaultLocation }
|
||||
public var fuzzedCoordinate: CLLocationCoordinate2D { fuzzedNodeCoordinate ?? LocationsHandler.DefaultLocation }
|
||||
public var title: String? { nodePosition?.user?.shortName ?? "Unknown".localized }
|
||||
public var subtitle: String? { time?.formatted() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,37 @@ import CoreData
|
|||
import MeshtasticProtobufs
|
||||
|
||||
extension UserEntity {
|
||||
var messagePredicate: NSPredicate {
|
||||
return NSPredicate(format: "((toUser == %@) OR (fromUser == %@)) AND toUser != nil AND fromUser != nil AND isEmoji == false AND admin = false AND portNum != 10", self, self)
|
||||
}
|
||||
|
||||
var messageFetchRequest: NSFetchRequest<MessageEntity> {
|
||||
let fetchRequest = MessageEntity.fetchRequest()
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
|
||||
fetchRequest.predicate = messagePredicate
|
||||
return fetchRequest
|
||||
}
|
||||
|
||||
var messageList: [MessageEntity] {
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
let fetchRequest = MessageEntity.fetchRequest()
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
|
||||
fetchRequest.predicate = NSPredicate(format: "((toUser == %@) OR (fromUser == %@)) AND toUser != nil AND fromUser != nil AND isEmoji == false AND admin = false AND portNum != 10", self, self)
|
||||
let fetchRequest = messageFetchRequest
|
||||
|
||||
return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
|
||||
}
|
||||
|
||||
var mostRecentMessage: MessageEntity? {
|
||||
// Most contacts will have no DMs history, so we can return early.
|
||||
guard self.lastMessage != nil else { return nil; }
|
||||
|
||||
// Most recent DM for this user (descending, limit 1)
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
let fetchRequest = messageFetchRequest
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: false)]
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
return (try? context.fetch(fetchRequest))?.first
|
||||
}
|
||||
|
||||
var sensorMessageList: [MessageEntity] {
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
let fetchRequest = MessageEntity.fetchRequest()
|
||||
|
|
@ -29,10 +50,21 @@ extension UserEntity {
|
|||
return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
|
||||
}
|
||||
|
||||
var unreadMessages: Int {
|
||||
let unreadMessages = messageList.filter { ($0 as AnyObject).read == false && ($0 as AnyObject).isEmoji == false }
|
||||
return unreadMessages.count
|
||||
func unreadMessages(context: NSManagedObjectContext, skipLastMessageCheck: Bool = false) -> Int {
|
||||
// Most contacts will have no DMs history, so we can return early.
|
||||
// (For our own node, set skipLastMessageCheck=true, because we don't update lastMessage on our own connected node.)
|
||||
guard self.lastMessage != nil || skipLastMessageCheck else { return 0; }
|
||||
|
||||
let fetchRequest = messageFetchRequest
|
||||
fetchRequest.sortDescriptors = [] // sort is irrelevant.
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])
|
||||
|
||||
return (try? context.count(for: fetchRequest)) ?? 0
|
||||
}
|
||||
|
||||
// Backwards-compatible property (uses viewContext)
|
||||
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) }
|
||||
|
||||
/// SVG Images for Vendors who are signed project backers
|
||||
var hardwareImage: String? {
|
||||
guard let hwModel else { return nil }
|
||||
|
|
@ -130,6 +162,7 @@ public func createUser(num: Int64, context: NSManagedObjectContext) throws -> Us
|
|||
newUser.longName = "Meshtastic \(last4)"
|
||||
newUser.shortName = last4
|
||||
newUser.hwModel = "UNSET"
|
||||
newUser.unmessagable = false
|
||||
}
|
||||
|
||||
return newUser
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ func deviceMetadataPacket (metadata: DeviceMetadata, fromNum: Int64, sessionPass
|
|||
}
|
||||
}
|
||||
|
||||
func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObjectContext) -> NodeInfoEntity? {
|
||||
func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObjectContext, deferSave: Bool = false) -> NodeInfoEntity? {
|
||||
|
||||
let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, String(nodeInfo.num))
|
||||
Logger.mesh.info("📟 \(logString, privacy: .public)")
|
||||
|
|
@ -375,8 +375,10 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje
|
|||
newNode.myInfo = fetchedMyInfo[0]
|
||||
}
|
||||
do {
|
||||
try context.save()
|
||||
Logger.data.info("💾 Saved a new Node Info For: \(String(nodeInfo.num), privacy: .public)")
|
||||
if !deferSave {
|
||||
try context.save()
|
||||
Logger.data.info("💾 Saved a new Node Info For: \(String(nodeInfo.num), privacy: .public)")
|
||||
}
|
||||
return newNode
|
||||
} catch {
|
||||
context.rollback()
|
||||
|
|
@ -500,8 +502,10 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje
|
|||
fetchedNode[0].myInfo = fetchedMyInfo[0]
|
||||
}
|
||||
do {
|
||||
try context.save()
|
||||
Logger.data.info("💾 [NodeInfo] saved for \(nodeInfo.num.toHex(), privacy: .public)")
|
||||
if !deferSave {
|
||||
try context.save()
|
||||
Logger.data.info("💾 [NodeInfo] saved for \(nodeInfo.num.toHex(), privacy: .public)")
|
||||
}
|
||||
return fetchedNode[0]
|
||||
} catch {
|
||||
context.rollback()
|
||||
|
|
@ -1040,7 +1044,10 @@ func textMessageAppPacket(
|
|||
if newMessage.fromUser != nil && newMessage.toUser != nil {
|
||||
// Set Unread Message Indicators
|
||||
if packet.to == connectedNode {
|
||||
appState?.unreadDirectMessages = newMessage.toUser?.unreadMessages ?? 0
|
||||
let unreadCount = newMessage.toUser?.unreadMessages(context: context, skipLastMessageCheck: true) ?? 0 // skipLastMessageCheck=true because we don't update lastMessage on our own connected node
|
||||
Task { @MainActor in
|
||||
appState?.unreadDirectMessages = unreadCount
|
||||
}
|
||||
}
|
||||
if !(newMessage.fromUser?.mute ?? false) && newMessage.isEmoji == false {
|
||||
// Create an iOS Notification for the received DM message
|
||||
|
|
@ -1068,7 +1075,7 @@ func textMessageAppPacket(
|
|||
do {
|
||||
let fetchedMyInfo = try context.fetch(fetchMyInfoRequest)
|
||||
if !fetchedMyInfo.isEmpty {
|
||||
appState?.unreadChannelMessages = fetchedMyInfo[0].unreadMessages
|
||||
appState?.unreadChannelMessages = fetchedMyInfo[0].unreadMessages(context: context)
|
||||
for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] {
|
||||
if channel.index == newMessage.channel {
|
||||
context.refresh(channel, mergeChanges: true)
|
||||
|
|
|
|||
|
|
@ -19,10 +19,8 @@ struct MeshtasticAppleApp: App {
|
|||
private let persistenceController: PersistenceController
|
||||
private let accessoryManager: AccessoryManager
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State var saveChannels = false
|
||||
@State var saveChannelLink: SaveChannelLinkData?
|
||||
@State var incomingUrl: URL?
|
||||
@State var channelSettings: String?
|
||||
@State var addChannels = false
|
||||
|
||||
init() {
|
||||
|
||||
|
|
@ -36,7 +34,7 @@ struct MeshtasticAppleApp: App {
|
|||
let appID = "79fe92a9-74c9-4c8f-ba63-6308384ecfa9"
|
||||
let clientToken = "pub4427bea20dbdb08a6af68034de22cd3b"
|
||||
var environment = "AppStore"
|
||||
|
||||
|
||||
#if DEBUG
|
||||
environment = "Local"
|
||||
#else
|
||||
|
|
@ -44,7 +42,8 @@ struct MeshtasticAppleApp: App {
|
|||
environment = "TestFlight"
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
#if false
|
||||
Datadog.initialize(
|
||||
with: Datadog.Configuration(
|
||||
clientToken: clientToken,
|
||||
|
|
@ -81,6 +80,7 @@ struct MeshtasticAppleApp: App {
|
|||
)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
accessoryManager = AccessoryManager.shared
|
||||
accessoryManager.appState = appState
|
||||
|
||||
|
|
@ -110,20 +110,11 @@ struct MeshtasticAppleApp: App {
|
|||
appState: appState,
|
||||
router: appState.router
|
||||
)
|
||||
.sheet(isPresented: Binding(
|
||||
get: {
|
||||
saveChannels && !(channelSettings == nil)
|
||||
},
|
||||
set: { newValue in
|
||||
saveChannels = newValue
|
||||
if !newValue {
|
||||
channelSettings = nil
|
||||
}
|
||||
}
|
||||
)) {
|
||||
.sheet(item: $saveChannelLink
|
||||
) { link in
|
||||
SaveChannelQRCode(
|
||||
channelSetLink: channelSettings ?? "Empty Channel URL",
|
||||
addChannels: addChannels,
|
||||
channelSetLink: link.data,
|
||||
addChannels: link.add,
|
||||
accessoryManager: accessoryManager )
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
|
|
@ -131,54 +122,54 @@ struct MeshtasticAppleApp: App {
|
|||
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in
|
||||
Logger.mesh.debug("URL received \(userActivity, privacy: .public)")
|
||||
self.incomingUrl = userActivity.webpageURL
|
||||
self.saveChannels = false
|
||||
self.saveChannelLink = nil
|
||||
var addChannels = false
|
||||
if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/v/#") == true {
|
||||
ContactURLHandler.handleContactUrl(url: self.incomingUrl!, accessoryManager: accessoryManager)
|
||||
} else if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/e/") == true {
|
||||
if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") {
|
||||
self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false
|
||||
addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false
|
||||
if (self.incomingUrl?.absoluteString.lowercased().contains("?")) != nil {
|
||||
guard let cs = components.last!.components(separatedBy: "?").first else {
|
||||
return
|
||||
}
|
||||
self.channelSettings = cs
|
||||
self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels)
|
||||
} else {
|
||||
guard let cs = components.first else {
|
||||
return
|
||||
}
|
||||
self.channelSettings = cs
|
||||
self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels)
|
||||
}
|
||||
Logger.services.debug("Add Channel \(self.addChannels, privacy: .public)")
|
||||
Logger.services.debug("Add Channel \(addChannels, privacy: .public)")
|
||||
}
|
||||
self.saveChannels = true
|
||||
Logger.mesh.debug("User wants to open a Channel Settings URL: \(self.incomingUrl?.absoluteString ?? "No QR Code Link")")
|
||||
}
|
||||
if self.saveChannels {
|
||||
if self.saveChannelLink != nil {
|
||||
Logger.mesh.debug("User wants to open Channel Settings URL: \(String(describing: self.incomingUrl!.relativeString), privacy: .public)")
|
||||
}
|
||||
}
|
||||
.onOpenURL(perform: { (url) in
|
||||
Logger.mesh.debug("Some sort of URL was received \(url, privacy: .public)")
|
||||
self.incomingUrl = url
|
||||
var addChannels = false
|
||||
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
|
||||
ContactURLHandler.handleContactUrl(url: url, accessoryManager: accessoryManager)
|
||||
} else if url.absoluteString.lowercased().contains("meshtastic.org/e/") {
|
||||
if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") {
|
||||
self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false
|
||||
addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false
|
||||
if self.incomingUrl?.absoluteString.lowercased().contains("?") != nil {
|
||||
guard let cs = components.last!.components(separatedBy: "?").first else {
|
||||
return
|
||||
}
|
||||
self.channelSettings = cs
|
||||
self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels)
|
||||
} else {
|
||||
guard let cs = components.first else {
|
||||
return
|
||||
}
|
||||
self.channelSettings = cs
|
||||
self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels)
|
||||
}
|
||||
Logger.services.debug("Add Channel \(self.addChannels, privacy: .public)")
|
||||
Logger.services.debug("Add Channel \(addChannels, privacy: .public)")
|
||||
}
|
||||
self.saveChannels = true
|
||||
Logger.mesh.debug("User wants to open a Channel Settings URL: \(self.incomingUrl?.absoluteString ?? "No QR Code Link", privacy: .public)")
|
||||
} else if url.absoluteString.lowercased().contains("meshtastic:///") {
|
||||
appState.router.route(url: url)
|
||||
|
|
@ -221,7 +212,7 @@ struct MeshtasticAppleApp: App {
|
|||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(accessoryManager)
|
||||
.environmentObject(appState.router)
|
||||
.environmentObject(appState.router)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,10 +63,22 @@ struct Connect: View {
|
|||
}
|
||||
Text("Connection Name").font(.callout)+Text(": \(connectedDevice.name.addingVariationSelectors)")
|
||||
.font(.callout).foregroundColor(Color.gray)
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
TransportIcon(transportType: connectedDevice.transportType)
|
||||
HStack {
|
||||
if connectedDevice.transportType == .ble {
|
||||
connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) }
|
||||
// baseline aligned looks better for the signal meter
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
TransportIcon(transportType: connectedDevice.transportType)
|
||||
connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) }
|
||||
}
|
||||
} else if connectedDevice.transportType == .tcp {
|
||||
// Not baseline aligned looks better for the connection string
|
||||
HStack {
|
||||
TransportIcon(transportType: connectedDevice.transportType)
|
||||
Text("\(connectedDevice.connectionDetails ?? connectedDevice.identifier)")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
} else {
|
||||
TransportIcon(transportType: connectedDevice.transportType)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,14 +37,16 @@ struct ChannelList: View {
|
|||
let dateFormatString = (localeDateFormat ?? "MM/dd/YY")
|
||||
|
||||
NavigationLink(value: channel) {
|
||||
let mostRecent = channel.allPrivateMessages.last(where: { $0.channel == channel.index })
|
||||
let mostRecent = channel.mostRecentPrivateMessage
|
||||
let hasMessages = mostRecent != nil
|
||||
let hasUnreadMessages = hasMessages && (channel.unreadMessages > 0)
|
||||
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
|
||||
|
||||
ZStack {
|
||||
Image(systemName: "circle.fill")
|
||||
.opacity(channel.unreadMessages > 0 ? 1 : 0)
|
||||
.opacity(hasUnreadMessages ? 1 : 0)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.accentColor)
|
||||
.brightness(0.2)
|
||||
|
|
@ -70,7 +72,7 @@ struct ChannelList: View {
|
|||
|
||||
Spacer()
|
||||
|
||||
if channel.allPrivateMessages.count > 0 {
|
||||
if hasMessages {
|
||||
|
||||
if lastMessageDay == currentDay {
|
||||
Text(lastMessageTime, style: .time )
|
||||
|
|
@ -95,7 +97,7 @@ struct ChannelList: View {
|
|||
}
|
||||
}
|
||||
|
||||
if channel.allPrivateMessages.count > 0 {
|
||||
if hasMessages {
|
||||
HStack(alignment: .top) {
|
||||
Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")")
|
||||
// .font(.system(size: 16))
|
||||
|
|
@ -112,6 +114,7 @@ struct ChannelList: View {
|
|||
if let node, let myInfo = node.myInfo {
|
||||
List(selection: $channelSelection) {
|
||||
ForEach(channels) { (channel: ChannelEntity) in
|
||||
let hasMessages = channel.mostRecentPrivateMessage != nil
|
||||
if !restrictedChannels.contains(channel.name?.lowercased() ?? "") {
|
||||
makeChannelRow(myInfo: myInfo, channel: channel)
|
||||
.alignmentGuide(.listRowSeparatorLeading) {
|
||||
|
|
@ -119,7 +122,7 @@ struct ChannelList: View {
|
|||
}
|
||||
.frame(height: 62)
|
||||
.contextMenu {
|
||||
if channel.allPrivateMessages.count > 0 {
|
||||
if hasMessages {
|
||||
Button(role: .destructive) {
|
||||
isPresentingDeleteChannelMessagesConfirm = true
|
||||
channelToDeleteMessages = channel
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import SwiftUI
|
|||
struct ChannelMessageList: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var router: Router
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@FocusState var messageFieldFocused: Bool
|
||||
|
|
@ -58,14 +59,29 @@ struct ChannelMessageList: View {
|
|||
Logger.data.error("Failed to read messages: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func routerIsShowingThisChannel() -> Bool {
|
||||
guard router.navigationState.selectedTab == .messages else { return false }
|
||||
return scenePhase == .active
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Cast allPrivateMessages to an array for easier indexing and ForEach.
|
||||
let messages: [MessageEntity] = Array(allPrivateMessages)
|
||||
|
||||
// Precompute previous message
|
||||
let previousByID: [Int64: MessageEntity?] = {
|
||||
var dict = [Int64: MessageEntity?]()
|
||||
var prev: MessageEntity?
|
||||
for m in messages { dict[m.messageId] = prev; prev = m }
|
||||
return dict
|
||||
}()
|
||||
|
||||
ScrollViewReader { scrollView in
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
ForEach(allPrivateMessages.indices, id: \.self) { index in
|
||||
let message = allPrivateMessages[index]
|
||||
let previousMessage = index > 0 ? allPrivateMessages[index - 1] : nil
|
||||
ForEach(messages, id: \.messageId) { message in
|
||||
let previousMessage: MessageEntity? = previousByID[message.messageId] ?? nil
|
||||
|
||||
ChannelMessageRow(
|
||||
message: message,
|
||||
|
|
@ -92,7 +108,7 @@ struct ChannelMessageList: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.id(redrawTapbacksTrigger)
|
||||
|
||||
}
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
|
|
|
|||
|
|
@ -25,9 +25,7 @@ struct MessageText: View {
|
|||
let isCurrentUser: Bool
|
||||
let onReply: () -> Void
|
||||
// State for handling channel URL sheet
|
||||
@State private var saveChannels = false
|
||||
@State private var channelSettings: String?
|
||||
@State private var addChannels = false
|
||||
@State private var saveChannelLink: SaveChannelLinkData?
|
||||
@State private var isShowingDeleteConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -97,7 +95,8 @@ struct MessageText: View {
|
|||
)
|
||||
}
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
channelSettings = nil
|
||||
saveChannelLink = nil
|
||||
var addChannels = false
|
||||
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
|
||||
// Handle contact URL
|
||||
ContactURLHandler.handleContactUrl(url: url, accessoryManager: AccessoryManager.shared)
|
||||
|
|
@ -109,35 +108,25 @@ struct MessageText: View {
|
|||
Logger.services.error("No valid components found in channel URL: \(url.absoluteString, privacy: .public)")
|
||||
return .discarded
|
||||
}
|
||||
self.addChannels = Bool(url.query?.contains("add=true") ?? false)
|
||||
addChannels = Bool(url.query?.contains("add=true") ?? false)
|
||||
guard let lastComponent = components.last else {
|
||||
Logger.services.error("Channel URL missing fragment component: \(url.absoluteString, privacy: .public)")
|
||||
self.channelSettings = nil
|
||||
self.saveChannelLink = nil
|
||||
return .discarded
|
||||
}
|
||||
self.channelSettings = lastComponent.components(separatedBy: "?").first ?? ""
|
||||
Logger.services.debug("Add Channel: \(self.addChannels, privacy: .public)")
|
||||
self.saveChannels = true
|
||||
let cs = lastComponent.components(separatedBy: "?").first ?? ""
|
||||
self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels)
|
||||
Logger.services.debug("Add Channel: \(addChannels, privacy: .public)")
|
||||
Logger.mesh.debug("Opening Channel Settings URL: \(url.absoluteString, privacy: .public)")
|
||||
return .handled // Prevent default browser opening
|
||||
}
|
||||
return .systemAction // Open other URLs in browser
|
||||
})
|
||||
// Display sheet for channel settings
|
||||
.sheet(isPresented: Binding(
|
||||
get: {
|
||||
saveChannels && !(channelSettings == nil)
|
||||
},
|
||||
set: { newValue in
|
||||
saveChannels = newValue
|
||||
if !newValue {
|
||||
channelSettings = nil
|
||||
}
|
||||
}
|
||||
)) {
|
||||
.sheet(item: $saveChannelLink) { link in
|
||||
SaveChannelQRCode(
|
||||
channelSetLink: channelSettings ?? "Empty Channel URL",
|
||||
addChannels: addChannels,
|
||||
channelSetLink: link.data,
|
||||
addChannels: link.add,
|
||||
accessoryManager: accessoryManager
|
||||
)
|
||||
.presentationDetents([.large])
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import OSLog
|
|||
import TipKit
|
||||
|
||||
struct UserList: View {
|
||||
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@State private var editingFilters = false
|
||||
|
|
@ -20,7 +20,7 @@ struct UserList: View {
|
|||
@StateObject private var filters: NodeFilterParameters = NodeFilterParameters()
|
||||
@Binding var node: NodeInfoEntity?
|
||||
@Binding var userSelection: UserEntity?
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
FilteredUserList(withFilters: filters, node: $node, userSelection: $userSelection)
|
||||
|
|
@ -92,13 +92,15 @@ fileprivate struct FilteredUserList: View {
|
|||
self._node = node
|
||||
self._userSelection = userSelection
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current)
|
||||
let dateFormatString = (localeDateFormat ?? "MM/dd/YY")
|
||||
|
||||
List(users, selection: $userSelection) { user in
|
||||
let mostRecent = user.messageList.last
|
||||
let mostRecent = user.mostRecentMessage
|
||||
let hasMessages = mostRecent != nil
|
||||
let hasUnreadMessages = user.unreadMessages > 0
|
||||
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
|
||||
|
|
@ -106,14 +108,14 @@ fileprivate struct FilteredUserList: View {
|
|||
NavigationLink(value: user) {
|
||||
ZStack {
|
||||
Image(systemName: "circle.fill")
|
||||
.opacity(user.unreadMessages > 0 ? 1 : 0)
|
||||
.opacity(hasUnreadMessages ? 1 : 0)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.accentColor)
|
||||
.brightness(0.2)
|
||||
}
|
||||
|
||||
|
||||
CircleText(text: user.shortName ?? "?", color: Color(UIColor(hex: UInt32(user.num))))
|
||||
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
if user.pkiEncrypted {
|
||||
|
|
@ -137,7 +139,7 @@ fileprivate struct FilteredUserList: View {
|
|||
Image(systemName: "star.fill")
|
||||
.foregroundColor(.yellow)
|
||||
}
|
||||
if user.messageList.count > 0 {
|
||||
if hasMessages {
|
||||
if lastMessageDay == currentDay {
|
||||
Text(lastMessageTime, style: .time )
|
||||
.font(.footnote)
|
||||
|
|
@ -157,8 +159,8 @@ fileprivate struct FilteredUserList: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user.messageList.count > 0 {
|
||||
|
||||
if hasMessages {
|
||||
HStack(alignment: .top) {
|
||||
Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")")
|
||||
.font(.footnote)
|
||||
|
|
@ -207,7 +209,7 @@ fileprivate struct FilteredUserList: View {
|
|||
} label: {
|
||||
Label(user.mute ? "Show Alerts" : "Hide Alerts", systemImage: user.mute ? "bell" : "bell.slash")
|
||||
}
|
||||
if user.messageList.count > 0 {
|
||||
if hasMessages {
|
||||
Button(role: .destructive) {
|
||||
isPresentingDeleteUserMessagesConfirm = true
|
||||
userToDeleteMessages = user
|
||||
|
|
@ -316,7 +318,7 @@ fileprivate extension NodeFilterParameters {
|
|||
predicates.append(isIgnoredPredicate)
|
||||
let isConnectedNodePredicate = NSPredicate(format: "NOT (numString CONTAINS %@)", String(UserDefaults.preferredPeripheralNum))
|
||||
predicates.append(isConnectedNodePredicate)
|
||||
|
||||
|
||||
// Combine all predicates
|
||||
let finalPredicate = predicates.isEmpty ? NSPredicate(value: true) : NSCompoundPredicate(type: .and, subpredicates: predicates)
|
||||
return finalPredicate
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import OSLog
|
|||
import MeshtasticProtobufs // Added to ensure RoutingError is accessible if needed
|
||||
|
||||
struct UserMessageList: View {
|
||||
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var router: Router
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@FocusState var messageFieldFocused: Bool
|
||||
@ObservedObject var user: UserEntity
|
||||
|
|
@ -21,17 +22,21 @@ struct UserMessageList: View {
|
|||
@State private var messageToHighlight: Int64 = 0
|
||||
@State private var redrawTapbacksTrigger = UUID()
|
||||
@AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1
|
||||
|
||||
private var allPrivateMessages: [MessageEntity] {
|
||||
// Cast user.messageList to an array for easier indexing and ForEach.
|
||||
return user.messageList.compactMap { $0 as MessageEntity }
|
||||
@FetchRequest private var allPrivateMessages: FetchedResults<MessageEntity>
|
||||
|
||||
init(user: UserEntity) {
|
||||
self.user = user
|
||||
|
||||
// Configure fetch request here
|
||||
let request: NSFetchRequest<MessageEntity> = user.messageFetchRequest
|
||||
_allPrivateMessages = FetchRequest(fetchRequest: request)
|
||||
}
|
||||
|
||||
|
||||
func handleInteractionComplete() {
|
||||
markMessagesAsRead()
|
||||
redrawTapbacksTrigger = UUID()
|
||||
}
|
||||
|
||||
|
||||
func markMessagesAsRead() {
|
||||
do {
|
||||
for unreadMessage in allPrivateMessages.filter({ !$0.read }) {
|
||||
|
|
@ -39,25 +44,46 @@ struct UserMessageList: View {
|
|||
}
|
||||
try context.save()
|
||||
Logger.data.info("📖 [App] All unread direct messages marked as read for user \(user.num, privacy: .public).")
|
||||
appState.unreadDirectMessages = user.unreadMessages
|
||||
|
||||
if let connectedPeripheralNum = accessoryManager.activeDeviceNum,
|
||||
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: context),
|
||||
let connectedUser = connectedNode.user {
|
||||
appState.unreadDirectMessages = connectedUser.unreadMessages(context: context, skipLastMessageCheck: true) // skipLastMessageCheck=true because we don't update lastMessage on our own connected node
|
||||
}
|
||||
|
||||
context.refresh(user, mergeChanges: true)
|
||||
} catch {
|
||||
Logger.data.error("Failed to read direct messages: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func routerIsShowingThisUser() -> Bool {
|
||||
guard router.navigationState.selectedTab == .messages else { return false }
|
||||
return scenePhase == .active
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Cast user.messageList to an array for easier indexing and ForEach.
|
||||
let messages: [MessageEntity] = Array(allPrivateMessages)
|
||||
|
||||
// Precompute previous message
|
||||
let previousByID: [Int64: MessageEntity?] = {
|
||||
var dict = [Int64: MessageEntity?]()
|
||||
var prev: MessageEntity?
|
||||
for m in messages { dict[m.messageId] = prev; prev = m }
|
||||
return dict
|
||||
}()
|
||||
|
||||
VStack {
|
||||
ScrollViewReader { scrollView in
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
ForEach(allPrivateMessages.indices, id: \.self) { index in
|
||||
let message = allPrivateMessages[index]
|
||||
let previousMessage = index > 0 ? allPrivateMessages[index - 1] : nil
|
||||
ForEach(messages, id: \.messageId) { message in
|
||||
let previousMessage: MessageEntity? = previousByID[message.messageId] ?? nil
|
||||
|
||||
UserMessageRow(
|
||||
message: message,
|
||||
allMessages: allPrivateMessages,
|
||||
allMessages: messages,
|
||||
previousMessage: previousMessage,
|
||||
preferredPeripheralNum: preferredPeripheralNum,
|
||||
user: user,
|
||||
|
|
@ -80,7 +106,7 @@ struct UserMessageList: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.id(redrawTapbacksTrigger)
|
||||
|
||||
}
|
||||
// Invisible spacer to detect reaching bottom
|
||||
Color.clear
|
||||
|
|
|
|||
|
|
@ -47,26 +47,46 @@ struct MeshMapContent: MapContent {
|
|||
@MapContentBuilder
|
||||
var positionAnnotations: some MapContent {
|
||||
ForEach(positions, id: \.id) { position in
|
||||
/// Apply favorites filter and don't show ignored nodes
|
||||
if (!showFavorites || (position.nodePosition?.favorite == true)) && !(position.nodePosition?.ignored == true) {
|
||||
|
||||
let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0))
|
||||
let positionName = position.nodePosition?.user?.longName ?? "?"
|
||||
// Use a hash of the position ID to stagger animation delays for each node, preventing synchronized animations and improving visual distinction.
|
||||
let calculatedDelay = Double(position.id.hashValue % 100) / 100.0 * 0.5
|
||||
|
||||
Annotation(positionName, coordinate: position.coordinate) {
|
||||
LazyVStack {
|
||||
AnimatedNodePin(
|
||||
nodeColor: nodeColor,
|
||||
shortName: position.nodePosition?.user?.shortName,
|
||||
hasDetectionSensorMetrics: position.nodePosition?.hasDetectionSensorMetrics ?? false,
|
||||
isOnline: position.nodePosition?.isOnline ?? false,
|
||||
calculatedDelay: calculatedDelay
|
||||
)
|
||||
let coordinateForNodePin: CLLocationCoordinate2D = if position.isPreciseLocation {
|
||||
// Precise location: place node pin at actual location.
|
||||
position.coordinate
|
||||
} else {
|
||||
// Imprecise location: fuzz slightly so overlapping nodes are visible and clickable at highest zoom levels.
|
||||
position.fuzzedCoordinate
|
||||
}
|
||||
if 12...15 ~= position.precisionBits || position.precisionBits == 32 {
|
||||
|
||||
let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0))
|
||||
let positionName = position.nodePosition?.user?.longName ?? "?"
|
||||
/// Reduced Precision Map Circle
|
||||
if 12...15 ~= position.precisionBits {
|
||||
let pp = PositionPrecision(rawValue: Int(position.precisionBits))
|
||||
let radius: CLLocationDistance = pp?.precisionMeters ?? 0
|
||||
if radius > 0.0 {
|
||||
MapCircle(center: position.coordinate, radius: radius)
|
||||
.foregroundStyle(Color(nodeColor).opacity(0.25))
|
||||
.stroke(.white, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
// Use a hash of the position ID to stagger animation delays for each node, preventing synchronized animations and improving visual distinction.
|
||||
let calculatedDelay = Double(position.id.hashValue % 100) / 100.0 * 0.5
|
||||
|
||||
Annotation(positionName, coordinate: coordinateForNodePin) {
|
||||
LazyVStack {
|
||||
AnimatedNodePin(
|
||||
nodeColor: nodeColor,
|
||||
shortName: position.nodePosition?.user?.shortName,
|
||||
hasDetectionSensorMetrics: position.nodePosition?.hasDetectionSensorMetrics ?? false,
|
||||
isOnline: position.nodePosition?.isOnline ?? false,
|
||||
calculatedDelay: calculatedDelay
|
||||
)
|
||||
}
|
||||
.highPriorityGesture(TapGesture().onEnded { _ in
|
||||
selectedPosition = (selectedPosition == position ? nil : position)
|
||||
})
|
||||
}
|
||||
.highPriorityGesture(TapGesture().onEnded { _ in
|
||||
selectedPosition = (selectedPosition == position ? nil : position)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,15 +84,12 @@ struct MapSettingsForm: View {
|
|||
Label("Node History", systemImage: "building.columns.fill")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
.onTapGesture {
|
||||
self.nodeHistory.toggle()
|
||||
UserDefaults.enableMapNodeHistoryPins = self.nodeHistory
|
||||
Toggle(isOn: $enableMapRouteLines) {
|
||||
Label("Route Lines", systemImage: "road.lanes")
|
||||
}
|
||||
.tint(.accentColor)
|
||||
|
||||
}
|
||||
Toggle(isOn: $enableMapRouteLines) {
|
||||
Label("Route Lines", systemImage: "road.lanes")
|
||||
}
|
||||
.tint(.accentColor)
|
||||
Toggle(isOn: $convexHull) {
|
||||
Label("Convex Hull", systemImage: "button.angledbottom.horizontal.right")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,14 @@ import MeshtasticProtobufs
|
|||
import OSLog
|
||||
|
||||
struct ShareContactQRDialog: View {
|
||||
let manuallyVerified = false
|
||||
let node: NodeInfo
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var qrString: String {
|
||||
var contact = SharedContact()
|
||||
contact.nodeNum = node.num
|
||||
contact.user = node.user
|
||||
contact.manuallyVerified = manuallyVerified
|
||||
do {
|
||||
let contactString = try contact.serializedData().base64EncodedString()
|
||||
return ("https://meshtastic.org/v/#" + contactString.base64ToBase64url())
|
||||
|
|
|
|||
|
|
@ -24,14 +24,14 @@ struct NodeList: View {
|
|||
@StateObject var filters = NodeFilterParameters()
|
||||
@State var isEditingFilters = false
|
||||
@SceneStorage("selectedDetailView") var selectedDetailView: String?
|
||||
|
||||
|
||||
var connectedNode: NodeInfoEntity? {
|
||||
if let num = accessoryManager.activeDeviceNum {
|
||||
return getNodeInfo(id: num, context: context)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
FilteredNodeList(
|
||||
|
|
@ -137,7 +137,7 @@ struct NodeList: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Helper to get the count of nodes for the navigation title
|
||||
private func getNodeCount() -> Int {
|
||||
let request: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
|
||||
|
|
@ -154,7 +154,7 @@ fileprivate struct FilteredNodeList: View {
|
|||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@FetchRequest private var nodes: FetchedResults<NodeInfoEntity>
|
||||
@Environment(\.managedObjectContext) var context
|
||||
|
||||
|
||||
@Binding var selectedNode: NodeInfoEntity?
|
||||
var connectedNode: NodeInfoEntity?
|
||||
@Binding var isPresentingDeleteNodeAlert: Bool
|
||||
|
|
@ -179,17 +179,19 @@ fileprivate struct FilteredNodeList: View {
|
|||
]
|
||||
request.predicate = withFilters.buildPredicate()
|
||||
self._nodes = FetchRequest(fetchRequest: request)
|
||||
|
||||
|
||||
self._selectedNode = selectedNode
|
||||
self.connectedNode = connectedNode
|
||||
self._isPresentingDeleteNodeAlert = isPresentingDeleteNodeAlert
|
||||
self._deleteNodeId = deleteNodeId
|
||||
self._shareContactNode = shareContactNode
|
||||
}
|
||||
|
||||
|
||||
// The body of the view
|
||||
var body: some View {
|
||||
List(nodes, id: \.self, selection: $selectedNode) { node in
|
||||
// If the connected node passes filters, always show it first
|
||||
let nodesWithConnectedFirst = nodes.filter { $0.num == accessoryManager.activeDeviceNum } + nodes.filter { $0.num != accessoryManager.activeDeviceNum }
|
||||
List(nodesWithConnectedFirst, id: \.self, selection: $selectedNode) { node in
|
||||
NavigationLink(value: node) {
|
||||
NodeListItem(
|
||||
node: node,
|
||||
|
|
@ -205,7 +207,7 @@ fileprivate struct FilteredNodeList: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
func contextMenuActions(
|
||||
node: NodeInfoEntity,
|
||||
|
|
@ -276,7 +278,7 @@ fileprivate struct FilteredNodeList: View {
|
|||
fileprivate extension NodeFilterParameters {
|
||||
func buildPredicate() -> NSPredicate? {
|
||||
var predicates: [NSPredicate] = []
|
||||
|
||||
|
||||
// Search text predicates
|
||||
if !searchText.isEmpty {
|
||||
let searchKeys = [
|
||||
|
|
@ -288,19 +290,19 @@ fileprivate extension NodeFilterParameters {
|
|||
}
|
||||
predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: textPredicates))
|
||||
}
|
||||
|
||||
|
||||
// Favorite filter
|
||||
if isFavorite {
|
||||
predicates.append(NSPredicate(format: "favorite == YES"))
|
||||
}
|
||||
|
||||
|
||||
// Via Lora/MQTT filters
|
||||
if viaLora && !viaMqtt {
|
||||
predicates.append(NSPredicate(format: "viaMqtt == NO"))
|
||||
} else if !viaLora && viaMqtt {
|
||||
predicates.append(NSPredicate(format: "viaMqtt == YES"))
|
||||
}
|
||||
|
||||
|
||||
// Role filter
|
||||
if roleFilter && !deviceRoles.isEmpty {
|
||||
let rolesPredicates = deviceRoles.map {
|
||||
|
|
@ -308,41 +310,41 @@ fileprivate extension NodeFilterParameters {
|
|||
}
|
||||
predicates.append(NSCompoundPredicate(type: .or, subpredicates: rolesPredicates))
|
||||
}
|
||||
|
||||
|
||||
// Hops Away filter
|
||||
if hopsAway == 0.0 {
|
||||
predicates.append(NSPredicate(format: "hopsAway == %i", 0))
|
||||
} else if hopsAway > 0.0 {
|
||||
predicates.append(NSPredicate(format: "hopsAway > 0 AND hopsAway <= %i", Int32(hopsAway)))
|
||||
}
|
||||
|
||||
|
||||
// Online filter
|
||||
if isOnline {
|
||||
let isOnlinePredicate = NSPredicate(format: "lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -120, to: Date())! as NSDate)
|
||||
predicates.append(isOnlinePredicate)
|
||||
}
|
||||
|
||||
|
||||
// Encrypted filter
|
||||
if isPkiEncrypted {
|
||||
predicates.append(NSPredicate(format: "user.pkiEncrypted == YES"))
|
||||
}
|
||||
|
||||
|
||||
// Ignored filter
|
||||
if isIgnored {
|
||||
predicates.append(NSPredicate(format: "ignored == YES"))
|
||||
} else {
|
||||
predicates.append(NSPredicate(format: "ignored == NO"))
|
||||
}
|
||||
|
||||
|
||||
// Environment filter
|
||||
if isEnvironment {
|
||||
predicates.append(NSPredicate(format: "SUBQUERY(telemetries, $tel, $tel.metricsType == 1).@count > 0"))
|
||||
}
|
||||
|
||||
|
||||
// Distance filter
|
||||
if distanceFilter {
|
||||
if let pointOfInterest = LocationsHandler.currentLocation {
|
||||
|
||||
|
||||
if pointOfInterest.latitude != LocationsHandler.DefaultLocation.latitude && pointOfInterest.longitude != LocationsHandler.DefaultLocation.longitude {
|
||||
let d: Double = maxDistance * 1.1
|
||||
let r: Double = 6371009
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ struct DeviceOnboarding: View {
|
|||
makeRow(
|
||||
icon: "person.2.shield",
|
||||
title: String(localized: "User Privacy"),
|
||||
subtitle: String(localized: "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. This helps us understand how the app is being used and where we can make improvements. The data we collect is non-personally identifiable and cannot be linked to you as an individual. You can opt out of this under app settings.")
|
||||
subtitle: String(localized: "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings.")
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ struct AppSettings: View {
|
|||
}
|
||||
.tint(.accentColor)
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
|
||||
#else
|
||||
Button {
|
||||
isPresentingAppIconSheet.toggle()
|
||||
} label: {
|
||||
|
|
@ -65,6 +68,7 @@ struct AppSettings: View {
|
|||
AppIconPicker(isPresenting: self.$isPresentingAppIconSheet)
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Section(header: Text("environment")) {
|
||||
VStack(alignment: .leading) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ import CoreData
|
|||
import OSLog
|
||||
import MeshtasticProtobufs
|
||||
|
||||
struct SaveChannelLinkData: Identifiable {
|
||||
let id = UUID()
|
||||
let data: String
|
||||
let add: Bool
|
||||
}
|
||||
|
||||
struct SaveChannelQRCode: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.managedObjectContext) var context
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue