From f12cf9978f84bf69664e8a5847acbc7a7db5596a Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Fri, 17 Apr 2026 19:44:00 -0700 Subject: [PATCH] Send messages from carplay --- .../CarPlay/CarPlayIntentDonation.swift | 3 +- Meshtastic/CarPlay/CarPlaySceneDelegate.swift | 347 +++++++++++++----- Meshtastic/Info.plist | 6 + .../Intents/IntentMessageConverters.swift | 77 +++- .../SearchForMessagesIntentHandler.swift | 30 +- .../Intents/SendMessageIntentHandler.swift | 53 ++- Meshtastic/Views/Connect/Connect.swift | 2 +- MeshtasticTests/CarPlayTests.swift | 3 +- Widgets/MeshActivityAttributes.swift | 1 + Widgets/WidgetsLiveActivity.swift | 330 ++++++++++------- 10 files changed, 602 insertions(+), 250 deletions(-) diff --git a/Meshtastic/CarPlay/CarPlayIntentDonation.swift b/Meshtastic/CarPlay/CarPlayIntentDonation.swift index c7e84d19..fa7aef40 100644 --- a/Meshtastic/CarPlay/CarPlayIntentDonation.swift +++ b/Meshtastic/CarPlay/CarPlayIntentDonation.swift @@ -74,7 +74,8 @@ enum CarPlayIntentDonation { let intent: INSendMessageIntent if toUserNum != 0 { - let recipientHandle = INPersonHandle(value: String(toUserNum), type: .unknown) + let handleValue = "\(toUserNum)@meshtastic.local" + let recipientHandle = INPersonHandle(value: handleValue, type: .emailAddress) let recipient = INPerson( personHandle: recipientHandle, nameComponents: nil, diff --git a/Meshtastic/CarPlay/CarPlaySceneDelegate.swift b/Meshtastic/CarPlay/CarPlaySceneDelegate.swift index e7c376e3..5bce8926 100644 --- a/Meshtastic/CarPlay/CarPlaySceneDelegate.swift +++ b/Meshtastic/CarPlay/CarPlaySceneDelegate.swift @@ -5,11 +5,8 @@ // Copyright(c) Garth Vander Houwen 4/16/26. // // CarPlay Communication app scene delegate. -// For communication apps, the system provides the messaging UI. -// This delegate manages the CarPlay scene lifecycle and shows -// favorite contacts and channels for quick messaging via Siri. -// Tapping a favorite pushes a CPContactTemplate detail view -// with a native message button that launches Siri compose. +// Uses a tab bar with Channels and Direct Messages tabs, +// matching the main app's Messages navigation structure. // import CarPlay @@ -17,6 +14,9 @@ import Combine import CoreData import Intents import OSLog +#if canImport(ActivityKit) +import ActivityKit +#endif class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPInterfaceControllerDelegate { @@ -26,6 +26,15 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI PersistenceController.shared.container.viewContext } + private func lastHeardText(_ date: Date?) -> String { + guard let date else { return "Never heard" } + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "Just now" } + if interval < 3600 { return "\(Int(interval / 60))m ago" } + if interval < 86400 { return "\(Int(interval / 3600))h ago" } + return "\(Int(interval / 86400))d ago" + } + // MARK: - CPTemplateApplicationSceneDelegate func templateApplicationScene( @@ -41,11 +50,21 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI // Observe connection state changes and refresh the template AccessoryManager.shared.$isConnected + .removeDuplicates() + .dropFirst() // Skip initial value โ€” we already set the root template above .receive(on: DispatchQueue.main) - .sink { [weak self] _ in + .sink { [weak self] isConnected in self?.refreshRootTemplate() + if isConnected { + self?.startLiveActivityIfNeeded() + } } .store(in: &cancellables) + + // Start Live Activity immediately if already connected + if AccessoryManager.shared.isConnected { + startLiveActivityIfNeeded() + } } func templateApplicationScene( @@ -53,6 +72,7 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI didDisconnectInterfaceController interfaceController: CPInterfaceController ) { Logger.services.info("๐Ÿš— [CarPlay] Disconnected") + endLiveActivity() cancellables.removeAll() self.interfaceController = nil } @@ -75,69 +95,116 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI private func buildRootTemplate() -> CPTemplate { let connected = AccessoryManager.shared.isConnected + // Channels tab + let channelsTab = buildChannelsTab(connected: connected) + + // Direct Messages tab + let directMessagesTab = buildDirectMessagesTab(connected: connected) + + let tabBar = CPTabBarTemplate(templates: [channelsTab, directMessagesTab]) + return tabBar + } + + // MARK: - Channels Tab + + private func buildChannelsTab(connected: Bool) -> CPListTemplate { var sections = [CPListSection]() - // Status section - let statusItem = CPListItem( - text: connected ? "Connected" : "Not Connected", - detailText: connected - ? (AccessoryManager.shared.activeConnection?.device.name ?? "Unknown Device") - : "Open Meshtastic on your phone to connect", - image: UIImage(systemName: connected - ? "antenna.radiowaves.left.and.right" - : "antenna.radiowaves.left.and.right.slash") - ) - statusItem.isEnabled = false - sections.append(CPListSection(items: [statusItem], header: "Status", sectionIndexTitle: nil)) + if connected { + let channelItems = fetchChannelItems() + if !channelItems.isEmpty { + sections.append(CPListSection(items: channelItems)) + } else { + let emptyItem = CPListItem(text: "No Channels", detailText: nil) + emptyItem.isEnabled = false + sections.append(CPListSection(items: [emptyItem])) + } + } else { + let statusItem = CPListItem( + text: "Not Connected", + detailText: "Open Meshtastic to connect", + image: UIImage(systemName: "antenna.radiowaves.left.and.right.slash") + ) + statusItem.isEnabled = false + sections.append(CPListSection(items: [statusItem])) + } + + let template = CPListTemplate(title: "Channels", sections: sections) + template.tabImage = UIImage(systemName: "bubble.left.and.bubble.right") + return template + } + + // MARK: - Direct Messages Tab + + private func buildDirectMessagesTab(connected: Bool) -> CPListTemplate { + var sections = [CPListSection]() if connected { - // Favorite contacts section let favoriteItems = fetchFavoriteContactItems() if !favoriteItems.isEmpty { sections.append(CPListSection(items: favoriteItems, header: "Favorites", sectionIndexTitle: nil)) } - // Channels section - let channelItems = fetchChannelItems() - if !channelItems.isEmpty { - sections.append(CPListSection(items: channelItems, header: "Channels", sectionIndexTitle: nil)) + let dmItems = fetchDirectMessageItems() + if !dmItems.isEmpty { + sections.append(CPListSection(items: dmItems, header: "Recent", sectionIndexTitle: nil)) } + + if favoriteItems.isEmpty && dmItems.isEmpty { + let emptyItem = CPListItem(text: "No Messages", detailText: "No direct message history") + emptyItem.isEnabled = false + sections.append(CPListSection(items: [emptyItem])) + } + } else { + let statusItem = CPListItem( + text: "Not Connected", + detailText: "Open Meshtastic to connect", + image: UIImage(systemName: "antenna.radiowaves.left.and.right.slash") + ) + statusItem.isEnabled = false + sections.append(CPListSection(items: [statusItem])) } - let listTemplate = CPListTemplate(title: "Meshtastic", sections: sections) - listTemplate.tabImage = UIImage(systemName: "antenna.radiowaves.left.and.right") - return listTemplate + let template = CPListTemplate(title: "Direct Messages", sections: sections) + template.tabImage = UIImage(systemName: "bubble.left.and.text.bubble.right") + return template } // MARK: - Data Fetching - private func fetchFavoriteContactItems() -> [CPListItem] { + private func fetchFavoriteContactItems() -> [CPMessageListItem] { let request: NSFetchRequest = NodeInfoEntity.fetchRequest() request.predicate = NSPredicate(format: "favorite == YES AND num != %lld", AccessoryManager.shared.activeDeviceNum ?? 0) - request.sortDescriptors = [ - NSSortDescriptor(key: "user.longName", ascending: true) - ] + request.sortDescriptors = [NSSortDescriptor(key: "lastHeard", ascending: false)] request.relationshipKeyPathsForPrefetching = ["user"] do { let nodes = try context.fetch(request) - return nodes.compactMap { node -> CPListItem? in + return nodes.compactMap { node -> CPMessageListItem? in guard let user = node.user else { return nil } let name = user.longName ?? user.shortName ?? "Unknown" - let shortName = user.shortName ?? "?" let unreadCount = user.unreadMessages(context: context) + let hasUnread = unreadCount > 0 - let detailText = unreadCount > 0 ? "\(shortName) ยท \(unreadCount) unread" : shortName - let item = CPListItem( - text: name, - detailText: detailText, - image: UIImage(systemName: "person.circle.fill") + let leadingConfig = CPMessageListItemLeadingConfiguration( + leadingItem: .star, + leadingImage: UIImage(systemName: "person.circle.fill"), + unread: hasUnread ) - item.handler = { [weak self] _, completion in - self?.pushContactTemplate(node: node) - completion() - } - item.isEnabled = true + + let item = CPMessageListItem( + fullName: name, + phoneOrEmailAddress: "\(node.num)@meshtastic.local", + leadingConfiguration: leadingConfig, + trailingConfiguration: nil, + detailText: hasUnread ? "\(unreadCount) unread" : nil, + trailingText: lastHeardText(node.lastHeard) + ) + item.conversationIdentifier = "dm-\(node.num)" + item.userInfo = node.num + + donateMessageIntent(toNodeNum: node.num, name: name) + return item } } catch { @@ -146,7 +213,7 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI } } - private func fetchChannelItems() -> [CPListItem] { + private func fetchChannelItems() -> [CPMessageListItem] { guard let connectedNum = AccessoryManager.shared.activeDeviceNum, let connectedNode = getNodeInfo(id: connectedNum, context: context), let myInfo = connectedNode.myInfo, @@ -154,71 +221,105 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI return [] } - return channels.compactMap { channel -> CPListItem? in + return channels.compactMap { channel -> CPMessageListItem? in guard channel.role > 0 else { return nil } let name = (channel.name?.isEmpty ?? true) ? (channel.index == 0 ? "Primary Channel" : "Channel \(channel.index)") : channel.name! let unreadCount = channel.unreadMessages(context: context) + let hasUnread = unreadCount > 0 + let channelIndex = Int(channel.index) - let detailText: String - if unreadCount > 0 { - detailText = (channel.index == 0 ? "Primary" : "Ch \(channel.index)") + " ยท \(unreadCount) unread" - } else { - detailText = channel.index == 0 ? "Primary" : "Ch \(channel.index)" - } - let item = CPListItem( - text: name, - detailText: detailText, - image: UIImage(systemName: channel.index == 0 ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right") + let leadingConfig = CPMessageListItemLeadingConfiguration( + leadingItem: .none, + leadingImage: UIImage(systemName: channel.index == 0 ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right"), + unread: hasUnread ) - item.handler = { [weak self] _, completion in - self?.startChannelMessageIntent(channelIndex: Int(channel.index), channelName: name) - completion() - } - item.isEnabled = true + + let item = CPMessageListItem( + conversationIdentifier: "channel-\(channelIndex)", + text: name, + leadingConfiguration: leadingConfig, + trailingConfiguration: nil, + detailText: hasUnread ? "\(unreadCount) unread" : (channel.index == 0 ? "Primary" : "Ch \(channel.index)"), + trailingText: nil + ) + item.phoneOrEmailAddress = "channel-\(channelIndex)@meshtastic.local" + item.userInfo = channelIndex + + donateChannelIntent(channelIndex: channelIndex, channelName: name) + return item } } - // MARK: - Contact Detail Template + private func fetchDirectMessageItems() -> [CPMessageListItem] { + let request: NSFetchRequest = UserEntity.fetchRequest() + let connectedNum = AccessoryManager.shared.activeDeviceNum ?? 0 - private func pushContactTemplate(node: NodeInfoEntity) { - guard let interfaceController, - let user = node.user else { return } + // Match the app's UserList: exclude self, exclude ignored, exclude favorites (shown above), show unmessagable only if they have messages + let notSelf = NSPredicate(format: "userNode.num != %lld", connectedNum) + let notIgnored = NSPredicate(format: "userNode.ignored == NO") + let notFavorite = NSPredicate(format: "userNode.favorite == NO") + let unmessagableFilter = NSCompoundPredicate(type: .or, subpredicates: [ + NSPredicate(format: "unmessagable == NO"), + NSPredicate(format: "receivedMessages.@count > 0 OR sentMessages.@count > 0") + ]) + request.predicate = NSCompoundPredicate(type: .and, subpredicates: [notSelf, notIgnored, notFavorite, unmessagableFilter]) + request.sortDescriptors = [ + NSSortDescriptor(key: "userNode.lastHeard", ascending: false), + NSSortDescriptor(key: "lastMessage", ascending: false), + NSSortDescriptor(key: "longName", ascending: true) + ] + request.fetchLimit = 24 // CarPlay limits list items - let name = user.longName ?? user.shortName ?? "Unknown" - let shortName = user.shortName ?? "?" + do { + let users = try context.fetch(request) + return users.compactMap { user -> CPMessageListItem? in + guard let node = user.userNode else { return nil } + let name = user.longName ?? user.shortName ?? "Unknown" + let unreadCount = user.unreadMessages(context: context) + let hasUnread = unreadCount > 0 + let nodeNum = node.num - let placeholderImage = UIImage(systemName: "person.circle.fill")! - .withTintColor(.systemBlue, renderingMode: .alwaysOriginal) - let contact = CPContact(name: name, image: placeholderImage) - contact.subtitle = shortName - if node.hopsAway >= 0 { - contact.informativeText = node.hopsAway == 0 ? "Direct" : "\(node.hopsAway) hop\(node.hopsAway == 1 ? "" : "s") away" + let leadingConfig = CPMessageListItemLeadingConfiguration( + leadingItem: .none, + leadingImage: UIImage(systemName: "person.circle.fill"), + unread: hasUnread + ) + + let item = CPMessageListItem( + fullName: name, + phoneOrEmailAddress: "\(nodeNum)@meshtastic.local", + leadingConfiguration: leadingConfig, + trailingConfiguration: nil, + detailText: hasUnread ? "\(unreadCount) unread" : nil, + trailingText: lastHeardText(node.lastHeard) + ) + item.conversationIdentifier = "dm-\(nodeNum)" + item.userInfo = nodeNum + + donateMessageIntent(toNodeNum: nodeNum, name: name) + + return item + } + } catch { + Logger.services.error("๐Ÿš— [CarPlay] Failed to fetch DM users: \(error.localizedDescription, privacy: .public)") + return [] } - - // Native message button that launches Siri compose flow - let messageButton = CPContactMessageButton(phoneOrEmail: name) - contact.actions = [messageButton] - - // Also donate the intent so Siri has context for this contact - donateMessageIntent(toNodeNum: node.num, name: name) - - let contactTemplate = CPContactTemplate(contact: contact) - interfaceController.pushTemplate(contactTemplate, animated: true, completion: nil) } // MARK: - Intent Donation private func donateMessageIntent(toNodeNum: Int64, name: String) { + let handleValue = "\(toNodeNum)@meshtastic.local" let person = INPerson( - personHandle: INPersonHandle(value: "\(toNodeNum)", type: .unknown), + personHandle: INPersonHandle(value: handleValue, type: .emailAddress), nameComponents: nil, displayName: name, image: nil, - contactIdentifier: nil, - customIdentifier: "meshtastic-node-\(toNodeNum)" + contactIdentifier: "\(toNodeNum)", + customIdentifier: "\(toNodeNum)" ) let intent = INSendMessageIntent( recipients: [person], @@ -239,10 +340,19 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI } } - private func startChannelMessageIntent(channelIndex: Int, channelName: String) { + private func donateChannelIntent(channelIndex: Int, channelName: String) { + let channelHandle = "channel-\(channelIndex)@meshtastic.local" + let recipient = INPerson( + personHandle: INPersonHandle(value: channelHandle, type: .emailAddress), + nameComponents: nil, + displayName: channelName, + image: nil, + contactIdentifier: channelHandle, + customIdentifier: channelHandle + ) let groupName = INSpeakableString(spokenPhrase: channelName) let intent = INSendMessageIntent( - recipients: nil, + recipients: [recipient], outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: groupName, @@ -259,4 +369,69 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPI } } } + + // MARK: - Live Activity + +#if canImport(ActivityKit) + private func startLiveActivityIfNeeded() { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + Logger.services.info("๐Ÿš— [CarPlay] Live Activities not enabled") + return + } + + // Don't start another if one is already running + guard Activity.activities.isEmpty else { + Logger.services.info("๐Ÿš— [CarPlay] Live Activity already active") + return + } + + guard let connectedNum = AccessoryManager.shared.activeDeviceNum else { return } + let connectedNode = getNodeInfo(id: connectedNum, context: context) + let nodeName = connectedNode?.user?.longName ?? "Meshtastic" + let nodeShortName = connectedNode?.user?.shortName ?? "?" + + // Fetch latest local stats telemetry + let localStats = connectedNode?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4")) + let mostRecent = localStats?.lastObject as? TelemetryEntity + + let timerSeconds = 900 // 15 minute local stats interval + let future = Date(timeIntervalSinceNow: Double(timerSeconds)) + let initialState = MeshActivityAttributes.ContentState( + uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0), + channelUtilization: mostRecent?.channelUtilization ?? 0.0, + airtime: mostRecent?.airUtilTx ?? 0.0, + sentPackets: UInt32(mostRecent?.numPacketsTx ?? 0), + receivedPackets: UInt32(mostRecent?.numPacketsRx ?? 0), + badReceivedPackets: UInt32(mostRecent?.numPacketsRxBad ?? 0), + dupeReceivedPackets: UInt32(mostRecent?.numRxDupe ?? 0), + packetsSentRelay: UInt32(mostRecent?.numTxRelay ?? 0), + packetsCanceledRelay: UInt32(mostRecent?.numTxRelayCanceled ?? 0), + nodesOnline: UInt32(mostRecent?.numOnlineNodes ?? 0), + totalNodes: UInt32(mostRecent?.numTotalNodes ?? 0), + timerRange: Date.now...future + ) + + let attributes = MeshActivityAttributes(nodeNum: Int(connectedNum), name: nodeName, shortName: nodeShortName) + let content = ActivityContent(state: initialState, staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: Date())!) + + do { + let activity = try Activity.request(attributes: attributes, content: content, pushType: nil) + Logger.services.info("๐Ÿš— [CarPlay] Started Live Activity: \(activity.id)") + } catch { + Logger.services.error("๐Ÿš— [CarPlay] Failed to start Live Activity: \(error.localizedDescription, privacy: .public)") + } + } + + private func endLiveActivity() { + Task { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + Logger.services.info("๐Ÿš— [CarPlay] Ended Live Activity: \(activity.id)") + } + } + } +#else + private func startLiveActivityIfNeeded() {} + private func endLiveActivity() {} +#endif } diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index 9a5f03bc..338dba77 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -87,6 +87,12 @@ INSearchForMessagesIntent INSetMessageAttributeIntent + NSUserActivityTypes + + INSendMessageIntent + INSearchForMessagesIntent + INSetMessageAttributeIntent + ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/Meshtastic/Intents/IntentMessageConverters.swift b/Meshtastic/Intents/IntentMessageConverters.swift index cfd1a724..01b63fd5 100644 --- a/Meshtastic/Intents/IntentMessageConverters.swift +++ b/Meshtastic/Intents/IntentMessageConverters.swift @@ -10,10 +10,13 @@ import CoreData import Intents enum IntentMessageConverters { + static let meshtasticDomain = "@meshtastic.local" /// Converts a `UserEntity` to an `INPerson` for use with SiriKit intents. + /// Uses the `@meshtastic.local` email format so the handle matches `CPContactMessageButton` identifiers. static func inPerson(from user: UserEntity) -> INPerson { - let handle = INPersonHandle(value: String(user.num), type: .unknown) + let handleValue = "\(user.num)\(meshtasticDomain)" + let handle = INPersonHandle(value: handleValue, type: .emailAddress) return INPerson( personHandle: handle, nameComponents: nil, @@ -29,8 +32,8 @@ enum IntentMessageConverters { let sender: INPerson? = message.fromUser.map { inPerson(from: $0) } let recipients: [INPerson]? = message.toUser.map { [inPerson(from: $0)] } let dateSent = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) - let groupName: INSpeakableString? = message.channel > 0 - ? INSpeakableString(spokenPhrase: "Channel \(message.channel)") + let groupName: INSpeakableString? = message.toUser == nil + ? INSpeakableString(spokenPhrase: channelDisplayName(for: message.channel, named: nil)) : nil return INMessage( @@ -56,16 +59,30 @@ enum IntentMessageConverters { /// Searches for `UserEntity` objects whose name matches the given search term. static func findUsers(matching searchTerm: String, in context: NSManagedObjectContext) -> [UserEntity] { + if let nodeNum = directMessageNodeNum(from: searchTerm) { + let fetchRequest: NSFetchRequest = UserEntity.fetchRequest() + fetchRequest.fetchLimit = 1 + fetchRequest.predicate = NSPredicate(format: "num == %lld", nodeNum) + return (try? context.fetch(fetchRequest)) ?? [] + } + let fetchRequest: NSFetchRequest = UserEntity.fetchRequest() fetchRequest.predicate = NSPredicate( - format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@", - searchTerm, searchTerm + format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@ OR userId CONTAINS[cd] %@", + searchTerm, searchTerm, searchTerm ) return (try? context.fetch(fetchRequest)) ?? [] } /// Looks up a `ChannelEntity` by matching name. static func findChannels(matching name: String, in context: NSManagedObjectContext) -> [ChannelEntity] { + if let explicitIndex = channelIndex(fromHandleOrName: name) { + let fetchRequest: NSFetchRequest = ChannelEntity.fetchRequest() + fetchRequest.fetchLimit = 1 + fetchRequest.predicate = NSPredicate(format: "index == %d", explicitIndex) + return (try? context.fetch(fetchRequest)) ?? [] + } + let fetchRequest: NSFetchRequest = ChannelEntity.fetchRequest() fetchRequest.predicate = NSPredicate( format: "name != nil AND name != '' AND name CONTAINS[cd] %@", name @@ -75,7 +92,57 @@ enum IntentMessageConverters { /// Resolves a channel index from a spoken group name, defaulting to the primary channel. static func channelIndex(for name: String, in context: NSManagedObjectContext) -> Int { + if let explicitIndex = channelIndex(fromHandleOrName: name) { + return explicitIndex + } + let channels = findChannels(matching: name, in: context) return channels.first.map { Int($0.index) } ?? 0 } + + static func directMessageNodeNum(from value: String) -> Int64? { + if let nodeNum = Int64(value) { + return nodeNum + } + + if value.hasSuffix(meshtasticDomain) { + let rawValue = String(value.dropLast(meshtasticDomain.count)) + return Int64(rawValue) + } + + return nil + } + + static func channelIndex(fromHandleOrName value: String) -> Int? { + if value.caseInsensitiveCompare("Primary Channel") == .orderedSame { + return 0 + } + + if value.hasPrefix("Channel "), let index = Int(value.dropFirst("Channel ".count)) { + return index + } + + let channelPrefix = "channel-" + if value.hasPrefix(channelPrefix) { + let remainder = String(value.dropFirst(channelPrefix.count)) + let rawIndex = remainder.hasSuffix(meshtasticDomain) + ? String(remainder.dropLast(meshtasticDomain.count)) + : remainder + return Int(rawIndex) + } + + return nil + } + + static func channelDisplayName(for index: Int32, named name: String?) -> String { + if let name, !name.isEmpty { + return name + } + + if index == 0 { + return "Primary Channel" + } + + return "Channel \(index)" + } } diff --git a/Meshtastic/Intents/SearchForMessagesIntentHandler.swift b/Meshtastic/Intents/SearchForMessagesIntentHandler.swift index 42d6208e..98f5551a 100644 --- a/Meshtastic/Intents/SearchForMessagesIntentHandler.swift +++ b/Meshtastic/Intents/SearchForMessagesIntentHandler.swift @@ -29,6 +29,22 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH predicates.append(NSPredicate(format: "admin == NO")) predicates.append(NSPredicate(format: "isEmoji == NO")) + // Filter by conversation identifiers (e.g., "dm-123456" or "channel-0") + // This is the primary filter when Siri reads messages for a CarPlay contact. + if let conversationIds = intent.conversationIdentifiers, !conversationIds.isEmpty { + var conversationPredicates: [NSPredicate] = [] + for convId in conversationIds { + if convId.hasPrefix("dm-"), let nodeNum = Int64(convId.dropFirst("dm-".count)) { + conversationPredicates.append(NSPredicate(format: "fromUser.num == %lld", nodeNum)) + } else if convId.hasPrefix("channel-"), let channelIndex = Int32(convId.dropFirst("channel-".count)) { + conversationPredicates.append(NSPredicate(format: "channel == %d AND toUser == nil", channelIndex)) + } + } + if !conversationPredicates.isEmpty { + predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: conversationPredicates)) + } + } + // Filter by identifiers (specific message IDs) if let identifiers = intent.identifiers, !identifiers.isEmpty { let messageIds = identifiers.compactMap { Int64($0) } @@ -37,9 +53,12 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH } } - // Filter by sender + // Filter by sender โ€” parse @meshtastic.local email-format handles if let senders = intent.senders, !senders.isEmpty { - let senderNums = senders.compactMap { $0.personHandle?.value }.compactMap { Int64($0) } + let senderNums = senders.compactMap { sender -> Int64? in + guard let handleValue = sender.personHandle?.value else { return nil } + return IntentMessageConverters.directMessageNodeNum(from: handleValue) + } if !senderNums.isEmpty { predicates.append(NSPredicate(format: "fromUser.num IN %@", senderNums)) } @@ -62,16 +81,19 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH } } - // Filter by group/channel name + // Filter by group/channel name or handle if let groupNames = intent.speakableGroupNames, !groupNames.isEmpty { let channelIndices: [Int32] = groupNames.compactMap { groupName in + if let idx = IntentMessageConverters.channelIndex(fromHandleOrName: groupName.spokenPhrase) { + return Int32(idx) + } let channels = IntentMessageConverters.findChannels( matching: groupName.spokenPhrase, in: context ) return channels.first.map { Int32($0.index) } } if !channelIndices.isEmpty { - predicates.append(NSPredicate(format: "channel IN %@", channelIndices)) + predicates.append(NSPredicate(format: "channel IN %@ AND toUser == nil", channelIndices)) } } diff --git a/Meshtastic/Intents/SendMessageIntentHandler.swift b/Meshtastic/Intents/SendMessageIntentHandler.swift index 7ab11234..7a42937d 100644 --- a/Meshtastic/Intents/SendMessageIntentHandler.swift +++ b/Meshtastic/Intents/SendMessageIntentHandler.swift @@ -30,7 +30,21 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling { } let context = PersistenceController.shared.container.viewContext - let searchTerm = recipients[0].displayName + let recipient = recipients[0] + let handleValue = recipient.personHandle?.value ?? "" + + // If this is a channel handle, accept it directly + if IntentMessageConverters.channelIndex(fromHandleOrName: handleValue) != nil { + return [.success(with: recipient)] + } + + // If the handle resolves to a node number, accept it directly + if IntentMessageConverters.directMessageNodeNum(from: handleValue) != nil { + return [.success(with: recipient)] + } + + // Otherwise search by display name + let searchTerm = recipient.displayName ?? handleValue let matchingUsers = await MainActor.run { IntentMessageConverters.findUsers(matching: searchTerm, in: context) } @@ -71,11 +85,15 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling { } if matchingChannels.count == 1, let channel = matchingChannels.first { - let speakable = INSpeakableString(spokenPhrase: channel.name ?? "Channel \(channel.index)") + let speakable = INSpeakableString( + spokenPhrase: IntentMessageConverters.channelDisplayName(for: channel.index, named: channel.name) + ) return .success(with: speakable) } else if matchingChannels.count > 1 { let speakables = matchingChannels.map { - INSpeakableString(spokenPhrase: $0.name ?? "Channel \($0.index)") + INSpeakableString( + spokenPhrase: IntentMessageConverters.channelDisplayName(for: $0.index, named: $0.name) + ) } return .disambiguation(with: speakables) } @@ -120,16 +138,27 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling { replyID: 0 ) } else if let recipient = intent.recipients?.first, - let handleValue = recipient.personHandle?.value, - let nodeNum = Int64(handleValue) { + let handleValue = recipient.personHandle?.value { + if let channelIndex = IntentMessageConverters.channelIndex(fromHandleOrName: handleValue) { + try await AccessoryManager.shared.sendMessage( + message: content, + toUserNum: 0, + channel: Int32(channelIndex), + isEmoji: false, + replyID: 0 + ) + } else if let nodeNum = IntentMessageConverters.directMessageNodeNum(from: handleValue) { // Direct message to a single node - try await AccessoryManager.shared.sendMessage( - message: content, - toUserNum: nodeNum, - channel: 0, - isEmoji: false, - replyID: 0 - ) + try await AccessoryManager.shared.sendMessage( + message: content, + toUserNum: nodeNum, + channel: 0, + isEmoji: false, + replyID: 0 + ) + } else { + return INSendMessageIntentResponse(code: .failure, userActivity: nil) + } } else { return INSendMessageIntentResponse(code: .failure, userActivity: nil) } diff --git a/Meshtastic/Views/Connect/Connect.swift b/Meshtastic/Views/Connect/Connect.swift index 84e90c6e..7e27221c 100644 --- a/Meshtastic/Views/Connect/Connect.swift +++ b/Meshtastic/Views/Connect/Connect.swift @@ -376,7 +376,7 @@ struct Connect: View { let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4")) let mostRecent = localStats?.lastObject as? TelemetryEntity - let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown") + let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown", shortName: node?.user?.shortName ?? "?") let future = Date(timeIntervalSinceNow: Double(timerSeconds)) let initialContentState = MeshActivityAttributes.ContentState(uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0), diff --git a/MeshtasticTests/CarPlayTests.swift b/MeshtasticTests/CarPlayTests.swift index 61b33929..786e4136 100644 --- a/MeshtasticTests/CarPlayTests.swift +++ b/MeshtasticTests/CarPlayTests.swift @@ -130,7 +130,8 @@ extension CarPlayIntentDonation { let me = mePerson() if toUserNum != 0 { - let recipientHandle = INPersonHandle(value: String(toUserNum), type: .unknown) + let handleValue = "\(toUserNum)@meshtastic.local" + let recipientHandle = INPersonHandle(value: handleValue, type: .emailAddress) let recipient = INPerson( personHandle: recipientHandle, nameComponents: nil, diff --git a/Widgets/MeshActivityAttributes.swift b/Widgets/MeshActivityAttributes.swift index 8d7ea9af..7d2bd5a8 100644 --- a/Widgets/MeshActivityAttributes.swift +++ b/Widgets/MeshActivityAttributes.swift @@ -34,6 +34,7 @@ struct MeshActivityAttributes: ActivityAttributes { // Fixed non-changing properties about your activity go here! var nodeNum: Int var name: String + var shortName: String } #endif #endif diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift index 5f1e6d29..525cf10b 100644 --- a/Widgets/WidgetsLiveActivity.swift +++ b/Widgets/WidgetsLiveActivity.swift @@ -14,7 +14,7 @@ struct WidgetsLiveActivity: Widget { ActivityConfiguration(for: MeshActivityAttributes.self) { context in LiveActivityView(nodeName: context.attributes.name, - uptimeSeconds: 0, // context.attributes.uptimeSeconds, + uptimeSeconds: context.state.uptimeSeconds, channelUtilization: context.state.channelUtilization, airtime: context.state.airtime, sentPackets: context.state.sentPackets, @@ -31,18 +31,16 @@ struct WidgetsLiveActivity: Widget { } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { - if context.state.totalNodes > 0 { - Text(" \(context.state.nodesOnline) online") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize() - } else { - Text(" ") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize() - } - Text("Ch. Util: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") + Text(context.attributes.shortName) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .fixedSize() + Text("Sent: \(context.state.sentPackets)") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize() + Text("ChUtil: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") .font(.caption2) .foregroundStyle(.secondary) .fixedSize() @@ -50,10 +48,6 @@ struct WidgetsLiveActivity: Widget { .font(.caption2) .foregroundStyle(.secondary) .fixedSize() - Text("Sent: \(context.state.sentPackets)") - .font(.caption2) - .foregroundStyle(.secondary) - .fixedSize() Text("Received: \(context.state.receivedPackets)") .font(.caption2) .foregroundStyle(.secondary) @@ -64,52 +58,95 @@ struct WidgetsLiveActivity: Widget { .tint(Color("LightIndigo")) } DynamicIslandExpandedRegion(.trailing, priority: 1) { - Spacer() + if context.state.totalNodes > 0 { + HStack(spacing: 3) { + Image(systemName: "person.2.fill") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(context.state.nodesOnline)/\(context.state.totalNodes)") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.primary) + } + .fixedSize() + } Text("Bad: \(context.state.badReceivedPackets)") - .font(.caption) + .font(.caption2) .foregroundStyle(.secondary) .fixedSize() Text("Dupe: \(context.state.dupeReceivedPackets)") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize() - Text("Relayed: \(context.state.packetsSentRelay)") - .font(.caption) + .font(.caption2) .foregroundStyle(.secondary) .fixedSize() - Text("Relay Cancel: \(context.state.packetsCanceledRelay)") - .font(.caption) + Text("Relayed: \(context.state.packetsSentRelay)") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize() + Text("Rly Cancel: \(context.state.packetsCanceledRelay)") + .font(.caption2) .foregroundStyle(.secondary) .fixedSize() } DynamicIslandExpandedRegion(.bottom) { - Text("Last Heard: \(Date().formatted())") - .font(.caption2) - .fontWeight(.medium) - .foregroundStyle(.tint) - .fixedSize() + HStack(spacing: 4) { + if let uptime = context.state.uptimeSeconds, uptime > 0 { + Text("UPTIME:") + .font(.caption2) + .foregroundStyle(.tint) + Text(uptime >= 3600 ? "\(uptime / 3600)h \((uptime % 3600) / 60)m" : "\((uptime % 3600) / 60)m") + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.tint) + Text("โ€ข") + .font(.caption2) + .foregroundStyle(.tint) + } + Text("UPDATED:") + .font(.caption2) + .foregroundStyle(.tint) + Text("\(Date().formatted(date: .omitted, time: .shortened))") + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.tint) + } } } compactLeading: { - Image("m-logo-black") - .resizable() - .frame(width: 25) - .padding(4) - .background(.green.gradient, in: ContainerRelativeShape()) + HStack(spacing: 2) { + Image(systemName: "person.2.fill") + .font(.system(size: 9)) + .foregroundStyle(.green) + if context.state.totalNodes > 0 { + Text("\(context.state.nodesOnline)") + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(.primary) + } + } + .fixedSize() } compactTrailing: { - Text(timerInterval: context.state.timerRange, countsDown: true) - .monospacedDigit() - .foregroundColor(Color("LightIndigo")) - .frame(width: 40) + Text("\(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(1))) ?? "--")%") + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.primary) + .fixedSize() } minimal: { - Image("m-logo-black") - .resizable() - .frame(width: 24.0) - .padding(4) - .background(.green.gradient, in: ContainerRelativeShape()) + ZStack { + Image(systemName: "person.2.fill") + .font(.system(size: 10)) + .foregroundStyle(.green) + if context.state.totalNodes > 0 { + Text("\(context.state.nodesOnline)") + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.white) + .offset(y: 6) + } + } } - .contentMargins(.trailing, 32, for: .expanded) - .contentMargins([.leading, .top, .bottom], 6, for: .compactLeading) + .contentMargins(.leading, 16, for: .expanded) + .contentMargins(.trailing, 16, for: .expanded) + .contentMargins(.all, 6, for: .compactLeading) + .contentMargins(.all, 6, for: .compactTrailing) .contentMargins(.all, 6, for: .minimal) .widgetURL(URL(string: "meshtastic:///connect")) } @@ -117,7 +154,7 @@ struct WidgetsLiveActivity: Widget { } struct WidgetsLiveActivity_Previews: PreviewProvider { - static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G") + static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G", shortName: "8E6G") static let state = MeshActivityAttributes.ContentState(uptimeSeconds: 600, channelUtilization: 1.2, airtime: 3.5, sentPackets: 12587, receivedPackets: 12555, badReceivedPackets: 800, dupeReceivedPackets: 100, packetsSentRelay: 250, packetsCanceledRelay: 372, nodesOnline: 99, totalNodes: 100, timerRange: Date.now...Date(timeIntervalSinceNow: 300)) static var previews: some View { @@ -154,108 +191,122 @@ struct LiveActivityView: View { var timerRange: ClosedRange var body: some View { - HStack { - Spacer() - Image(colorScheme == .light ? "m-logo-black" : "m-logo-white") - .resizable() - .clipShape(ContainerRelativeShape()) - .opacity(isLuminanceReduced ? 0.5 : 1.0) - .aspectRatio(contentMode: .fit) - .frame(minWidth: 25, idealWidth: 45, maxWidth: 55) - Spacer() - NodeInfoView(isLuminanceReduced: _isLuminanceReduced, nodeName: nodeName, uptimeSeconds: uptimeSeconds, channelUtilization: channelUtilization, airtime: airtime, sentPackets: sentPackets, receivedPackets: receivedPackets, badReceivedPackets: badReceivedPackets, - dupeReceivedPackets: dupeReceivedPackets, packetsSentRelay: packetsSentRelay, packetsCanceledRelay: packetsCanceledRelay, nodesOnline: nodesOnline, timerRange: timerRange) - Spacer() - } - .tint(.primary) - .padding([.leading, .top, .bottom]) - .padding(.trailing, 25) - .activityBackgroundTint(colorScheme == .light ? Color("LiveActivityBackground") : Color("AccentColorDimmed")) - .activitySystemActionForegroundColor(.primary) - } -} + let errorRate = receivedPackets > 0 + ? (Double(badReceivedPackets) / Double(receivedPackets)) * 100 + : 0.0 + let now = Date() -struct NodeInfoView: View { - @Environment(\.isLuminanceReduced) var isLuminanceReduced + VStack(alignment: .leading, spacing: 4) { + // Header row: logo + node name + nodes online + HStack(spacing: 6) { + Image("m-logo-white") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 6)) + Text(nodeName) + .font(.callout) + .fontWeight(.semibold) + .foregroundStyle(.tint) + .lineLimit(1) + Spacer() + if totalNodes > 0 { + HStack(spacing: 3) { + Image(systemName: "person.2.fill") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(nodesOnline)/\(totalNodes)") + .font(.caption2) + .foregroundStyle(.secondary) + } + .fixedSize() + } + } - var nodeName: String - var uptimeSeconds: UInt32? - var channelUtilization: Float? - var airtime: Float? - var sentPackets: UInt32 - var receivedPackets: UInt32 - var badReceivedPackets: UInt32 - var dupeReceivedPackets: UInt32 - var packetsSentRelay: UInt32 - var packetsCanceledRelay: UInt32 - var nodesOnline: UInt32 - var timerRange: ClosedRange + // Stats grid โ€” two columns + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + StatRow(label: "Ch. Utilization", value: "\(channelUtilization?.formatted(.number.precision(.fractionLength(1))) ?? "--")%") + StatRow(label: "Airtime", value: "\(airtime?.formatted(.number.precision(.fractionLength(1))) ?? "--")%") + StatRow(label: "Sent", value: "\(sentPackets)") + StatRow(label: "Received", value: "\(receivedPackets)") + } + VStack(alignment: .leading, spacing: 2) { + StatRow(label: "Error Rate", value: "\(errorRate.formatted(.number.precision(.fractionLength(1))))%") + StatRow(label: "Relayed", value: "\(packetsSentRelay)") + StatRow(label: "Relay Canceled", value: "\(packetsCanceledRelay)") + StatRow(label: "Duplicate", value: "\(dupeReceivedPackets)") + } + } + .fixedSize(horizontal: true, vertical: false) + .opacity(isLuminanceReduced ? 0.8 : 1.0) - var body: some View { - let errorRate = (Double(badReceivedPackets) / Double(receivedPackets)) * 100 - VStack(alignment: .leading, spacing: 0) { - Text(nodeName) - .font(nodeName.count > 14 ? .callout : .title3) - .fontWeight(.semibold) - .foregroundStyle(.tint) - // Text("\(channelUtilization.map { String(format: "Ch. Util: %.2f", $0 ) } ?? "--")% \(airtime.map { String(format: "Airtime: %.2f", $0) } ?? "--")%") - Text("Ch. Util: \(channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - Text("Packets: Sent \(sentPackets) Rec. \(receivedPackets)") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - Text("Bad: \(badReceivedPackets) Error Rate: \(errorRate.formatted(.number.precision(.fractionLength(2))))%") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - - Text("Connected: \(nodesOnline) nodes online") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - - let now = Date() - Text("Last Heard: \(now.formatted())") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() + // Footer: uptime + timer HStack { - - if timerRange.upperBound >= now { - Text("Next Update:") - .font(.caption) - .fontWeight(.medium) + Spacer(minLength: 0) + if let uptimeSeconds, uptimeSeconds > 0 { + Text("Uptime:") + .font(.caption2) + .foregroundStyle(.secondary) + Text(uptimeText(uptimeSeconds)) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.tint) + Text("โ€ข") + .font(.caption2) + .foregroundStyle(.secondary) + } + if timerRange.upperBound >= now { + Text("Update in:") + .font(.caption2) .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() Text(timerInterval: timerRange, countsDown: true) .monospacedDigit() - .multilineTextAlignment(.leading) - .font(.caption) + .font(.caption2) .fontWeight(.medium) .foregroundStyle(.tint) } else { Text("Not Connected") - .multilineTextAlignment(.leading) - .font(.caption) + .font(.caption2) .fontWeight(.semibold) .foregroundStyle(.tint) } + Spacer(minLength: 0) } + .fixedSize(horizontal: false, vertical: true) } + .tint(.primary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .activityBackgroundTint(colorScheme == .light ? Color("LiveActivityBackground") : Color("AccentColorDimmed")) + .activitySystemActionForegroundColor(.primary) + } + + private func uptimeText(_ seconds: UInt32) -> String { + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + if hours > 0 { + return "\(hours)h \(minutes)m" + } + return "\(minutes)m" + } +} + +struct StatRow: View { + var label: String + var value: String + + var body: some View { + HStack(spacing: 4) { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + Text(value) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.primary) + } + .fixedSize() } } @@ -265,27 +316,26 @@ struct TimerView: View { var timerRange: ClosedRange var body: some View { - VStack(alignment: .center) { + VStack(alignment: .center, spacing: 2) { Text("UPDATE IN") .font(.caption2) - .allowsTightening(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/) + .allowsTightening(true) .fontWeight(.medium) .foregroundStyle(.secondary) .opacity(isLuminanceReduced ? 0.5 : 1.0) Text(timerInterval: timerRange, countsDown: true) .monospacedDigit() .multilineTextAlignment(.center) - .frame(width: 80) - .font(.callout) + .frame(width: 60) + .font(.caption) .fontWeight(.semibold) .foregroundStyle(.tint) Image(systemName: "timer") .symbolRenderingMode(.multicolor) .resizable() .foregroundStyle(.secondary) - .frame(width: 30, height: 30) + .frame(width: 20, height: 20) .opacity(isLuminanceReduced ? 0.5 : 1.0) - .offset(y: -5) } } }