diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index ec7c43b..df85c4a 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ App/RootView.swift, Assets.xcassets, ContentView.swift, + Core/Config/AppConfig.swift, Core/CrashReporting/CrashReportingConsent.swift, Core/CrashReporting/CrashReportScrubber.swift, Core/CrashReporting/PendingCrashSheet.swift, @@ -114,6 +115,8 @@ Core/Networking/Z2MWebSocketClient.swift, Core/Networking/Z2MWebSocketSessionDelegate.swift, Core/Parsing/DeviceDocNormalizer.swift, + "Core/Parsing/DeviceDocNormalizer+Pairing.swift", + Core/Parsing/DeviceDocumentationModels.swift, Core/Parsing/DocParser.swift, Core/Parsing/FrontendReferenceRewriter.swift, Core/Services/BundledDocStore.swift, @@ -121,6 +124,11 @@ Core/Services/DocBrowserIndex.swift, Core/Services/GuideDocService.swift, Core/Store/AppStore.swift, + "Core/Store/AppStore+Devices.swift", + "Core/Store/AppStore+Events.swift", + "Core/Store/AppStore+Logs.swift", + "Core/Store/AppStore+Notifications.swift", + "Core/Store/AppStore+OTA.swift", Core/Store/NotificationPreferences.swift, Features/Bridge/PhilipsHueResetSheet.swift, Features/Bridge/TouchlinkDeviceRow.swift, @@ -196,6 +204,8 @@ Features/Logs/LogRowView.swift, Features/Logs/LogsView.swift, Features/Logs/LogsViewModel.swift, + Features/Notifications/FastTrackBanner.swift, + Features/Notifications/InAppNotificationBanner.swift, Features/Notifications/InAppNotificationOverlay.swift, Features/Settings/AboutView.swift, Features/Settings/AcknowledgementsView.swift, @@ -210,6 +220,7 @@ Features/Settings/Developer/DeveloperSettings.swift, Features/Settings/Developer/DeveloperSettingsView.swift, Features/Settings/Developer/MQTTInspectorView.swift, + Features/Settings/Developer/SubscribeStore.swift, Features/Settings/DeviceStatisticsView.swift, Features/Settings/DocBrowserDetailView.swift, Features/Settings/DocBrowserView.swift, @@ -264,6 +275,8 @@ Shared/ExposeCardView.swift, Shared/FanControl/FanControlCard.swift, Shared/FanControl/FanControlContext.swift, + Shared/FanControl/FanExtraRow.swift, + Shared/FanControl/FanFeatureSections.swift, Shared/GenericExposeCard/GenericExposeCard.swift, "Shared/LightControl/Color+HexString.swift", Shared/LightControl/LightAdvancedFeatureRow.swift, @@ -809,7 +822,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -850,7 +863,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -890,7 +903,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -932,7 +945,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Shellbee/App/MainTabView.swift b/Shellbee/App/MainTabView.swift index 5e64753..02d8264 100644 --- a/Shellbee/App/MainTabView.swift +++ b/Shellbee/App/MainTabView.swift @@ -23,7 +23,7 @@ struct MainTabView: View { .overlay(alignment: .bottom) { InAppNotificationOverlay() .safeAreaPadding(.bottom) - .padding(.bottom, 58) + .padding(.bottom, DesignTokens.Size.mainTabBarInset) } .sheet(item: Binding( get: { environment.pendingLogSheet }, diff --git a/Shellbee/App/RootView.swift b/Shellbee/App/RootView.swift index 0e976bc..8ae3a70 100644 --- a/Shellbee/App/RootView.swift +++ b/Shellbee/App/RootView.swift @@ -17,7 +17,7 @@ struct RootView: View { setupInterface } } - .animation(.spring(duration: 0.6), value: isInitializing) + .animation(.spring(duration: DesignTokens.Duration.slowAnimation), value: isInitializing) .sheet(item: $pendingCrash) { crash in PendingCrashSheet( crash: crash, diff --git a/Shellbee/App/SplashScreenView.swift b/Shellbee/App/SplashScreenView.swift index 4d42de0..8178970 100644 --- a/Shellbee/App/SplashScreenView.swift +++ b/Shellbee/App/SplashScreenView.swift @@ -19,15 +19,16 @@ struct SplashScreenView: View { Image(splashImageName) .resizable() .scaledToFit() - .frame(width: 120, height: 120) + .frame(width: DesignTokens.Size.splashIconLarge, height: DesignTokens.Size.splashIconLarge) .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.xl, style: .continuous)) - .shadow(color: .black.opacity(0.12), radius: 18, y: 8) + .shadow(color: .black.opacity(DesignTokens.Opacity.chipFill), + radius: DesignTokens.Shadow.splashRadius, y: DesignTokens.Shadow.splashY) .scaleEffect(isVisible ? 1 : 0.8) .opacity(isVisible ? 1 : 0) Text("Shellbee") .font(.system(size: DesignTokens.Size.splashTitle, weight: .bold, design: .rounded)) - .tracking(-1) + .tracking(DesignTokens.Tracking.splashTitle) .opacity(isVisible ? 1 : 0) .offset(y: isVisible ? 0 : 10) @@ -45,7 +46,7 @@ struct SplashScreenView: View { } } .onAppear { - withAnimation(.easeOut(duration: 0.8)) { + withAnimation(.easeOut(duration: DesignTokens.Duration.pulseExpand)) { isVisible = true } } diff --git a/Shellbee/Core/Config/AppConfig.swift b/Shellbee/Core/Config/AppConfig.swift new file mode 100644 index 0000000..be2bfdb --- /dev/null +++ b/Shellbee/Core/Config/AppConfig.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Tunable values that shape app behavior — networking timeouts, UX +/// windows, retry budgets, etc. These are *behavior tokens*, distinct from +/// `DesignTokens` (visual). Today they're static defaults; over time some +/// will be exposed in Settings as user-configurable. +/// +/// Add new values to a topical sub-enum (Networking, UX, …) rather than +/// the top level so this file stays browsable. +nonisolated enum AppConfig { + + /// WebSocket / discovery / network-layer timeouts and retry budgets. + nonisolated enum Networking { + /// Hard cap on how long we wait for the WebSocket handshake to + /// complete before giving up and surfacing a connection failure. + static let websocketConnectionTimeout: TimeInterval = 10 + + /// After the WebSocket opens, how long to wait for the first + /// inbound frame before treating the connection as silent / stuck. + /// Z2M sends `bridge/info` immediately on connect, so a 5s window + /// covers slow networks without making genuine breakage feel laggy. + static let websocketFirstMessageTimeout: TimeInterval = 5 + + /// How long the discovery service probes a candidate host:port + /// before declaring it unreachable. Short enough to scan a /24 in + /// a reasonable wall-clock time when most addresses don't answer. + static let discoveryProbeTimeout: TimeInterval = 1.5 + } + + /// UX-tuning windows that aren't visual (durations, coalescing, recency). + nonisolated enum UX { + /// Notifications with the same `coalesceKey` arriving within this + /// window collapse into a single banner with a `× N` count badge. + /// Tuned so a burst of related events (e.g. an interview producing + /// multiple log lines) reads as one notification, not four. + static let notificationCoalesceWindow: TimeInterval = 1.5 + + /// How long after a device first joins the network it shows the + /// "Recently Added" badge in the device list. 30 minutes covers + /// most pairing → naming → first-test workflows without lingering + /// on the homepage forever. + static let recentDeviceWindow: TimeInterval = 30 * 60 + } +} diff --git a/Shellbee/Core/Networking/Z2MDiscoveryService.swift b/Shellbee/Core/Networking/Z2MDiscoveryService.swift index d5ad5a1..63c9413 100644 --- a/Shellbee/Core/Networking/Z2MDiscoveryService.swift +++ b/Shellbee/Core/Networking/Z2MDiscoveryService.swift @@ -10,7 +10,7 @@ final class Z2MDiscoveryService { @MainActor private var scanTask: Task? nonisolated private static let z2mPort: UInt16 = 8080 - nonisolated private static let probeTimeout: TimeInterval = 1.5 + nonisolated private static let probeTimeout: TimeInterval = AppConfig.Networking.discoveryProbeTimeout nonisolated private static let maxConcurrent = 48 @MainActor diff --git a/Shellbee/Core/Networking/Z2MWebSocketClient.swift b/Shellbee/Core/Networking/Z2MWebSocketClient.swift index ab6d4c7..9f1695b 100644 --- a/Shellbee/Core/Networking/Z2MWebSocketClient.swift +++ b/Shellbee/Core/Networking/Z2MWebSocketClient.swift @@ -2,7 +2,7 @@ import Foundation actor Z2MWebSocketClient { - private static let connectionTimeout: TimeInterval = 10 + private static let connectionTimeout: TimeInterval = AppConfig.Networking.websocketConnectionTimeout /// After the WS handshake succeeds we wait for the *first inbound message* /// before declaring the connection valid. Z2M accepts the HTTP 101 upgrade /// and only then either (a) immediately publishes the cached bridge state / @@ -10,7 +10,7 @@ actor Z2MWebSocketClient { /// auth token is missing/invalid. Both arrive over the WS — receive() will /// return data on success and throw on close. If neither happens within /// this timeout, the bridge is unreachable. - private static let firstMessageTimeout: TimeInterval = 5 + private static let firstMessageTimeout: TimeInterval = AppConfig.Networking.websocketFirstMessageTimeout /// Default URLSessionWebSocketTask frame limit is 1 MB. Z2M `bridge/response/backup` /// payloads carry the entire data folder as a base64 string inside JSON — a populated /// install with many devices and rotated config backups can produce 5–10 MB frames. diff --git a/Shellbee/Core/Parsing/DeviceDocNormalizer+Pairing.swift b/Shellbee/Core/Parsing/DeviceDocNormalizer+Pairing.swift new file mode 100644 index 0000000..c4011d7 --- /dev/null +++ b/Shellbee/Core/Parsing/DeviceDocNormalizer+Pairing.swift @@ -0,0 +1,175 @@ +import Foundation + +extension DeviceDocNormalizer { + static nonisolated func extractFromNotes(_ section: DocSection) -> (pairingBlocks: [DocBlock], pairingRelatedBlocks: [DocBlock], noteBlocks: [DocBlock]) { + var pairingBlocks: [DocBlock] = [] + var pairingRelatedBlocks: [DocBlock] = [] + var noteBlocks: [DocBlock] = [] + + for block in section.blocks { + switch block { + case .subsection(let title, let blocks): + let normalizedTitle = normalizeTitle(title) + if normalizedTitle == "pairing" { + pairingBlocks.append(contentsOf: blocks) + } else if isPairingAdjacentTitle(normalizedTitle) { + pairingRelatedBlocks.append(.subsection(title: title, blocks: blocks)) + } else { + noteBlocks.append(.subsection(title: title, blocks: blocks)) + } + default: + noteBlocks.append(block) + } + } + + return (pairingBlocks, pairingRelatedBlocks, noteBlocks) + } + + static nonisolated func makePairingGuide( + from pairingBlocks: [DocBlock], + relatedBlocks: [DocBlock], + identity: DeviceDocIdentity + ) -> DevicePairingGuide? { + // Only count steps that came from real step-list blocks, not paragraph fallback. + // Paragraph fallback produces step text identical to the summary, which would show twice. + let primarySteps = collectStepItems(from: pairingBlocks, paragraphFallback: false) + let summary = firstParagraph(in: pairingBlocks) ?? defaultPairingSummary(identity: identity, hasSteps: !primarySteps.isEmpty) + let summaryText = plainText(summary) + + // Exclude summary so it doesn't also appear in Before You Start / Success / Troubleshooting + let paragraphs = collectParagraphSpans(from: pairingBlocks + relatedBlocks) + .filter { plainText($0) != summaryText } + + let prerequisites = unique(paragraphs.filter { matchesAny($0, keywords: prerequisiteKeywords) }) + let successCues = unique(paragraphs.filter { matchesAny($0, keywords: successKeywords) }) + let troubleshooting = unique(paragraphs.filter { matchesAny($0, keywords: troubleshootingKeywords) }) + + // Track every plain-text span already shown in a named section so additionalNotes never repeats them + var usedTexts = Set([summaryText]) + for spans in prerequisites + successCues + troubleshooting { + usedTexts.insert(plainText(spans)) + } + + let alternatives = collectSubsections(from: pairingBlocks) + .filter { normalizeTitle($0.title) != "pairing" } + .map { subsection in + let altSummary = firstParagraph(in: subsection.blocks) ?? [] + let altSummaryText = plainText(altSummary) + let altSteps = collectStepItems(from: subsection.blocks, paragraphFallback: false) + let altNotes = subsection.blocks.filter { block in + guard !isPureStepList(block) else { return false } + if case .paragraph(let spans) = block { return plainText(spans) != altSummaryText } + return true + } + let normalizedSubtitle = normalizeTitle(subsection.title) + // A subsection is a pure Touchlink reference when its title mentions Touchlink and it + // contains no device-specific steps or notes — just a link to the Touchlink guide. + let isTouchlinkReset = normalizedSubtitle.contains("touchlink") + && altSteps.isEmpty + && altNotes.isEmpty + // A subsection describes a Philips Hue serial-number reset when it mentions + // "touchlink" and "serial". The Z2M docs include raw JSON and frontend references + // for this flow which the app replaces with the in-app Philips Hue Reset action. + let isPhilipsHueSerialReset = normalizedSubtitle.contains("touchlink") + && normalizedSubtitle.contains("serial") + return DevicePairingMethod( + title: subsection.title, + summary: altSummary, + steps: altSteps, + notes: altNotes, + isTouchlinkReset: isTouchlinkReset, + isPhilipsHueSerialReset: isPhilipsHueSerialReset + ) + } + .filter { !$0.summary.isEmpty || !$0.steps.isEmpty || !$0.notes.isEmpty || $0.isTouchlinkReset || $0.isPhilipsHueSerialReset } + + // Subsection titles promoted to Alternatives — skip them in additionalNotes + let alternativeTitles = Set(alternatives.map { normalizeTitle($0.title) }) + + let additionalNotes = (pairingBlocks + relatedBlocks).filter { block in + switch block { + case .paragraph(let spans), .note(let spans): + return !usedTexts.contains(plainText(spans)) + case .stepList: + return false + case .subsection(let title, _): + return !alternativeTitles.contains(normalizeTitle(title)) + default: + return true + } + } + + let guide = DevicePairingGuide( + summary: summary, + prerequisites: prerequisites, + primarySteps: primarySteps, + alternatives: alternatives, + successCues: successCues, + troubleshooting: troubleshooting, + additionalNotes: additionalNotes + ) + + return guide.hasContent ? guide : nil + } + + private static nonisolated func collectSubsections(from blocks: [DocBlock]) -> [(title: String, blocks: [DocBlock])] { + blocks.compactMap { block in + if case .subsection(let title, let blocks) = block { + return (title, blocks) + } + return nil + } + } + + private static nonisolated func isPureStepList(_ block: DocBlock) -> Bool { + if case .stepList = block { return true } + return false + } + + private static nonisolated func collectStepItems(from blocks: [DocBlock], paragraphFallback: Bool = true) -> [StepItem] { + var result: [StepItem] = [] + var autoNumber = 1 + + for block in blocks { + switch block { + case .stepList(let steps): + result.append(contentsOf: steps) + autoNumber = max(autoNumber, (steps.last?.number ?? 0) + 1) + case .paragraph(let spans) where paragraphFallback && result.isEmpty: + result.append(StepItem(number: autoNumber, spans: spans)) + autoNumber += 1 + case .subsection(_, let subblocks) where result.isEmpty: + let nested = collectStepItems(from: subblocks, paragraphFallback: paragraphFallback) + if !nested.isEmpty { + result.append(contentsOf: nested) + autoNumber = max(autoNumber, (nested.last?.number ?? 0) + 1) + } + default: + break + } + } + + return result + } + + private static nonisolated func defaultPairingSummary(identity: DeviceDocIdentity, hasSteps: Bool) -> [InlineSpan] { + if hasSteps { + return [.text("Follow the steps below to pair the \(identity.model) with Zigbee2MQTT.")] + } else { + return [.text("Pairing guidance for the \(identity.model) is limited. Review the notes below for device-specific instructions.")] + } + } + + static nonisolated let prerequisiteKeywords = [ + "coordinator", "adapter", "wake", "awake", "close to", "battery", "install code", + "permit join", "bridge", "factory reset" + ] + static nonisolated let successKeywords = [ + "when connected", "turns off", "flash", "flashes", "blink", "blinks", "pulsate", + "joined", "success", "light turns", "beep" + ] + static nonisolated let troubleshootingKeywords = [ + "troubleshooting", "issue", "doesn't", "didn't", "retry", "remove the device", + "join it again", "re-pair", "not work", "work around" + ] +} diff --git a/Shellbee/Core/Parsing/DeviceDocNormalizer.swift b/Shellbee/Core/Parsing/DeviceDocNormalizer.swift index 803cd71..3d58f7f 100644 --- a/Shellbee/Core/Parsing/DeviceDocNormalizer.swift +++ b/Shellbee/Core/Parsing/DeviceDocNormalizer.swift @@ -1,119 +1,5 @@ import Foundation -struct DeviceDocumentation: Sendable { - let sourcePath: String - let parsed: ParsedDeviceDoc - let normalized: NormalizedDeviceDoc -} - -struct NormalizedDeviceDoc: Sendable { - let identity: DeviceDocIdentity - let pairing: DevicePairingGuide? - let capabilities: [DeviceDocCapability] - let options: [DocOption] - let notesSections: [DocSection] - let advancedSections: [DocSection] - let miscSections: [DocSection] - let quality: Quality - - enum Quality: Sendable, Equatable { - case fullyNormalized - case partiallyNormalized - case parsedOnly - } - - var additionalSections: [DocSection] { advancedSections + miscSections } - var hasSemanticContent: Bool { - pairing != nil || !capabilities.isEmpty || !options.isEmpty || !notesSections.isEmpty - } -} - -struct DeviceDocIdentity: Sendable { - let vendor: String - let model: String - let description: String - let imageURL: URL? - let supportsOTA: Bool - let exposesSummary: String? -} - -struct DevicePairingGuide: Sendable { - let summary: [InlineSpan] - let prerequisites: [[InlineSpan]] - let primarySteps: [StepItem] - let alternatives: [DevicePairingMethod] - let successCues: [[InlineSpan]] - let troubleshooting: [[InlineSpan]] - let additionalNotes: [DocBlock] - - nonisolated var hasContent: Bool { - !summary.isEmpty - || !prerequisites.isEmpty - || !primarySteps.isEmpty - || !alternatives.isEmpty - || !successCues.isEmpty - || !troubleshooting.isEmpty - || !additionalNotes.isEmpty - } -} - -struct DevicePairingMethod: Sendable, Identifiable { - let id: UUID - let title: String - let summary: [InlineSpan] - let steps: [StepItem] - let notes: [DocBlock] - /// True when this alternative is purely a reference to the Touchlink guide with no - /// device-specific steps. The UI replaces the generic card with an in-app Touchlink button. - let isTouchlinkReset: Bool - /// True when this alternative describes a Philips Hue serial-number factory reset. - /// The UI replaces the raw Z2M content with an in-app Philips Hue Reset action. - let isPhilipsHueSerialReset: Bool - - nonisolated init(title: String, summary: [InlineSpan] = [], steps: [StepItem] = [], notes: [DocBlock] = [], isTouchlinkReset: Bool = false, isPhilipsHueSerialReset: Bool = false) { - self.id = UUID() - self.title = title - self.summary = summary - self.steps = steps - self.notes = notes - self.isTouchlinkReset = isTouchlinkReset - self.isPhilipsHueSerialReset = isPhilipsHueSerialReset - } -} - -struct DeviceDocCapability: Sendable, Identifiable { - let id: UUID - let title: String - let subtitle: String? - let summary: String - let kind: String - let unit: String? - let isReadable: Bool - let isWritable: Bool - let detailChips: [String] - - nonisolated init( - title: String, - subtitle: String? = nil, - summary: String, - kind: String, - unit: String? = nil, - isReadable: Bool, - isWritable: Bool, - detailChips: [String] = [] - ) { - self.id = UUID() - self.title = title - self.subtitle = subtitle - self.summary = summary - self.kind = kind - self.unit = unit - self.isReadable = isReadable - self.isWritable = isWritable - self.detailChips = detailChips - } -} - enum DeviceDocNormalizer { static nonisolated func normalize(parsed: ParsedDeviceDoc, device: Device) -> NormalizedDeviceDoc { // The Z2M device definition is authoritative for connected devices, but catalog @@ -214,7 +100,7 @@ enum DeviceDocNormalizer { ) } - private static nonisolated func normalizeTitle(_ title: String) -> String { + static nonisolated func normalizeTitle(_ title: String) -> String { title .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() @@ -272,158 +158,7 @@ enum DeviceDocNormalizer { } } - private static nonisolated func extractFromNotes(_ section: DocSection) -> (pairingBlocks: [DocBlock], pairingRelatedBlocks: [DocBlock], noteBlocks: [DocBlock]) { - var pairingBlocks: [DocBlock] = [] - var pairingRelatedBlocks: [DocBlock] = [] - var noteBlocks: [DocBlock] = [] - - for block in section.blocks { - switch block { - case .subsection(let title, let blocks): - let normalizedTitle = normalizeTitle(title) - if normalizedTitle == "pairing" { - pairingBlocks.append(contentsOf: blocks) - } else if isPairingAdjacentTitle(normalizedTitle) { - pairingRelatedBlocks.append(.subsection(title: title, blocks: blocks)) - } else { - noteBlocks.append(.subsection(title: title, blocks: blocks)) - } - default: - noteBlocks.append(block) - } - } - - return (pairingBlocks, pairingRelatedBlocks, noteBlocks) - } - - private static nonisolated func makePairingGuide( - from pairingBlocks: [DocBlock], - relatedBlocks: [DocBlock], - identity: DeviceDocIdentity - ) -> DevicePairingGuide? { - // Only count steps that came from real step-list blocks, not paragraph fallback. - // Paragraph fallback produces step text identical to the summary, which would show twice. - let primarySteps = collectStepItems(from: pairingBlocks, paragraphFallback: false) - let summary = firstParagraph(in: pairingBlocks) ?? defaultPairingSummary(identity: identity, hasSteps: !primarySteps.isEmpty) - let summaryText = plainText(summary) - - // Exclude summary so it doesn't also appear in Before You Start / Success / Troubleshooting - let paragraphs = collectParagraphSpans(from: pairingBlocks + relatedBlocks) - .filter { plainText($0) != summaryText } - - let prerequisites = unique(paragraphs.filter { matchesAny($0, keywords: prerequisiteKeywords) }) - let successCues = unique(paragraphs.filter { matchesAny($0, keywords: successKeywords) }) - let troubleshooting = unique(paragraphs.filter { matchesAny($0, keywords: troubleshootingKeywords) }) - - // Track every plain-text span already shown in a named section so additionalNotes never repeats them - var usedTexts = Set([summaryText]) - for spans in prerequisites + successCues + troubleshooting { - usedTexts.insert(plainText(spans)) - } - - let alternatives = collectSubsections(from: pairingBlocks) - .filter { normalizeTitle($0.title) != "pairing" } - .map { subsection in - let altSummary = firstParagraph(in: subsection.blocks) ?? [] - let altSummaryText = plainText(altSummary) - let altSteps = collectStepItems(from: subsection.blocks, paragraphFallback: false) - let altNotes = subsection.blocks.filter { block in - guard !isPureStepList(block) else { return false } - if case .paragraph(let spans) = block { return plainText(spans) != altSummaryText } - return true - } - let normalizedSubtitle = normalizeTitle(subsection.title) - // A subsection is a pure Touchlink reference when its title mentions Touchlink and it - // contains no device-specific steps or notes — just a link to the Touchlink guide. - let isTouchlinkReset = normalizedSubtitle.contains("touchlink") - && altSteps.isEmpty - && altNotes.isEmpty - // A subsection describes a Philips Hue serial-number reset when it mentions - // "touchlink" and "serial". The Z2M docs include raw JSON and frontend references - // for this flow which the app replaces with the in-app Philips Hue Reset action. - let isPhilipsHueSerialReset = normalizedSubtitle.contains("touchlink") - && normalizedSubtitle.contains("serial") - return DevicePairingMethod( - title: subsection.title, - summary: altSummary, - steps: altSteps, - notes: altNotes, - isTouchlinkReset: isTouchlinkReset, - isPhilipsHueSerialReset: isPhilipsHueSerialReset - ) - } - .filter { !$0.summary.isEmpty || !$0.steps.isEmpty || !$0.notes.isEmpty || $0.isTouchlinkReset || $0.isPhilipsHueSerialReset } - - // Subsection titles promoted to Alternatives — skip them in additionalNotes - let alternativeTitles = Set(alternatives.map { normalizeTitle($0.title) }) - - let additionalNotes = (pairingBlocks + relatedBlocks).filter { block in - switch block { - case .paragraph(let spans), .note(let spans): - return !usedTexts.contains(plainText(spans)) - case .stepList: - return false - case .subsection(let title, _): - return !alternativeTitles.contains(normalizeTitle(title)) - default: - return true - } - } - - let guide = DevicePairingGuide( - summary: summary, - prerequisites: prerequisites, - primarySteps: primarySteps, - alternatives: alternatives, - successCues: successCues, - troubleshooting: troubleshooting, - additionalNotes: additionalNotes - ) - - return guide.hasContent ? guide : nil - } - - private static nonisolated func collectSubsections(from blocks: [DocBlock]) -> [(title: String, blocks: [DocBlock])] { - blocks.compactMap { block in - if case .subsection(let title, let blocks) = block { - return (title, blocks) - } - return nil - } - } - - private static nonisolated func isPureStepList(_ block: DocBlock) -> Bool { - if case .stepList = block { return true } - return false - } - - private static nonisolated func collectStepItems(from blocks: [DocBlock], paragraphFallback: Bool = true) -> [StepItem] { - var result: [StepItem] = [] - var autoNumber = 1 - - for block in blocks { - switch block { - case .stepList(let steps): - result.append(contentsOf: steps) - autoNumber = max(autoNumber, (steps.last?.number ?? 0) + 1) - case .paragraph(let spans) where paragraphFallback && result.isEmpty: - result.append(StepItem(number: autoNumber, spans: spans)) - autoNumber += 1 - case .subsection(_, let subblocks) where result.isEmpty: - let nested = collectStepItems(from: subblocks, paragraphFallback: paragraphFallback) - if !nested.isEmpty { - result.append(contentsOf: nested) - autoNumber = max(autoNumber, (nested.last?.number ?? 0) + 1) - } - default: - break - } - } - - return result - } - - private static nonisolated func firstParagraph(in blocks: [DocBlock]) -> [InlineSpan]? { + static nonisolated func firstParagraph(in blocks: [DocBlock]) -> [InlineSpan]? { for block in blocks { switch block { case .paragraph(let spans): @@ -439,7 +174,7 @@ enum DeviceDocNormalizer { return nil } - private static nonisolated func collectParagraphSpans(from blocks: [DocBlock]) -> [[InlineSpan]] { + static nonisolated func collectParagraphSpans(from blocks: [DocBlock]) -> [[InlineSpan]] { var result: [[InlineSpan]] = [] for block in blocks { switch block { @@ -456,7 +191,7 @@ enum DeviceDocNormalizer { return result } - private static nonisolated func unique(_ items: [[InlineSpan]]) -> [[InlineSpan]] { + static nonisolated func unique(_ items: [[InlineSpan]]) -> [[InlineSpan]] { var seen = Set() return items.filter { spans in let key = plainText(spans) @@ -465,7 +200,7 @@ enum DeviceDocNormalizer { } } - private static nonisolated func plainText(_ spans: [InlineSpan]) -> String { + static nonisolated func plainText(_ spans: [InlineSpan]) -> String { spans.map { span in switch span { case .text(let text), .bold(let text), .italic(let text), .boldItalic(let text), .code(let text): @@ -479,19 +214,11 @@ enum DeviceDocNormalizer { .lowercased() } - private static nonisolated func matchesAny(_ spans: [InlineSpan], keywords: [String]) -> Bool { + static nonisolated func matchesAny(_ spans: [InlineSpan], keywords: [String]) -> Bool { let text = plainText(spans) return keywords.contains { text.contains($0) } } - private static nonisolated func defaultPairingSummary(identity: DeviceDocIdentity, hasSteps: Bool) -> [InlineSpan] { - if hasSteps { - return [.text("Follow the steps below to pair the \(identity.model) with Zigbee2MQTT.")] - } else { - return [.text("Pairing guidance for the \(identity.model) is limited. Review the notes below for device-specific instructions.")] - } - } - private static nonisolated func makeCapabilities(from exposes: [Expose]) -> [DeviceDocCapability] { exposes.flattened.map { expose in let title = expose.label ?? expose.name ?? expose.property ?? expose.type.capitalized @@ -621,7 +348,7 @@ enum DeviceDocNormalizer { || title.contains("warning") } - private static nonisolated func isPairingAdjacentTitle(_ title: String) -> Bool { + static nonisolated func isPairingAdjacentTitle(_ title: String) -> Bool { title.contains("troubleshooting") || title.contains("factory reset") || title.contains("install code") @@ -631,16 +358,4 @@ enum DeviceDocNormalizer { || title.contains("pair") } - private static nonisolated let prerequisiteKeywords = [ - "coordinator", "adapter", "wake", "awake", "close to", "battery", "install code", - "permit join", "bridge", "factory reset" - ] - private static nonisolated let successKeywords = [ - "when connected", "turns off", "flash", "flashes", "blink", "blinks", "pulsate", - "joined", "success", "light turns", "beep" - ] - private static nonisolated let troubleshootingKeywords = [ - "troubleshooting", "issue", "doesn't", "didn't", "retry", "remove the device", - "join it again", "re-pair", "not work", "work around" - ] } diff --git a/Shellbee/Core/Parsing/DeviceDocumentationModels.swift b/Shellbee/Core/Parsing/DeviceDocumentationModels.swift new file mode 100644 index 0000000..3e910e9 --- /dev/null +++ b/Shellbee/Core/Parsing/DeviceDocumentationModels.swift @@ -0,0 +1,119 @@ +import Foundation + +/// Top-level container pairing the raw `ParsedDeviceDoc` (from `DocParser`) +/// with the higher-level `NormalizedDeviceDoc` (from `DeviceDocNormalizer`). +/// `sourcePath` is the path that produced the document, used for resolving +/// relative image links. +struct DeviceDocumentation: Sendable { + let sourcePath: String + let parsed: ParsedDeviceDoc + let normalized: NormalizedDeviceDoc +} + +struct NormalizedDeviceDoc: Sendable { + let identity: DeviceDocIdentity + let pairing: DevicePairingGuide? + let capabilities: [DeviceDocCapability] + let options: [DocOption] + let notesSections: [DocSection] + let advancedSections: [DocSection] + let miscSections: [DocSection] + let quality: Quality + + enum Quality: Sendable, Equatable { + case fullyNormalized + case partiallyNormalized + case parsedOnly + } + + var additionalSections: [DocSection] { advancedSections + miscSections } + var hasSemanticContent: Bool { + pairing != nil || !capabilities.isEmpty || !options.isEmpty || !notesSections.isEmpty + } +} + +struct DeviceDocIdentity: Sendable { + let vendor: String + let model: String + let description: String + let imageURL: URL? + let supportsOTA: Bool + let exposesSummary: String? +} + +struct DevicePairingGuide: Sendable { + let summary: [InlineSpan] + let prerequisites: [[InlineSpan]] + let primarySteps: [StepItem] + let alternatives: [DevicePairingMethod] + let successCues: [[InlineSpan]] + let troubleshooting: [[InlineSpan]] + let additionalNotes: [DocBlock] + + nonisolated var hasContent: Bool { + !summary.isEmpty + || !prerequisites.isEmpty + || !primarySteps.isEmpty + || !alternatives.isEmpty + || !successCues.isEmpty + || !troubleshooting.isEmpty + || !additionalNotes.isEmpty + } +} + +struct DevicePairingMethod: Sendable, Identifiable { + let id: UUID + let title: String + let summary: [InlineSpan] + let steps: [StepItem] + let notes: [DocBlock] + /// True when this alternative is purely a reference to the Touchlink guide with no + /// device-specific steps. The UI replaces the generic card with an in-app Touchlink button. + let isTouchlinkReset: Bool + /// True when this alternative describes a Philips Hue serial-number factory reset. + /// The UI replaces the raw Z2M content with an in-app Philips Hue Reset action. + let isPhilipsHueSerialReset: Bool + + nonisolated init(title: String, summary: [InlineSpan] = [], steps: [StepItem] = [], notes: [DocBlock] = [], isTouchlinkReset: Bool = false, isPhilipsHueSerialReset: Bool = false) { + self.id = UUID() + self.title = title + self.summary = summary + self.steps = steps + self.notes = notes + self.isTouchlinkReset = isTouchlinkReset + self.isPhilipsHueSerialReset = isPhilipsHueSerialReset + } +} + +struct DeviceDocCapability: Sendable, Identifiable { + let id: UUID + let title: String + let subtitle: String? + let summary: String + let kind: String + let unit: String? + let isReadable: Bool + let isWritable: Bool + let detailChips: [String] + + nonisolated init( + title: String, + subtitle: String? = nil, + summary: String, + kind: String, + unit: String? = nil, + isReadable: Bool, + isWritable: Bool, + detailChips: [String] = [] + ) { + self.id = UUID() + self.title = title + self.subtitle = subtitle + self.summary = summary + self.kind = kind + self.unit = unit + self.isReadable = isReadable + self.isWritable = isWritable + self.detailChips = detailChips + } +} diff --git a/Shellbee/Core/Store/AppStore+Devices.swift b/Shellbee/Core/Store/AppStore+Devices.swift new file mode 100644 index 0000000..46ff3bc --- /dev/null +++ b/Shellbee/Core/Store/AppStore+Devices.swift @@ -0,0 +1,49 @@ +import Foundation + +extension AppStore { + func device(named friendlyName: String) -> Device? { + devices.first { $0.friendlyName == friendlyName } + } + + func state(for friendlyName: String) -> [String: JSONValue] { + deviceStates[friendlyName] ?? [:] + } + + func isAvailable(_ friendlyName: String) -> Bool { + deviceAvailability[friendlyName] ?? false + } + + /// Apply a rename to local state immediately so the UI updates without + /// waiting for the bridge/devices snapshot (which can lag 3-10s after a + /// bridge/request/device/rename). Migrates availability and state keys so + /// the renamed device doesn't flicker through "offline". + func optimisticRename(from: String, to: String) { + guard from != to, !to.isEmpty else { return } + guard let idx = devices.firstIndex(where: { $0.friendlyName == from }) else { return } + var device = devices[idx] + device.friendlyName = to + devices[idx] = device + + if let availability = deviceAvailability.removeValue(forKey: from) { + deviceAvailability[to] = availability + } + if let state = deviceStates.removeValue(forKey: from) { + deviceStates[to] = state + } + pendingRenames.append((from: from, to: to)) + } + + func revertOptimisticRename(from: String, to: String) { + guard let idx = devices.firstIndex(where: { $0.friendlyName == to }) else { return } + var device = devices[idx] + device.friendlyName = from + devices[idx] = device + + if let availability = deviceAvailability.removeValue(forKey: to) { + deviceAvailability[from] = availability + } + if let state = deviceStates.removeValue(forKey: to) { + deviceStates[from] = state + } + } +} diff --git a/Shellbee/Core/Store/AppStore+Events.swift b/Shellbee/Core/Store/AppStore+Events.swift new file mode 100644 index 0000000..cc5f8bf --- /dev/null +++ b/Shellbee/Core/Store/AppStore+Events.swift @@ -0,0 +1,275 @@ +import Foundation + +extension AppStore { + func apply(_ event: Z2MEvent) { + switch event { + case .bridgeInfo(let info): + bridgeInfo = info + case .bridgeState(let state): + bridgeOnline = state == "online" + case .devices(let list): + // Backfill first-seen for any device we've never recorded. + // Covers the case where a device joined while the app was closed + // and we missed the bridge/event device_joined message — when it + // shows up in interview state on the first snapshot, treat it as + // freshly added. + for device in list where device.type != .coordinator { + guard deviceFirstSeen[device.ieeeAddress] == nil else { continue } + if device.interviewing || !device.interviewCompleted { + recordFirstSeen(ieee: device.ieeeAddress) + } + } + devices = list + case .groups(let list): + groups = list + case .logMessage(let msg): + let level = LogLevel(raw: msg.level) ?? .info + insertRawLogEntry(LogEntry( + id: msg.id, timestamp: .now, level: level, + category: .general, namespace: msg.namespace, + message: msg.message, deviceName: nil + )) + let knownNames = Set(devices.map(\.friendlyName) + groups.map(\.friendlyName)) + let ctx = LogMapperEngine.context( + message: msg.message, namespace: msg.namespace, knownDevices: knownNames + ) + // MQTT publish for a known device/group state topic is redundant — the + // .deviceState event creates a richer stateChange entry for the same update. + if case .mqttPublish = ctx.action, + let deviceName = ctx.primaryDevice?.friendlyName, + knownNames.contains(deviceName) { + break + } + let entry = LogEntry( + id: msg.id, timestamp: .now, level: level, + category: ctx.inferredCategory, + namespace: msg.namespace, message: msg.message, + deviceName: ctx.primaryDevice?.friendlyName, context: ctx + ) + insertLogEntry(entry) + if let note = notification(for: ctx.action, level: level, deviceName: ctx.primaryDevice?.friendlyName, message: msg.message, id: msg.id) { + enqueueNotification(note) + } + case .bridgeEvent(let event): + if let entry = Self.logEntry(from: event) { + insertLogEntry(entry) + if let note = Self.notification(from: event, entry: entry) { + enqueueNotification(note) + } + } + if let ieee = event.data.object?["ieee_address"]?.stringValue { + switch event.type { + case "device_joined": + // Restart the 30-min window on (re)join. + recordFirstSeen(ieee: ieee, overwrite: true) + case "device_leave": + removeFirstSeen(ieee: ieee) + case "device_interview": + let name = event.data.object?["friendly_name"]?.stringValue ?? ieee + let status = event.data.object?["status"]?.stringValue + Task { @MainActor in + switch status { + case "started": + InterviewLiveActivityCoordinator.shared.start(deviceName: name, ieeeAddress: ieee) + case "successful": + InterviewLiveActivityCoordinator.shared.finish(deviceName: name, ieeeAddress: ieee, success: true) + case "failed": + InterviewLiveActivityCoordinator.shared.finish(deviceName: name, ieeeAddress: ieee, success: false) + default: + break + } + } + default: + break + } + } + case .deviceState(let name, let state): + let previous = deviceStates[name] ?? [:] + if !previous.isEmpty { + let changes = LogMapperEngine.diff(previous, state) + if !changes.isEmpty { + insertLogEntry(LogMapperEngine.stateChangeEntry(device: name, changes: changes)) + } + } + deviceStates[name] = state + handleOTAState(for: name, state: state) + case .deviceAvailability(let name, let available): + deviceAvailability[name] = available + case .deviceOTAUpdateResponse(let response): + handleOTAResponse(response) + case .deviceOTACheckResponse(let response): + handleOTACheckResponse(response) + case .permitJoinChanged(let enabled, let remaining): + if let info = bridgeInfo { + bridgeInfo = BridgeInfo( + version: info.version, + commit: info.commit, + coordinator: info.coordinator, + network: info.network, + logLevel: info.logLevel, + permitJoin: enabled, + permitJoinTimeout: remaining, + permitJoinEnd: remaining.map { Int(Date().timeIntervalSince1970 * 1000) + ($0 * 1000) }, + restartRequired: info.restartRequired, + config: info.config + ) + } + + case .bridgeResponse(let topic, let payload): + if topic == Z2MTopics.bridgeResponseBackup, let handler = backupResponseHandler { + backupResponseHandler = nil + if payload.object?["status"]?.stringValue == "ok", + let zip = payload.object?["data"]?.object?["zip"]?.stringValue { + handler(zip, nil) + } else { + let err = payload.object?["error"]?.stringValue ?? "Unknown error" + handler(nil, err) + } + break + } + // The options/info responses carry only `{restart_required}` (and + // echo the request on error). The full config is delivered via the + // separate `bridge/info` topic, so don't try to decode config here + // — doing so would overwrite the real config with all-nils. + guard payload.object?["status"]?.stringValue == "ok" else { break } + if let restartRequired = payload.object?["data"]?.object?["restart_required"]?.boolValue, + let info = bridgeInfo { + bridgeInfo = info.copyUpdating(restartRequired: restartRequired) + } + + case .bridgeHealth(let health): + if let existing = bridgeHealth, health.process == nil { + // Sparse response (e.g. bridge/response/health_check returns only {healthy:true}) + // Merge: preserve rich stats, update the healthy flag + bridgeHealth = BridgeHealth( + healthy: health.healthy, + responseTime: existing.responseTime, + process: existing.process, + os: existing.os, + mqtt: existing.mqtt + ) + } else { + bridgeHealth = health + } + + case .touchlinkScanResult(let devices): + touchlinkDevices = devices + touchlinkScanInProgress = false + + case .touchlinkIdentifyDone: + touchlinkIdentifyInProgress = false + + case .touchlinkFactoryResetDone: + touchlinkResetInProgress = false + + case .deviceRemoveResponse(let id, let ok, let errorMessage): + pendingRemovals.remove(id) + if ok { + // Remove locally so the next bridge/devices snapshot doesn't + // race with our List diff. Also clears keyed state so the + // "Recently Added" backfill doesn't resurrect it. + devices.removeAll { $0.friendlyName == id } + deviceStates.removeValue(forKey: id) + deviceAvailability.removeValue(forKey: id) + otaUpdates.removeValue(forKey: id) + deviceCheckResults.removeValue(forKey: id) + } else { + let message = errorMessage ?? "Failed to remove '\(id)'" + let error = Z2MOperationError( + id: UUID(), + topic: Z2MTopics.bridgeResponseDeviceRemove, + message: message, + timestamp: .now + ) + apply(.operationError(error)) + } + + case .deviceRenameResponse(let from, let to, let ok, let errorMessage): + if let pendingIdx = pendingRenames.firstIndex(where: { $0.from == from && $0.to == to }) { + pendingRenames.remove(at: pendingIdx) + } + if !ok { + revertOptimisticRename(from: from, to: to) + let message = errorMessage ?? "Failed to rename '\(from)' to '\(to)'" + let error = Z2MOperationError( + id: UUID(), + topic: Z2MTopics.bridgeResponseDeviceRename, + message: message, + timestamp: .now + ) + apply(.operationError(error)) + } + + case .operationError(let error): + touchlinkScanInProgress = false + touchlinkIdentifyInProgress = false + touchlinkResetInProgress = false + operationErrors.insert(error, at: 0) + let entry = LogEntry( + id: UUID(), + timestamp: error.timestamp, + level: .error, + category: .general, + namespace: "z2m:response", + message: error.message, + deviceName: nil + ) + insertLogEntry(entry) + enqueueNotification(InAppNotification( + level: .error, + title: "Operation Failed", + subtitle: stripped(String(error.message.prefix(100))), + logEntryID: entry.id, + category: .operationFailed + )) + + case .unknown: + break + } + } + + // MARK: - Static helpers for bridge events + + static func logEntry(from event: BridgeDeviceEvent) -> LogEntry? { + guard let data = event.data.object else { return nil } + let deviceName = data["friendly_name"]?.stringValue + let ieeeAddr = data["ieee_address"]?.stringValue + let name = deviceName ?? ieeeAddr ?? "unknown" + + switch event.type { + case "device_joined": + return LogEntry(id: UUID(), timestamp: .now, level: .info, category: .deviceJoined, namespace: nil, message: "Device '\(name)' joined the network", deviceName: deviceName) + case "device_announce": + return LogEntry(id: UUID(), timestamp: .now, level: .info, category: .deviceAnnounce, namespace: nil, message: "Device '\(name)' announced", deviceName: deviceName) + case "device_interview": + let status = data["status"]?.stringValue ?? "unknown" + let level: LogLevel = status == "failed" ? .error : .info + return LogEntry(id: UUID(), timestamp: .now, level: level, category: .interview, namespace: nil, message: "Interview of '\(name)' \(status)", deviceName: deviceName) + case "device_leave": + return LogEntry(id: UUID(), timestamp: .now, level: .warning, category: .deviceLeave, namespace: nil, message: "Device '\(name)' left the network", deviceName: deviceName) + default: + return nil + } + } + + static func notification(from event: BridgeDeviceEvent, entry: LogEntry) -> InAppNotification? { + switch event.type { + case "device_leave": + return InAppNotification(level: .warning, title: "Device Left Network", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .deviceLeft) + case "device_interview": + let status = event.data.object?["status"]?.stringValue ?? "unknown" + switch status { + case "started": + return InAppNotification(level: .info, title: "Interviewing Device", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .interviewStarted) + case "successful": + return InAppNotification(level: .info, title: "Interview Successful", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .interviewSuccessful) + case "failed": + return InAppNotification(level: .error, title: "Interview Failed", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .interviewFailed) + default: + return nil + } + default: + return nil + } + } +} diff --git a/Shellbee/Core/Store/AppStore+Logs.swift b/Shellbee/Core/Store/AppStore+Logs.swift new file mode 100644 index 0000000..d6745e7 --- /dev/null +++ b/Shellbee/Core/Store/AppStore+Logs.swift @@ -0,0 +1,22 @@ +import Foundation + +extension AppStore { + func clearLogs() { + logEntries = [] + rawLogEntries = [] + } + + func insertLogEntry(_ entry: LogEntry) { + logEntries.insert(entry, at: 0) + if logEntries.count > Self.logLimit { + logEntries = Array(logEntries.prefix(Self.logLimit)) + } + } + + func insertRawLogEntry(_ entry: LogEntry) { + rawLogEntries.insert(entry, at: 0) + if rawLogEntries.count > Self.logLimit { + rawLogEntries = Array(rawLogEntries.prefix(Self.logLimit)) + } + } +} diff --git a/Shellbee/Core/Store/AppStore+Notifications.swift b/Shellbee/Core/Store/AppStore+Notifications.swift new file mode 100644 index 0000000..142f958 --- /dev/null +++ b/Shellbee/Core/Store/AppStore+Notifications.swift @@ -0,0 +1,109 @@ +import Foundation + +extension AppStore { + func popNotification() -> InAppNotification? { + guard !pendingNotifications.isEmpty else { return nil } + return pendingNotifications.removeFirst() + } + + func popFastTrackNotification() -> InAppNotification? { + guard !fastTrackNotifications.isEmpty else { return nil } + return fastTrackNotifications.removeFirst() + } + + func enqueueOTABulkSummary(_ summary: OTABulkOperationQueue.CompletionSummary) { + let noun = summary.kind == .check ? "Checked" : "Updated" + let level: LogLevel = summary.failed > 0 ? .warning : .info + let title: String + if summary.wasCancelled { + title = summary.kind == .check ? "Check Cancelled" : "Updates Cancelled" + } else if summary.failed > 0 { + title = "\(noun) \(summary.total) Devices" + } else { + title = "\(noun) \(summary.total) Devices" + } + var parts: [String] = [] + if summary.succeeded > 0 { + parts.append("\(summary.succeeded) succeeded") + } + if summary.failed > 0 { + parts.append("\(summary.failed) failed") + } + let subtitle = parts.isEmpty ? nil : parts.joined(separator: ", ") + enqueueNotification(InAppNotification( + level: level, + title: title, + subtitle: subtitle, + category: .otaBulkSummary + )) + } + + func enqueueNotification(_ notification: InAppNotification) { + // Fast-track bypasses the filter — these are transient confirmations + // (e.g. "Copied to Clipboard") driven by the user's own action. + if notification.priority == .fastTrack { + fastTrackNotifications.append(notification) + return + } + + if let filter = notificationFilter, !filter(notification) { return } + + let now = Date() + + if let idx = pendingNotifications.lastIndex(where: { $0.coalesceKey == notification.coalesceKey }), + now.timeIntervalSince(pendingNotifications[idx].lastUpdated) <= Self.coalesceWindow { + pendingNotifications[idx].count += notification.count + pendingNotifications[idx].logEntryIDs.append(contentsOf: notification.logEntryIDs) + pendingNotifications[idx].occurrences.append(contentsOf: notification.occurrences) + if let sub = notification.subtitle { pendingNotifications[idx].subtitle = sub } + pendingNotifications[idx].lastUpdated = now + return + } + + pendingNotifications.append(notification) + notificationArrivalID = UUID() + } + + func notification( + for action: LogContext.LogAction, level: LogLevel, + deviceName: String?, message: String, id: UUID + ) -> InAppNotification? { + let truncated = stripped(String(message.prefix(100))) + switch action { + case .bindSuccess: + return InAppNotification(level: .info, title: "Bind Successful", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .bindSuccess) + case .bindFailure: + return InAppNotification(level: .error, title: "Bind Failed", subtitle: deviceName ?? truncated, logEntryID: id, deviceName: deviceName, category: .bindFailure) + case .unbind: + return InAppNotification(level: .info, title: "Unbound", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .unbind) + case .groupAdd: + return InAppNotification(level: .info, title: "Added to Group", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .groupAdd) + case .groupRemove: + return InAppNotification(level: .info, title: "Removed from Group", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .groupRemove) + case .publishFailure(let command): + let detail = command.isEmpty ? truncated : command + return InAppNotification(level: .error, title: "Command Failed", subtitle: detail, logEntryID: id, deviceName: deviceName, category: .publishFailure) + case .requestFailure: + return InAppNotification(level: .error, title: "Request Failed", subtitle: truncated, logEntryID: id, deviceName: deviceName, category: .requestFailure) + case .otaFinished: + return InAppNotification(level: .info, title: "Update Installed", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .otaUpdateInstalled) + case .reportingConfigure: + return InAppNotification(level: .info, title: "Reporting Configured", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .reportingConfigure) + case .general where level == .error: + return InAppNotification(level: .error, title: "Error", subtitle: truncated, logEntryID: id, deviceName: deviceName, category: .genericError) + default: + return nil + } + } + + /// Z2M log messages sometimes embed their namespace at the start + /// ("z2m:controller Something failed"). Strip it so notifications show + /// only the human-readable part. + func stripped(_ text: String) -> String { + guard text.hasPrefix("z2m:") else { return text } + if let spaceRange = text.range(of: " ") { + return String(text[spaceRange.upperBound...]) + } + return text + } +} diff --git a/Shellbee/Core/Store/AppStore+OTA.swift b/Shellbee/Core/Store/AppStore+OTA.swift new file mode 100644 index 0000000..ef76445 --- /dev/null +++ b/Shellbee/Core/Store/AppStore+OTA.swift @@ -0,0 +1,140 @@ +import Foundation + +extension AppStore { + var activeOTAUpdates: [OTAUpdateStatus] { + otaUpdates.values.filter(\.isActive) + } + + func otaStatus(for friendlyName: String) -> OTAUpdateStatus? { + otaUpdates[friendlyName] ?? state(for: friendlyName).otaUpdateStatus(for: friendlyName) + } + + func startOTAUpdate(for friendlyName: String) { + otaUpdates[friendlyName] = OTAUpdateStatus( + deviceName: friendlyName, + phase: .requested, + progress: nil, + remaining: nil + ) + OTAUpdateLiveActivityCoordinator.shared.sync(with: activeOTAUpdates, devices: devices) + } + + func startOTACheck(for friendlyName: String) { + otaUpdates[friendlyName] = OTAUpdateStatus( + deviceName: friendlyName, + phase: .checking, + progress: nil, + remaining: nil + ) + } + + func startOTASchedule(for friendlyName: String) { + otaUpdates[friendlyName] = OTAUpdateStatus( + deviceName: friendlyName, + phase: .scheduled, + progress: nil, + remaining: nil + ) + } + + func cancelOTASchedule(for friendlyName: String) { + if otaUpdates[friendlyName]?.phase == .scheduled { + otaUpdates.removeValue(forKey: friendlyName) + } + } + + func handleOTAState(for deviceName: String, state: [String: JSONValue]) { + guard let update = state.otaUpdateStatus(for: deviceName) else { return } + + let previous = otaUpdates[deviceName] + + switch update.phase { + case .available: + if previous?.isActive == true { + otaUpdates.removeValue(forKey: deviceName) + } + case .checking: + break // Handled by manual check trigger + case .requested: + otaUpdates[deviceName] = update + case .scheduled, .updating: + otaUpdates[deviceName] = update + case .idle: + otaUpdates.removeValue(forKey: deviceName) + if previous?.isActive == true, activeOTAUpdates.isEmpty { + OTAUpdateLiveActivityCoordinator.shared.finish(for: deviceName, success: true) + return + } + } + + OTAUpdateLiveActivityCoordinator.shared.sync(with: activeOTAUpdates, devices: devices) + } + + func handleOTAResponse(_ response: DeviceOTAUpdateResponse) { + if let deviceName = response.deviceName { + otaResponseForwarding?(deviceName, response.isSuccess, .update) + } + guard !response.isSuccess else { return } + guard let deviceName = response.deviceName else { return } + + otaUpdates.removeValue(forKey: deviceName) + + if activeOTAUpdates.isEmpty { + OTAUpdateLiveActivityCoordinator.shared.finish(for: deviceName, success: false) + } else { + OTAUpdateLiveActivityCoordinator.shared.sync(with: activeOTAUpdates, devices: devices) + } + } + + func handleOTACheckResponse(_ response: DeviceOTAUpdateResponse) { + guard let deviceName = response.deviceName else { return } + + otaResponseForwarding?(deviceName, response.isSuccess, .check) + + if !response.isSuccess { + flashCheckResult(.failed, for: deviceName) + Task { + try? await Task.sleep(for: .seconds(DesignTokens.Duration.liveActivitySuccess)) + await MainActor.run { + if otaUpdates[deviceName]?.phase == .checking { + otaUpdates.removeValue(forKey: deviceName) + } + } + } + } else { + if otaUpdates[deviceName]?.phase == .checking { + otaUpdates.removeValue(forKey: deviceName) + } + // The subsequent deviceState event decides whether an update was + // found. Look at current state: if hasUpdateAvailable, "Found + // update"; otherwise "No update" (off by default as a + // notification, but always shown as a transient row chip). + let hasUpdate = state(for: deviceName).hasUpdateAvailable + if hasUpdate { + flashCheckResult(.updateFound, for: deviceName) + } else { + flashCheckResult(.noUpdate, for: deviceName) + enqueueNotification(InAppNotification( + level: .info, + title: "No Update Available", + subtitle: deviceName, + deviceName: deviceName, + category: .otaNoUpdate + )) + } + } + } + + func flashCheckResult(_ result: DeviceCheckResult, for deviceName: String) { + deviceCheckResults[deviceName] = result + Task { [weak self] in + try? await Task.sleep(for: .seconds(DesignTokens.Duration.checkResultDisplay)) + await MainActor.run { + guard let self else { return } + if self.deviceCheckResults[deviceName] == result { + self.deviceCheckResults.removeValue(forKey: deviceName) + } + } + } + } +} diff --git a/Shellbee/Core/Store/AppStore.swift b/Shellbee/Core/Store/AppStore.swift index 6af5e72..20a9c27 100644 --- a/Shellbee/Core/Store/AppStore.swift +++ b/Shellbee/Core/Store/AppStore.swift @@ -17,7 +17,7 @@ final class AppStore { var deviceFirstSeen: [String: Date] = [:] // Optimistic renames awaiting bridge confirmation. Used to roll back if z2m // returns status="error" for the rename request. - private var pendingRenames: [(from: String, to: String)] = [] + var pendingRenames: [(from: String, to: String)] = [] // Friendly names of devices the user has asked to remove and we're awaiting // bridge/response/device/remove for. Drives the "Deleting" badge and // disables further swipe-deletes on the same row. @@ -62,7 +62,7 @@ final class AppStore { } static let logLimit = 1000 - static let coalesceWindow: TimeInterval = 1.5 + static let coalesceWindow: TimeInterval = AppConfig.UX.notificationCoalesceWindow init() { if let raw = UserDefaults.standard.dictionary(forKey: Self.firstSeenStoreKey) as? [String: Double] { @@ -70,500 +70,6 @@ final class AppStore { } } - private func persistFirstSeen() { - let raw = deviceFirstSeen.mapValues { $0.timeIntervalSince1970 } - UserDefaults.standard.set(raw, forKey: Self.firstSeenStoreKey) - } - - private func recordFirstSeen(ieee: String, overwrite: Bool = false) { - if !overwrite, deviceFirstSeen[ieee] != nil { return } - deviceFirstSeen[ieee] = Date() - persistFirstSeen() - } - - private func removeFirstSeen(ieee: String) { - guard deviceFirstSeen.removeValue(forKey: ieee) != nil else { return } - persistFirstSeen() - } - - func apply(_ event: Z2MEvent) { - switch event { - case .bridgeInfo(let info): - bridgeInfo = info - case .bridgeState(let state): - bridgeOnline = state == "online" - case .devices(let list): - // Backfill first-seen for any device we've never recorded. - // Covers the case where a device joined while the app was closed - // and we missed the bridge/event device_joined message — when it - // shows up in interview state on the first snapshot, treat it as - // freshly added. - for device in list where device.type != .coordinator { - guard deviceFirstSeen[device.ieeeAddress] == nil else { continue } - if device.interviewing || !device.interviewCompleted { - recordFirstSeen(ieee: device.ieeeAddress) - } - } - devices = list - case .groups(let list): - groups = list - case .logMessage(let msg): - let level = LogLevel(raw: msg.level) ?? .info - insertRawLogEntry(LogEntry( - id: msg.id, timestamp: .now, level: level, - category: .general, namespace: msg.namespace, - message: msg.message, deviceName: nil - )) - let knownNames = Set(devices.map(\.friendlyName) + groups.map(\.friendlyName)) - let ctx = LogMapperEngine.context( - message: msg.message, namespace: msg.namespace, knownDevices: knownNames - ) - // MQTT publish for a known device/group state topic is redundant — the - // .deviceState event creates a richer stateChange entry for the same update. - if case .mqttPublish = ctx.action, - let deviceName = ctx.primaryDevice?.friendlyName, - knownNames.contains(deviceName) { - break - } - let entry = LogEntry( - id: msg.id, timestamp: .now, level: level, - category: ctx.inferredCategory, - namespace: msg.namespace, message: msg.message, - deviceName: ctx.primaryDevice?.friendlyName, context: ctx - ) - insertLogEntry(entry) - if let note = notification(for: ctx.action, level: level, deviceName: ctx.primaryDevice?.friendlyName, message: msg.message, id: msg.id) { - enqueueNotification(note) - } - case .bridgeEvent(let event): - if let entry = Self.logEntry(from: event) { - insertLogEntry(entry) - if let note = Self.notification(from: event, entry: entry) { - enqueueNotification(note) - } - } - if let ieee = event.data.object?["ieee_address"]?.stringValue { - switch event.type { - case "device_joined": - // Restart the 30-min window on (re)join. - recordFirstSeen(ieee: ieee, overwrite: true) - case "device_leave": - removeFirstSeen(ieee: ieee) - case "device_interview": - let name = event.data.object?["friendly_name"]?.stringValue ?? ieee - let status = event.data.object?["status"]?.stringValue - Task { @MainActor in - switch status { - case "started": - InterviewLiveActivityCoordinator.shared.start(deviceName: name, ieeeAddress: ieee) - case "successful": - InterviewLiveActivityCoordinator.shared.finish(deviceName: name, ieeeAddress: ieee, success: true) - case "failed": - InterviewLiveActivityCoordinator.shared.finish(deviceName: name, ieeeAddress: ieee, success: false) - default: - break - } - } - default: - break - } - } - case .deviceState(let name, let state): - let previous = deviceStates[name] ?? [:] - if !previous.isEmpty { - let changes = LogMapperEngine.diff(previous, state) - if !changes.isEmpty { - insertLogEntry(LogMapperEngine.stateChangeEntry(device: name, changes: changes)) - } - } - deviceStates[name] = state - handleOTAState(for: name, state: state) - case .deviceAvailability(let name, let available): - deviceAvailability[name] = available - case .deviceOTAUpdateResponse(let response): - handleOTAResponse(response) - case .deviceOTACheckResponse(let response): - handleOTACheckResponse(response) - case .permitJoinChanged(let enabled, let remaining): - if let info = bridgeInfo { - bridgeInfo = BridgeInfo( - version: info.version, - commit: info.commit, - coordinator: info.coordinator, - network: info.network, - logLevel: info.logLevel, - permitJoin: enabled, - permitJoinTimeout: remaining, - permitJoinEnd: remaining.map { Int(Date().timeIntervalSince1970 * 1000) + ($0 * 1000) }, - restartRequired: info.restartRequired, - config: info.config - ) - } - - case .bridgeResponse(let topic, let payload): - if topic == Z2MTopics.bridgeResponseBackup, let handler = backupResponseHandler { - backupResponseHandler = nil - if payload.object?["status"]?.stringValue == "ok", - let zip = payload.object?["data"]?.object?["zip"]?.stringValue { - handler(zip, nil) - } else { - let err = payload.object?["error"]?.stringValue ?? "Unknown error" - handler(nil, err) - } - break - } - // The options/info responses carry only `{restart_required}` (and - // echo the request on error). The full config is delivered via the - // separate `bridge/info` topic, so don't try to decode config here - // — doing so would overwrite the real config with all-nils. - guard payload.object?["status"]?.stringValue == "ok" else { break } - if let restartRequired = payload.object?["data"]?.object?["restart_required"]?.boolValue, - let info = bridgeInfo { - bridgeInfo = info.copyUpdating(restartRequired: restartRequired) - } - - case .bridgeHealth(let health): - if let existing = bridgeHealth, health.process == nil { - // Sparse response (e.g. bridge/response/health_check returns only {healthy:true}) - // Merge: preserve rich stats, update the healthy flag - bridgeHealth = BridgeHealth( - healthy: health.healthy, - responseTime: existing.responseTime, - process: existing.process, - os: existing.os, - mqtt: existing.mqtt - ) - } else { - bridgeHealth = health - } - - case .touchlinkScanResult(let devices): - touchlinkDevices = devices - touchlinkScanInProgress = false - - case .touchlinkIdentifyDone: - touchlinkIdentifyInProgress = false - - case .touchlinkFactoryResetDone: - touchlinkResetInProgress = false - - case .deviceRemoveResponse(let id, let ok, let errorMessage): - pendingRemovals.remove(id) - if ok { - // Remove locally so the next bridge/devices snapshot doesn't - // race with our List diff. Also clears keyed state so the - // "Recently Added" backfill doesn't resurrect it. - devices.removeAll { $0.friendlyName == id } - deviceStates.removeValue(forKey: id) - deviceAvailability.removeValue(forKey: id) - otaUpdates.removeValue(forKey: id) - deviceCheckResults.removeValue(forKey: id) - } else { - let message = errorMessage ?? "Failed to remove '\(id)'" - let error = Z2MOperationError( - id: UUID(), - topic: Z2MTopics.bridgeResponseDeviceRemove, - message: message, - timestamp: .now - ) - apply(.operationError(error)) - } - - case .deviceRenameResponse(let from, let to, let ok, let errorMessage): - if let pendingIdx = pendingRenames.firstIndex(where: { $0.from == from && $0.to == to }) { - pendingRenames.remove(at: pendingIdx) - } - if !ok { - revertOptimisticRename(from: from, to: to) - let message = errorMessage ?? "Failed to rename '\(from)' to '\(to)'" - let error = Z2MOperationError( - id: UUID(), - topic: Z2MTopics.bridgeResponseDeviceRename, - message: message, - timestamp: .now - ) - apply(.operationError(error)) - } - - case .operationError(let error): - touchlinkScanInProgress = false - touchlinkIdentifyInProgress = false - touchlinkResetInProgress = false - operationErrors.insert(error, at: 0) - let entry = LogEntry( - id: UUID(), - timestamp: error.timestamp, - level: .error, - category: .general, - namespace: "z2m:response", - message: error.message, - deviceName: nil - ) - insertLogEntry(entry) - enqueueNotification(InAppNotification( - level: .error, - title: "Operation Failed", - subtitle: stripped(String(error.message.prefix(100))), - logEntryID: entry.id, - category: .operationFailed - )) - - case .unknown: - break - } - } - - func clearLogs() { - logEntries = [] - rawLogEntries = [] - } - - func popNotification() -> InAppNotification? { - guard !pendingNotifications.isEmpty else { return nil } - return pendingNotifications.removeFirst() - } - - func popFastTrackNotification() -> InAppNotification? { - guard !fastTrackNotifications.isEmpty else { return nil } - return fastTrackNotifications.removeFirst() - } - - func enqueueOTABulkSummary(_ summary: OTABulkOperationQueue.CompletionSummary) { - let noun = summary.kind == .check ? "Checked" : "Updated" - let level: LogLevel = summary.failed > 0 ? .warning : .info - let title: String - if summary.wasCancelled { - title = summary.kind == .check ? "Check Cancelled" : "Updates Cancelled" - } else if summary.failed > 0 { - title = "\(noun) \(summary.total) Devices" - } else { - title = "\(noun) \(summary.total) Devices" - } - var parts: [String] = [] - if summary.succeeded > 0 { - parts.append("\(summary.succeeded) succeeded") - } - if summary.failed > 0 { - parts.append("\(summary.failed) failed") - } - let subtitle = parts.isEmpty ? nil : parts.joined(separator: ", ") - enqueueNotification(InAppNotification( - level: level, - title: title, - subtitle: subtitle, - category: .otaBulkSummary - )) - } - - func enqueueNotification(_ notification: InAppNotification) { - // Fast-track bypasses the filter — these are transient confirmations - // (e.g. "Copied to Clipboard") driven by the user's own action. - if notification.priority == .fastTrack { - fastTrackNotifications.append(notification) - return - } - - if let filter = notificationFilter, !filter(notification) { return } - - let now = Date() - - if let idx = pendingNotifications.lastIndex(where: { $0.coalesceKey == notification.coalesceKey }), - now.timeIntervalSince(pendingNotifications[idx].lastUpdated) <= Self.coalesceWindow { - pendingNotifications[idx].count += notification.count - pendingNotifications[idx].logEntryIDs.append(contentsOf: notification.logEntryIDs) - pendingNotifications[idx].occurrences.append(contentsOf: notification.occurrences) - if let sub = notification.subtitle { pendingNotifications[idx].subtitle = sub } - pendingNotifications[idx].lastUpdated = now - return - } - - pendingNotifications.append(notification) - notificationArrivalID = UUID() - } - - private func notification( - for action: LogContext.LogAction, level: LogLevel, - deviceName: String?, message: String, id: UUID - ) -> InAppNotification? { - let truncated = stripped(String(message.prefix(100))) - switch action { - case .bindSuccess: - return InAppNotification(level: .info, title: "Bind Successful", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .bindSuccess) - case .bindFailure: - return InAppNotification(level: .error, title: "Bind Failed", subtitle: deviceName ?? truncated, logEntryID: id, deviceName: deviceName, category: .bindFailure) - case .unbind: - return InAppNotification(level: .info, title: "Unbound", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .unbind) - case .groupAdd: - return InAppNotification(level: .info, title: "Added to Group", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .groupAdd) - case .groupRemove: - return InAppNotification(level: .info, title: "Removed from Group", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .groupRemove) - case .publishFailure(let command): - let detail = command.isEmpty ? truncated : command - return InAppNotification(level: .error, title: "Command Failed", subtitle: detail, logEntryID: id, deviceName: deviceName, category: .publishFailure) - case .requestFailure: - return InAppNotification(level: .error, title: "Request Failed", subtitle: truncated, logEntryID: id, deviceName: deviceName, category: .requestFailure) - case .otaFinished: - return InAppNotification(level: .info, title: "Update Installed", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .otaUpdateInstalled) - case .reportingConfigure: - return InAppNotification(level: .info, title: "Reporting Configured", subtitle: deviceName, logEntryID: id, deviceName: deviceName, category: .reportingConfigure) - case .general where level == .error: - return InAppNotification(level: .error, title: "Error", subtitle: truncated, logEntryID: id, deviceName: deviceName, category: .genericError) - default: - return nil - } - } - - // Z2M log messages sometimes embed their namespace at the start ("z2m:controller Something failed"). - // Strip it so notifications show only the human-readable part. - private func stripped(_ text: String) -> String { - guard text.hasPrefix("z2m:") else { return text } - if let spaceRange = text.range(of: " ") { - return String(text[spaceRange.upperBound...]) - } - return text - } - - private static func notification(from event: BridgeDeviceEvent, entry: LogEntry) -> InAppNotification? { - switch event.type { - case "device_leave": - return InAppNotification(level: .warning, title: "Device Left Network", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .deviceLeft) - case "device_interview": - let status = event.data.object?["status"]?.stringValue ?? "unknown" - switch status { - case "started": - return InAppNotification(level: .info, title: "Interviewing Device", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .interviewStarted) - case "successful": - return InAppNotification(level: .info, title: "Interview Successful", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .interviewSuccessful) - case "failed": - return InAppNotification(level: .error, title: "Interview Failed", subtitle: entry.deviceName, logEntryID: entry.id, deviceName: entry.deviceName, category: .interviewFailed) - default: - return nil - } - default: - return nil - } - } - - private func insertLogEntry(_ entry: LogEntry) { - logEntries.insert(entry, at: 0) - if logEntries.count > Self.logLimit { - logEntries = Array(logEntries.prefix(Self.logLimit)) - } - } - - private func insertRawLogEntry(_ entry: LogEntry) { - rawLogEntries.insert(entry, at: 0) - if rawLogEntries.count > Self.logLimit { - rawLogEntries = Array(rawLogEntries.prefix(Self.logLimit)) - } - } - - private static func logEntry(from event: BridgeDeviceEvent) -> LogEntry? { - guard let data = event.data.object else { return nil } - let deviceName = data["friendly_name"]?.stringValue - let ieeeAddr = data["ieee_address"]?.stringValue - let name = deviceName ?? ieeeAddr ?? "unknown" - - switch event.type { - case "device_joined": - return LogEntry(id: UUID(), timestamp: .now, level: .info, category: .deviceJoined, namespace: nil, message: "Device '\(name)' joined the network", deviceName: deviceName) - case "device_announce": - return LogEntry(id: UUID(), timestamp: .now, level: .info, category: .deviceAnnounce, namespace: nil, message: "Device '\(name)' announced", deviceName: deviceName) - case "device_interview": - let status = data["status"]?.stringValue ?? "unknown" - let level: LogLevel = status == "failed" ? .error : .info - return LogEntry(id: UUID(), timestamp: .now, level: level, category: .interview, namespace: nil, message: "Interview of '\(name)' \(status)", deviceName: deviceName) - case "device_leave": - return LogEntry(id: UUID(), timestamp: .now, level: .warning, category: .deviceLeave, namespace: nil, message: "Device '\(name)' left the network", deviceName: deviceName) - default: - return nil - } - } - - func device(named friendlyName: String) -> Device? { - devices.first { $0.friendlyName == friendlyName } - } - - func state(for friendlyName: String) -> [String: JSONValue] { - deviceStates[friendlyName] ?? [:] - } - - func isAvailable(_ friendlyName: String) -> Bool { - deviceAvailability[friendlyName] ?? false - } - - // Apply a rename to local state immediately so the UI updates without - // waiting for the bridge/devices snapshot (which can lag 3-10s after a - // bridge/request/device/rename). Migrates availability and state keys so - // the renamed device doesn't flicker through "offline". - func optimisticRename(from: String, to: String) { - guard from != to, !to.isEmpty else { return } - guard let idx = devices.firstIndex(where: { $0.friendlyName == from }) else { return } - var device = devices[idx] - device.friendlyName = to - devices[idx] = device - - if let availability = deviceAvailability.removeValue(forKey: from) { - deviceAvailability[to] = availability - } - if let state = deviceStates.removeValue(forKey: from) { - deviceStates[to] = state - } - pendingRenames.append((from: from, to: to)) - } - - private func revertOptimisticRename(from: String, to: String) { - guard let idx = devices.firstIndex(where: { $0.friendlyName == to }) else { return } - var device = devices[idx] - device.friendlyName = from - devices[idx] = device - - if let availability = deviceAvailability.removeValue(forKey: to) { - deviceAvailability[from] = availability - } - if let state = deviceStates.removeValue(forKey: to) { - deviceStates[from] = state - } - } - - func otaStatus(for friendlyName: String) -> OTAUpdateStatus? { - otaUpdates[friendlyName] ?? state(for: friendlyName).otaUpdateStatus(for: friendlyName) - } - - func startOTAUpdate(for friendlyName: String) { - otaUpdates[friendlyName] = OTAUpdateStatus( - deviceName: friendlyName, - phase: .requested, - progress: nil, - remaining: nil - ) - OTAUpdateLiveActivityCoordinator.shared.sync(with: activeOTAUpdates, devices: devices) - } - - func startOTACheck(for friendlyName: String) { - otaUpdates[friendlyName] = OTAUpdateStatus( - deviceName: friendlyName, - phase: .checking, - progress: nil, - remaining: nil - ) - } - - func startOTASchedule(for friendlyName: String) { - otaUpdates[friendlyName] = OTAUpdateStatus( - deviceName: friendlyName, - phase: .scheduled, - progress: nil, - remaining: nil - ) - } - - func cancelOTASchedule(for friendlyName: String) { - if otaUpdates[friendlyName]?.phase == .scheduled { - otaUpdates.removeValue(forKey: friendlyName) - } - } - func reset() { devices = [] groups = [] @@ -590,102 +96,21 @@ final class AppStore { OTAUpdateLiveActivityCoordinator.shared.clearAll() } - private var activeOTAUpdates: [OTAUpdateStatus] { - otaUpdates.values.filter(\.isActive) - } - - private func handleOTAState(for deviceName: String, state: [String: JSONValue]) { - guard let update = state.otaUpdateStatus(for: deviceName) else { return } - - let previous = otaUpdates[deviceName] - - switch update.phase { - case .available: - if previous?.isActive == true { - otaUpdates.removeValue(forKey: deviceName) - } - case .checking: - break // Handled by manual check trigger - case .requested: - otaUpdates[deviceName] = update - case .scheduled, .updating: - otaUpdates[deviceName] = update - case .idle: - otaUpdates.removeValue(forKey: deviceName) - if previous?.isActive == true, activeOTAUpdates.isEmpty { - OTAUpdateLiveActivityCoordinator.shared.finish(for: deviceName, success: true) - return - } - } - - OTAUpdateLiveActivityCoordinator.shared.sync(with: activeOTAUpdates, devices: devices) - } - - private func handleOTAResponse(_ response: DeviceOTAUpdateResponse) { - if let deviceName = response.deviceName { - otaResponseForwarding?(deviceName, response.isSuccess, .update) - } - guard !response.isSuccess else { return } - guard let deviceName = response.deviceName else { return } - - otaUpdates.removeValue(forKey: deviceName) + // MARK: - First-seen persistence - if activeOTAUpdates.isEmpty { - OTAUpdateLiveActivityCoordinator.shared.finish(for: deviceName, success: false) - } else { - OTAUpdateLiveActivityCoordinator.shared.sync(with: activeOTAUpdates, devices: devices) - } + private func persistFirstSeen() { + let raw = deviceFirstSeen.mapValues { $0.timeIntervalSince1970 } + UserDefaults.standard.set(raw, forKey: Self.firstSeenStoreKey) } - private func handleOTACheckResponse(_ response: DeviceOTAUpdateResponse) { - guard let deviceName = response.deviceName else { return } - - otaResponseForwarding?(deviceName, response.isSuccess, .check) - - if !response.isSuccess { - flashCheckResult(.failed, for: deviceName) - Task { - try? await Task.sleep(for: .seconds(DesignTokens.Duration.liveActivitySuccess)) - await MainActor.run { - if otaUpdates[deviceName]?.phase == .checking { - otaUpdates.removeValue(forKey: deviceName) - } - } - } - } else { - if otaUpdates[deviceName]?.phase == .checking { - otaUpdates.removeValue(forKey: deviceName) - } - // The subsequent deviceState event decides whether an update was - // found. Look at current state: if hasUpdateAvailable, "Found - // update"; otherwise "No update" (off by default as a - // notification, but always shown as a transient row chip). - let hasUpdate = state(for: deviceName).hasUpdateAvailable - if hasUpdate { - flashCheckResult(.updateFound, for: deviceName) - } else { - flashCheckResult(.noUpdate, for: deviceName) - enqueueNotification(InAppNotification( - level: .info, - title: "No Update Available", - subtitle: deviceName, - deviceName: deviceName, - category: .otaNoUpdate - )) - } - } + func recordFirstSeen(ieee: String, overwrite: Bool = false) { + if !overwrite, deviceFirstSeen[ieee] != nil { return } + deviceFirstSeen[ieee] = Date() + persistFirstSeen() } - private func flashCheckResult(_ result: DeviceCheckResult, for deviceName: String) { - deviceCheckResults[deviceName] = result - Task { [weak self] in - try? await Task.sleep(for: .seconds(3)) - await MainActor.run { - guard let self else { return } - if self.deviceCheckResults[deviceName] == result { - self.deviceCheckResults.removeValue(forKey: deviceName) - } - } - } + func removeFirstSeen(ieee: String) { + guard deviceFirstSeen.removeValue(forKey: ieee) != nil else { return } + persistFirstSeen() } } diff --git a/Shellbee/Features/Bridge/TouchlinkGuideView.swift b/Shellbee/Features/Bridge/TouchlinkGuideView.swift index b06a45c..c893ecc 100644 --- a/Shellbee/Features/Bridge/TouchlinkGuideView.swift +++ b/Shellbee/Features/Bridge/TouchlinkGuideView.swift @@ -91,8 +91,8 @@ struct TouchlinkGuideView: View { .background( LinearGradient( colors: [ - Color.teal.opacity(0.18), - Color.cyan.opacity(0.10), + Color.teal.opacity(DesignTokens.Opacity.onStateTint), + Color.cyan.opacity(DesignTokens.Opacity.lightOpaque), Color(.secondarySystemGroupedBackground) ], startPoint: .topLeading, diff --git a/Shellbee/Features/Connection/ConnectionOverviewView.swift b/Shellbee/Features/Connection/ConnectionOverviewView.swift index 16f9752..4f1df20 100644 --- a/Shellbee/Features/Connection/ConnectionOverviewView.swift +++ b/Shellbee/Features/Connection/ConnectionOverviewView.swift @@ -11,7 +11,7 @@ struct ConnectionOverviewView: View { Section("Explore") { NavigationLink(destination: DocBrowserView()) { Label { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { Text("Device Library") Text("Browse docs for 5,000+ Zigbee devices") .font(.caption) diff --git a/Shellbee/Features/Connection/ConnectionViewModel.swift b/Shellbee/Features/Connection/ConnectionViewModel.swift index 963be0d..fb5dc3b 100644 --- a/Shellbee/Features/Connection/ConnectionViewModel.swift +++ b/Shellbee/Features/Connection/ConnectionViewModel.swift @@ -70,7 +70,7 @@ final class ConnectionViewModel { func startDiscovery() { Task { @MainActor in environment.discovery.start() - try? await Task.sleep(for: .seconds(15)) + try? await Task.sleep(for: .seconds(DesignTokens.Duration.discoveryScanWindow)) environment.discovery.stop() } } diff --git a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift index fc81fb6..619f1f1 100644 --- a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift +++ b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift @@ -75,8 +75,9 @@ struct DeviceFirmwareMenu: View { if updateCount > 0 && !bulkActive { Circle() .fill(Color.red) - .frame(width: 8, height: 8) - .offset(x: 4, y: -2) + .frame(width: DesignTokens.Size.logLevelDotSize, height: DesignTokens.Size.logLevelDotSize) + .offset(x: DesignTokens.Size.firmwareUpdateBadgeOffsetX, + y: DesignTokens.Size.firmwareUpdateBadgeOffsetY) } } } diff --git a/Shellbee/Features/Devices/DeviceImageView.swift b/Shellbee/Features/Devices/DeviceImageView.swift index dfaac54..cd456c8 100644 --- a/Shellbee/Features/Devices/DeviceImageView.swift +++ b/Shellbee/Features/Devices/DeviceImageView.swift @@ -60,7 +60,8 @@ struct DeviceImageView: View { } private var offlineDot: some View { - let dotSize = max(7, size * 0.26) + let dotSize = max(DesignTokens.Ratio.deviceImageDotMin, + size * DesignTokens.Ratio.deviceImageDot) return Circle() .fill(Color.red) .frame(width: dotSize, height: dotSize) @@ -70,7 +71,7 @@ struct DeviceImageView: View { private var fallbackIcon: some View { Image(systemName: device.categorySystemImage) - .font(.system(size: size * 0.5, weight: .medium)) + .font(.system(size: size * DesignTokens.Typography.iconRatioHalf, weight: .medium)) .foregroundStyle(isAvailable ? Color.accentColor : .secondary.opacity(DesignTokens.Opacity.overlay)) .frame(maxWidth: .infinity, maxHeight: .infinity) // Optional: add a very subtle circle for fallbacks only @@ -85,7 +86,7 @@ struct DeviceImageView: View { } #Preview { - VStack(spacing: 20) { + VStack(spacing: DesignTokens.Spacing.xl) { HStack { DeviceImageView(device: .preview, isAvailable: true) Text("Available Image") diff --git a/Shellbee/Features/Devices/DeviceListViewModel.swift b/Shellbee/Features/Devices/DeviceListViewModel.swift index 4d4cb79..546642a 100644 --- a/Shellbee/Features/Devices/DeviceListViewModel.swift +++ b/Shellbee/Features/Devices/DeviceListViewModel.swift @@ -102,7 +102,7 @@ final class DeviceListViewModel { /// A device counts as "Recently Added" if it is currently interviewing, or /// if its first-seen timestamp falls within this window. The window /// survives app close/open via the timestamps persisted in `AppStore`. - static let recentWindow: TimeInterval = 30 * 60 + static let recentWindow: TimeInterval = AppConfig.UX.recentDeviceWindow var hasActiveFilter: Bool { categoryFilter != nil || typeFilter != nil || vendorFilter != nil || statusFilter != .all diff --git a/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift b/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift index 40aa5b1..1e4e43c 100644 --- a/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift +++ b/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift @@ -93,7 +93,7 @@ struct DeviceUpgradeBadgeView: View { .phaseAnimator([0, 360]) { content, phase in content.rotationEffect(.degrees(phase)) } animation: { _ in - .linear(duration: 1.0).repeatForever(autoreverses: false) + .linear(duration: DesignTokens.Duration.pulseFull).repeatForever(autoreverses: false) } } diff --git a/Shellbee/Features/Groups/GroupCard.swift b/Shellbee/Features/Groups/GroupCard.swift index fff6fc6..2cd0bce 100644 --- a/Shellbee/Features/Groups/GroupCard.swift +++ b/Shellbee/Features/Groups/GroupCard.swift @@ -37,10 +37,10 @@ struct GroupCard: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { Text(group.friendlyName) - .font(.system(size: 20, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.compactCardTitle) .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.72) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMildLight) Text("Group #\(group.id) · \(group.members.count) members") .font(.subheadline) @@ -88,7 +88,7 @@ struct GroupCard: View { .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(2) - .minimumScaleFactor(0.82) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorSubtle) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -113,28 +113,28 @@ struct GroupCard: View { private func identityMetric(label: String, icon: String, value: String, unit: String?, color: Color) -> some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(alignment: .firstTextBaseline, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(label) - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(.system(size: 24, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.identityTileValue) .monospacedDigit() .foregroundStyle(color) .lineLimit(1) - .minimumScaleFactor(0.55) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) if let unit { Text(unit) - .font(.system(size: 14, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.identityTileUnit) .foregroundStyle(.secondary) .lineLimit(1) } @@ -155,10 +155,10 @@ struct GroupCard: View { @ViewBuilder private var nameView: some View { let label = Text(group.friendlyName) - .font(.system(size: 24, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.cardTitle) .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.45) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorAggressive) .allowsTightening(true) if let onRenameTapped { @@ -175,8 +175,8 @@ struct GroupCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) + .frame(height: DesignTokens.Size.hairline) } private var scenesTitle: String { diff --git a/Shellbee/Features/Groups/GroupCardFooterBar.swift b/Shellbee/Features/Groups/GroupCardFooterBar.swift index c9c160d..417b634 100644 --- a/Shellbee/Features/Groups/GroupCardFooterBar.swift +++ b/Shellbee/Features/Groups/GroupCardFooterBar.swift @@ -17,10 +17,10 @@ struct GroupCardFooterBar: View { private func statCell(value: String, label: String, color: Color) -> some View { VStack(spacing: DesignTokens.Spacing.summaryRowVerticalPadding) { Text(value) - .font(.system(size: 13, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.footerActionLabel) .foregroundStyle(color) .lineLimit(1) - .minimumScaleFactor(0.75) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMild) Text(label) .font(.caption2) .foregroundStyle(.tertiary) diff --git a/Shellbee/Features/Groups/GroupIconView.swift b/Shellbee/Features/Groups/GroupIconView.swift index 20a9a3a..e719d8f 100644 --- a/Shellbee/Features/Groups/GroupIconView.swift +++ b/Shellbee/Features/Groups/GroupIconView.swift @@ -12,9 +12,10 @@ struct GroupIconView: View { .frame(width: size, height: size) } else { ZStack(alignment: .topLeading) { - DeviceImageView(device: memberDevices[0], isAvailable: true, size: size * 0.72) - DeviceImageView(device: memberDevices[1], isAvailable: true, size: size * 0.72) - .offset(x: size * 0.28, y: size * 0.28) + DeviceImageView(device: memberDevices[0], isAvailable: true, size: size * DesignTokens.Ratio.groupIconMember) + DeviceImageView(device: memberDevices[1], isAvailable: true, size: size * DesignTokens.Ratio.groupIconMember) + .offset(x: size * DesignTokens.Ratio.groupIconOffset, + y: size * DesignTokens.Ratio.groupIconOffset) } .frame(width: size, height: size, alignment: .topLeading) } @@ -22,9 +23,10 @@ struct GroupIconView: View { private var genericIcon: some View { Image(systemName: "square.on.square.fill") - .font(.system(size: size * 0.5, weight: .medium)) + .font(.system(size: size * DesignTokens.Typography.iconRatioHalf, weight: .medium)) .foregroundStyle(.secondary) .frame(width: size, height: size) - .background(.fill.secondary, in: RoundedRectangle(cornerRadius: size * 0.28)) + .background(.fill.secondary, + in: RoundedRectangle(cornerRadius: size * DesignTokens.Ratio.groupIconCorner)) } } diff --git a/Shellbee/Features/Groups/GroupListRow.swift b/Shellbee/Features/Groups/GroupListRow.swift index 82ed7b7..4f5f67b 100644 --- a/Shellbee/Features/Groups/GroupListRow.swift +++ b/Shellbee/Features/Groups/GroupListRow.swift @@ -38,7 +38,7 @@ struct GroupListRow: View { Text(title) .font(.caption2) .lineLimit(1) - .minimumScaleFactor(0.65) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorAggressiveLight) } .frame(minWidth: DesignTokens.Size.deviceActionSheetImage) } diff --git a/Shellbee/Features/Home/HomeAddCardsSection.swift b/Shellbee/Features/Home/HomeAddCardsSection.swift index df06e8b..1429e23 100644 --- a/Shellbee/Features/Home/HomeAddCardsSection.swift +++ b/Shellbee/Features/Home/HomeAddCardsSection.swift @@ -24,7 +24,7 @@ struct HomeAddCardsSection: View { Image(systemName: card.symbol) .font(.subheadline.weight(.semibold)) .foregroundStyle(card.tint) - .frame(width: 22) + .frame(width: DesignTokens.Size.cardSymbol) Text(card.title) .font(.subheadline.weight(.medium)) .foregroundStyle(.primary) @@ -36,7 +36,7 @@ struct HomeAddCardsSection: View { .buttonStyle(.plain) if index < hidden.count - 1 { - Divider().padding(.leading, 60) + Divider().padding(.leading, DesignTokens.Size.homeAddDividerInset) } } } diff --git a/Shellbee/Features/Home/HomeBackgroundGradient.swift b/Shellbee/Features/Home/HomeBackgroundGradient.swift index a19dcf1..7d0dac2 100644 --- a/Shellbee/Features/Home/HomeBackgroundGradient.swift +++ b/Shellbee/Features/Home/HomeBackgroundGradient.swift @@ -19,7 +19,7 @@ struct HomeBackgroundGradient: View { stops: [ .init(color: .black, location: 0.0), .init(color: .black, location: 0.75), - .init(color: .black.opacity(0.75), location: 1.0) + .init(color: .black.opacity(DesignTokens.Opacity.secondaryDim), location: 1.0) ], startPoint: .top, endPoint: .bottom @@ -44,7 +44,7 @@ struct HomeBackgroundGradient: View { ) .overlay( LinearGradient( - colors: [.white.opacity(0.30), .clear], + colors: [.white.opacity(DesignTokens.Opacity.dimmedSurface), .clear], startPoint: .top, endPoint: .center ) @@ -69,7 +69,7 @@ struct HomeBackgroundGradient: View { ) .overlay( LinearGradient( - colors: [.black.opacity(0.25), .clear], + colors: [.black.opacity(DesignTokens.Opacity.pressedAlpha), .clear], startPoint: .top, endPoint: .center ) diff --git a/Shellbee/Features/Home/HomeBridgeCard.swift b/Shellbee/Features/Home/HomeBridgeCard.swift index 894148b..3ed974f 100644 --- a/Shellbee/Features/Home/HomeBridgeCard.swift +++ b/Shellbee/Features/Home/HomeBridgeCard.swift @@ -69,7 +69,7 @@ struct HomeBridgeCard: View { HomeCardTitle(symbol: "antenna.radiowaves.left.and.right", title: headerTitle, tint: .teal) .lineLimit(1) if isReconnecting { - HStack(spacing: 4) { + HStack(spacing: DesignTokens.Spacing.xs) { ProgressView().controlSize(.mini) Text("Reconnecting (\(reconnectAttempt))") .font(.caption) @@ -123,7 +123,7 @@ struct HomeBridgeCard: View { } private func statusLine(symbol: String, tint: Color, text: String) -> some View { - HStack(spacing: 6) { + HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: symbol) .font(.footnote.weight(.semibold)) .foregroundStyle(tint) diff --git a/Shellbee/Features/Home/HomeCardComponents.swift b/Shellbee/Features/Home/HomeCardComponents.swift index 6986c85..6637e4b 100644 --- a/Shellbee/Features/Home/HomeCardComponents.swift +++ b/Shellbee/Features/Home/HomeCardComponents.swift @@ -38,7 +38,7 @@ struct HomeStatCell: View { var subtitle: String? = nil var body: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { Text(value) .font(.title3.weight(.bold)) .foregroundStyle(valueColor) diff --git a/Shellbee/Features/Home/HomeCardSlot.swift b/Shellbee/Features/Home/HomeCardSlot.swift index 1fab9d9..0a50767 100644 --- a/Shellbee/Features/Home/HomeCardSlot.swift +++ b/Shellbee/Features/Home/HomeCardSlot.swift @@ -20,7 +20,8 @@ struct HomeCardSlot: View { } .buttonStyle(.plain) .accessibilityLabel("Hide \(card.title)") - .offset(x: -8, y: -8) + .offset(x: DesignTokens.Size.homeCardSlotButtonOffset, + y: DesignTokens.Size.homeCardSlotButtonOffset) .transition(.scale.combined(with: .opacity)) } } diff --git a/Shellbee/Features/Home/HomeLogsCard.swift b/Shellbee/Features/Home/HomeLogsCard.swift index f2c1d97..66e695f 100644 --- a/Shellbee/Features/Home/HomeLogsCard.swift +++ b/Shellbee/Features/Home/HomeLogsCard.swift @@ -50,7 +50,7 @@ struct HomeLogRow: View { var body: some View { HStack(alignment: .center, spacing: DesignTokens.Spacing.md) { badge - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { Text(entry.summaryTitle) .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) @@ -75,11 +75,11 @@ struct HomeLogRow: View { private var badge: some View { Circle() - .fill(entry.level.color.opacity(0.18)) + .fill(entry.level.color.opacity(DesignTokens.Opacity.onStateTint)) .frame(width: Self.badgeSize, height: Self.badgeSize) .overlay { Image(systemName: entry.category.systemImage) - .font(.system(size: 12, weight: .semibold)) + .font(DesignTokens.Typography.sectionHeaderLabel) .foregroundStyle(entry.level.color) } } diff --git a/Shellbee/Features/Home/HomeView.swift b/Shellbee/Features/Home/HomeView.swift index aaddc86..109cd58 100644 --- a/Shellbee/Features/Home/HomeView.swift +++ b/Shellbee/Features/Home/HomeView.swift @@ -45,12 +45,12 @@ struct HomeView: View { card: id, isEditing: layout.isEditing, onHide: { - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(.easeInOut(duration: DesignTokens.Duration.mediumAnimation)) { layout.hide(id) } }, onEnterEdit: { - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(.easeInOut(duration: DesignTokens.Duration.mediumAnimation)) { layout.isEditing = true } } @@ -76,7 +76,7 @@ struct HomeView: View { HomeAddCardsSection( hidden: HomeCardID.allCases.filter { layout.hidden.contains($0) } ) { card in - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(.easeInOut(duration: DesignTokens.Duration.mediumAnimation)) { layout.show(card) } } @@ -117,7 +117,7 @@ struct HomeView: View { if layout.isEditing { ToolbarItem(placement: .topBarTrailing) { Button("Done") { - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(.easeInOut(duration: DesignTokens.Duration.mediumAnimation)) { layout.isEditing = false } } @@ -211,7 +211,7 @@ struct HomeView: View { .font(.subheadline) .foregroundStyle(.secondary) Button("Edit Home") { - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(.easeInOut(duration: DesignTokens.Duration.mediumAnimation)) { layout.isEditing = true } } diff --git a/Shellbee/Features/Home/PermitJoinActiveSheet.swift b/Shellbee/Features/Home/PermitJoinActiveSheet.swift index e75b79e..d73046a 100644 --- a/Shellbee/Features/Home/PermitJoinActiveSheet.swift +++ b/Shellbee/Features/Home/PermitJoinActiveSheet.swift @@ -35,27 +35,28 @@ struct PermitJoinActiveSheet: View { private func countdownRing(remaining: Int?) -> some View { ZStack { Circle() - .stroke(Color.green.opacity(0.15), lineWidth: 8) + .stroke(Color.green.opacity(DesignTokens.Opacity.actionButtonFill), + lineWidth: DesignTokens.Size.permitJoinRingStroke) Circle() .trim(from: 0, to: progress(remaining: remaining)) - .stroke(Color.green, style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .stroke(Color.green, style: StrokeStyle(lineWidth: DesignTokens.Size.permitJoinRingStroke, lineCap: .round)) .rotationEffect(.degrees(-90)) - .animation(.linear(duration: 1), value: remaining) + .animation(.linear(duration: DesignTokens.Duration.pulseFull), value: remaining) if let remaining { Text(String(format: "%d:%02d", remaining / 60, remaining % 60)) - .font(.system(size: 64, weight: .thin).monospacedDigit()) + .font(DesignTokens.Typography.permitJoinCountdown.monospacedDigit()) .foregroundStyle(.primary) .contentTransition(.numericText(countsDown: true)) } else { Image(systemName: "dot.radiowaves.up.forward") - .font(.system(size: 48)) + .font(DesignTokens.Typography.permitJoinSymbol) .foregroundStyle(.green) .symbolEffect(.pulse) } } - .frame(width: 220, height: 220) + .frame(width: DesignTokens.Size.permitJoinQR, height: DesignTokens.Size.permitJoinQR) } private func progress(remaining: Int?) -> CGFloat { diff --git a/Shellbee/Features/Logs/LogRowView.swift b/Shellbee/Features/Logs/LogRowView.swift index fe800f2..4c72334 100644 --- a/Shellbee/Features/Logs/LogRowView.swift +++ b/Shellbee/Features/Logs/LogRowView.swift @@ -8,7 +8,7 @@ struct LogRowView: View { HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) { leadingVisual - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) { Text(entry.summaryTitle) .font(.subheadline.bold()) @@ -34,7 +34,7 @@ struct LogRowView: View { private var leadingVisual: some View { let size = DesignTokens.Size.logRowDeviceImage - let badgeSize = size * 0.47 + let badgeSize = size * DesignTokens.Ratio.logRowBadgeSize return ZStack(alignment: .bottomTrailing) { Circle() @@ -42,25 +42,28 @@ struct LogRowView: View { .frame(width: size, height: size) .overlay { Image(systemName: entry.category.systemImage) - .font(.system(size: size * 0.38, weight: .semibold)) + .font(.system(size: size * DesignTokens.Typography.iconRatioSmall, weight: .semibold)) .foregroundStyle(iconForeground) } if let device = resolvedDevice { deviceThumbnail(device, size: badgeSize) - .offset(x: 3, y: 3) + .offset(x: DesignTokens.Size.logRowBadgeOffset, + y: DesignTokens.Size.logRowBadgeOffset) } } } private var iconForeground: Color { - entry.level == .warning ? Color.black.opacity(0.75) : Color.white + entry.level == .warning ? Color.black.opacity(DesignTokens.Opacity.secondaryDim) : Color.white } private func deviceThumbnail(_ device: Device, size: CGFloat) -> some View { DeviceImageView(device: device, isAvailable: true, size: size) .clipShape(Circle()) - .overlay(Circle().strokeBorder(Color(.systemBackground), lineWidth: max(1.5, size * 0.1))) + .overlay(Circle().strokeBorder(Color(.systemBackground), + lineWidth: max(DesignTokens.Ratio.logRowBadgeBorderMin, + size * DesignTokens.Ratio.logRowBadgeBorder))) } private var resolvedDevice: Device? { diff --git a/Shellbee/Features/Notifications/FastTrackBanner.swift b/Shellbee/Features/Notifications/FastTrackBanner.swift new file mode 100644 index 0000000..6a290b1 --- /dev/null +++ b/Shellbee/Features/Notifications/FastTrackBanner.swift @@ -0,0 +1,44 @@ +import SwiftUI + +/// A compact, non-interactive banner reserved for short, transient +/// confirmations like "Copied to Clipboard". The queue manager surfaces a +/// fast-track notification briefly above whatever main banner is showing, +/// then dismisses it on a fixed timer. +struct FastTrackBanner: View { + let notification: InAppNotification + + var body: some View { + HStack(spacing: DesignTokens.Spacing.sm) { + Image(systemName: notification.level.systemImage) + .font(DesignTokens.Typography.notificationLevelIcon) + .foregroundStyle(notification.level.color) + Text(notification.title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.primary) + } + .padding(.horizontal, DesignTokens.Spacing.lg) + .padding(.vertical, DesignTokens.Spacing.md) + .glassEffect(in: Capsule(style: .continuous)) + .shadow( + color: .black.opacity(DesignTokens.Shadow.floatingOpacity), + radius: DesignTokens.Shadow.floatingRadius, + y: -DesignTokens.Shadow.floatingY + ) + } +} + +#Preview("Fast-track — Copied") { + VStack { + Spacer() + FastTrackBanner( + notification: InAppNotification( + level: .info, + title: "Copied to Clipboard", + priority: .fastTrack + ) + ) + .padding(.bottom, DesignTokens.Size.notificationBottomInset) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) +} diff --git a/Shellbee/Features/Notifications/InAppNotificationBanner.swift b/Shellbee/Features/Notifications/InAppNotificationBanner.swift new file mode 100644 index 0000000..1428923 --- /dev/null +++ b/Shellbee/Features/Notifications/InAppNotificationBanner.swift @@ -0,0 +1,389 @@ +import SwiftUI + +/// The expanded/collapsed notification banner shown above the floating tab +/// bar. Vertical drags collapse / expand / dismiss; horizontal drags +/// (expanded, stack > 1) page the carousel via `onSwipeNext` / `onSwipePrevious`. +struct InAppNotificationBanner: View { + let notification: InAppNotification + @Binding var isExpanded: Bool + var stackCount: Int = 1 + var stackPositionLabel: String? = nil + let onDismiss: () -> Void + let onGoToLog: () -> Void + let onGoToDevice: () -> Void + let onCopyMessage: () -> Void + var onSwipeNext: (() -> Void)? = nil + var onSwipePrevious: (() -> Void)? = nil + + @State private var dragOffset: CGSize = .zero + // Vertical drag = collapse/expand/dismiss. Horizontal drag while + // expanded = carousel left/right. See dragGesture. + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { + header + if isExpanded { expandedBody } + } + .padding(.horizontal, DesignTokens.Spacing.lg) + .padding(.vertical, DesignTokens.Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + // iOS 26 floating tab bar uses a continuous capsule; per Apple HIG + // (Tab bars — floating accessories), floating UI above the tab bar + // should match its silhouette. Expanded uses a rounded rect for body room. + .glassEffect( + in: isExpanded + ? AnyShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.xl, style: .continuous)) + : AnyShape(Capsule(style: .continuous)) + ) + .shadow( + color: .black.opacity(DesignTokens.Shadow.floatingOpacity), + radius: DesignTokens.Shadow.floatingRadius, + y: -DesignTokens.Shadow.floatingY + ) + .padding(.horizontal, DesignTokens.Spacing.lg) + .offset(x: dragOffset.width, y: dragOffset.height) + .contentShape(bannerHitShape) + .highPriorityGesture(dragGesture, including: .all) + .animation(Self.settleAnimation, value: isExpanded) + } + + private static var settleAnimation: Animation { + .interactiveSpring(response: 0.24, dampingFraction: 0.9, blendDuration: 0.04) + } + + private var bannerHitShape: AnyShape { + isExpanded + ? AnyShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.xl, style: .continuous)) + : AnyShape(Capsule(style: .continuous)) + } + + private var header: some View { + HStack(spacing: DesignTokens.Spacing.sm) { + Image(systemName: notification.level.systemImage) + .font(DesignTokens.Typography.notificationLevelIcon) + .foregroundStyle(notification.level.color) + .frame(width: DesignTokens.Size.cardSymbol) + + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { + HStack(spacing: DesignTokens.Spacing.sm) { + Text(notification.title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.primary) + if notification.count > 1, stackPositionLabel == nil { + Text("× \(notification.count)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, DesignTokens.Spacing.sm) + .padding(.vertical, DesignTokens.Spacing.xxs) + .background(.secondary.opacity(DesignTokens.Opacity.subtleFill), in: Capsule()) + } + if let stackPositionLabel { + Text(stackPositionLabel) + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(.secondary) + .padding(.horizontal, DesignTokens.Spacing.sm) + .padding(.vertical, DesignTokens.Spacing.xxs) + .background(.tint.opacity(DesignTokens.Opacity.softFill), in: Capsule()) + } + } + if let subtitle = notification.subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(isExpanded ? nil : 1) + .truncationMode(.tail) + } + } + + Spacer(minLength: DesignTokens.Spacing.sm) + + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .frame(width: DesignTokens.Size.cardSymbol, height: DesignTokens.Size.cardSymbol) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .contentShape(Rectangle()) + .onTapGesture { + if isExpanded { + onCopyMessage() + } else { + isExpanded = true + } + } + } + + private var expandedBody: some View { + HStack(spacing: DesignTokens.Spacing.sm) { + if notification.deviceName != nil { + Button(action: onGoToDevice) { + Label("Device", systemImage: "sensor.tag.radiowaves.forward.fill") + } + .buttonStyle(.glassProminent) + .controlSize(.small) + } else if !notification.logEntryIDs.isEmpty { + Button(action: onGoToLog) { + Label("Log", systemImage: "list.bullet.rectangle.portrait") + } + .buttonStyle(.glassProminent) + .controlSize(.small) + } + + if notification.deviceName != nil, !notification.logEntryIDs.isEmpty { + Button(action: onGoToLog) { + Label("Log", systemImage: "list.bullet.rectangle.portrait") + } + .buttonStyle(.glass) + .controlSize(.small) + } + + Button(action: onCopyMessage) { + Label("Copy", systemImage: "doc.on.doc") + } + .buttonStyle(.glass) + .controlSize(.small) + + Spacer(minLength: 0) + } + .padding(.leading, DesignTokens.Size.cardSymbol + DesignTokens.Spacing.sm) + } + + private var dragGesture: some Gesture { + // Vertical: swipe up expands (from collapsed), swipe down dismisses + // the entire stack (from either state). + // Horizontal (expanded only): carousel left/right when stack > 1. + DragGesture(minimumDistance: DesignTokens.Spacing.sm) + .onChanged { value in + let dx = value.translation.width + let dy = value.translation.height + let verticalDominant = abs(dy) > abs(dx) + if verticalDominant { + if isExpanded { + dragOffset = CGSize(width: 0, height: max(dy, 0)) + } else { + dragOffset = CGSize(width: 0, height: dy > 0 ? dy : dy / 3) + } + } else if isExpanded, stackCount > 1 { + dragOffset = CGSize(width: dx, height: 0) + } else { + dragOffset = .zero + } + } + .onEnded { value in + let dx = value.translation.width + let dy = value.translation.height + let verticalDominant = abs(dy) > abs(dx) + var committedSwipe = false + if verticalDominant { + if !isExpanded, dy < -30 { + isExpanded = true + } else if dy > 60 { + onDismiss() + } + } else if isExpanded, stackCount > 1 { + if dx < -60 { + onSwipeNext?() + committedSwipe = true + } else if dx > 60 { + onSwipePrevious?() + committedSwipe = true + } + } + // On a committed carousel swipe, the outgoing banner is now a + // removed view — its position doesn't matter. Drop offset + // without animating so the slide-out transition takes over + // cleanly instead of the finger-follow offset snapping back. + if committedSwipe { + dragOffset = .zero + } else { + withAnimation(Self.settleAnimation) { dragOffset = .zero } + } + } + } +} + +// MARK: - Previews + +private struct PreviewHost: View { + let notification: InAppNotification + var expanded: Bool = false + @State private var isExpanded = false + + var body: some View { + VStack { + Spacer() + InAppNotificationBanner( + notification: notification, + isExpanded: $isExpanded, + stackCount: 1, + stackPositionLabel: nil, + onDismiss: {}, + onGoToLog: {}, + onGoToDevice: {}, + onCopyMessage: {} + ) + .padding(.bottom, DesignTokens.Size.notificationBottomInset) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .onAppear { isExpanded = expanded } + } +} + +#Preview("Info — collapsed") { + PreviewHost( + notification: InAppNotification( + level: .info, + title: "Bind Successful", + subtitle: "hallway_motion_sensor → living_room_light", + logEntryID: UUID() + ) + ) +} + +#Preview("Error — collapsed") { + PreviewHost( + notification: InAppNotification( + level: .error, + title: "Operation Failed", + subtitle: "Publish 'zigbee2mqtt/hallway_ff_sensor/set' failed because the device is offline and no response was received within the timeout window.", + logEntryID: UUID() + ) + ) +} + +#Preview("Warning — collapsed") { + PreviewHost( + notification: InAppNotification( + level: .warning, + title: "Device Left Network", + subtitle: "bedroom_thermostat", + logEntryID: UUID() + ) + ) +} + +#Preview("Error — expanded") { + PreviewHost( + notification: InAppNotification( + level: .error, + title: "Operation Failed", + subtitle: "Publish 'zigbee2mqtt/hallway_ff_sensor/set' failed because the device is offline and no response was received within the timeout window.", + logEntryID: UUID() + ), + expanded: true + ) +} + +#Preview("Coalesced burst (× 12)") { + PreviewHost( + notification: { + var n = InAppNotification( + level: .error, + title: "Operation Failed", + subtitle: "Interview of 'sensor_12' failed", + logEntryID: UUID() + ) + n.count = 12 + n.logEntryIDs = (0..<12).map { _ in UUID() } + return n + }() + ) +} + +#Preview("Coalesced — expanded") { + PreviewHost( + notification: { + var n = InAppNotification( + level: .warning, + title: "Interview Failed", + subtitle: "Most recent: 'attic_sensor_3'", + logEntryID: UUID() + ) + n.count = 4 + n.logEntryIDs = (0..<4).map { _ in UUID() } + return n + }(), + expanded: true + ) +} + +#Preview("Interview started — collapsed") { + PreviewHost( + notification: InAppNotification( + level: .info, + title: "Interviewing Device", + subtitle: "kitchen_motion_sensor", + logEntryID: UUID(), + deviceName: "kitchen_motion_sensor" + ) + ) +} + +#Preview("Interview successful — expanded") { + PreviewHost( + notification: InAppNotification( + level: .info, + title: "Interview Successful", + subtitle: "kitchen_motion_sensor", + logEntryID: UUID(), + deviceName: "kitchen_motion_sensor" + ), + expanded: true + ) +} + +#Preview("Interview failed — expanded") { + PreviewHost( + notification: InAppNotification( + level: .error, + title: "Interview Failed", + subtitle: "attic_thermostat", + logEntryID: UUID(), + deviceName: "attic_thermostat" + ), + expanded: true + ) +} + +#Preview("Info — no logEntry (Go to Log hidden)") { + PreviewHost( + notification: InAppNotification( + level: .info, + title: "Reporting Configured", + subtitle: "living_room_light" + ), + expanded: true + ) +} + +#Preview("Stacked carousel — 1 of 12 expanded") { + @Previewable @State var expanded = true + VStack { + Spacer() + InAppNotificationBanner( + notification: InAppNotification( + level: .error, + title: "Operation Failed", + subtitle: "Publish 'zigbee2mqtt/hallway_ff_sensor/set' failed", + logEntryID: UUID(), + deviceName: "hallway_ff_sensor" + ), + isExpanded: $expanded, + stackCount: 12, + stackPositionLabel: "1/12", + onDismiss: {}, + onGoToLog: {}, + onGoToDevice: {}, + onCopyMessage: {}, + onSwipeNext: {}, + onSwipePrevious: {} + ) + .padding(.bottom, DesignTokens.Size.notificationBottomInset) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) +} diff --git a/Shellbee/Features/Notifications/InAppNotificationOverlay.swift b/Shellbee/Features/Notifications/InAppNotificationOverlay.swift index c511aca..267f7c5 100644 --- a/Shellbee/Features/Notifications/InAppNotificationOverlay.swift +++ b/Shellbee/Features/Notifications/InAppNotificationOverlay.swift @@ -91,7 +91,7 @@ struct InAppNotificationOverlay: View { } .animation(.spring(duration: DesignTokens.Duration.standardAnimation), value: stack.isEmpty) .animation(Self.carouselAnimation, value: displayedPage.map { bannerIdentity(for: $0) }) - .animation(.spring(duration: 0.25), value: fastTrackVisible) + .animation(.spring(duration: DesignTokens.Duration.mediumAnimation), value: fastTrackVisible) .onChange(of: environment.store.notificationArrivalID) { _, newID in // New (non-coalesced) normal notification arrived. Haptic once, // and schedule auto-dismiss on the now-visible banner. @@ -298,431 +298,3 @@ struct InAppNotificationOverlay: View { } } } - -// MARK: - Main banner - -struct InAppNotificationBanner: View { - let notification: InAppNotification - @Binding var isExpanded: Bool - var stackCount: Int = 1 - var stackPositionLabel: String? = nil - let onDismiss: () -> Void - let onGoToLog: () -> Void - let onGoToDevice: () -> Void - let onCopyMessage: () -> Void - var onSwipeNext: (() -> Void)? = nil - var onSwipePrevious: (() -> Void)? = nil - - @State private var dragOffset: CGSize = .zero - // Vertical drag = collapse/expand/dismiss. Horizontal drag while - // expanded = carousel left/right. See dragGesture. - - var body: some View { - VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - header - if isExpanded { expandedBody } - } - .padding(.horizontal, DesignTokens.Spacing.lg) - .padding(.vertical, DesignTokens.Spacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - // iOS 26 floating tab bar uses a continuous capsule; per Apple HIG - // (Tab bars — floating accessories), floating UI above the tab bar - // should match its silhouette. Expanded uses a rounded rect for body room. - .glassEffect( - in: isExpanded - ? AnyShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.xl, style: .continuous)) - : AnyShape(Capsule(style: .continuous)) - ) - .shadow( - color: .black.opacity(DesignTokens.Shadow.floatingOpacity), - radius: DesignTokens.Shadow.floatingRadius, - y: -DesignTokens.Shadow.floatingY - ) - .padding(.horizontal, DesignTokens.Spacing.lg) - .offset(x: dragOffset.width, y: dragOffset.height) - .contentShape(bannerHitShape) - .highPriorityGesture(dragGesture, including: .all) - .animation(Self.settleAnimation, value: isExpanded) - } - - private static var settleAnimation: Animation { - .interactiveSpring(response: 0.24, dampingFraction: 0.9, blendDuration: 0.04) - } - - private var bannerHitShape: AnyShape { - isExpanded - ? AnyShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.xl, style: .continuous)) - : AnyShape(Capsule(style: .continuous)) - } - - private var header: some View { - HStack(spacing: DesignTokens.Spacing.sm) { - Image(systemName: notification.level.systemImage) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(notification.level.color) - .frame(width: 22) - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: DesignTokens.Spacing.sm) { - Text(notification.title) - .font(.footnote.weight(.semibold)) - .foregroundStyle(.primary) - if notification.count > 1, stackPositionLabel == nil { - Text("× \(notification.count)") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, DesignTokens.Spacing.sm) - .padding(.vertical, 2) - .background(.secondary.opacity(DesignTokens.Opacity.subtleFill), in: Capsule()) - } - if let stackPositionLabel { - Text(stackPositionLabel) - .font(.caption2.weight(.semibold).monospacedDigit()) - .foregroundStyle(.secondary) - .padding(.horizontal, DesignTokens.Spacing.sm) - .padding(.vertical, 2) - .background(.tint.opacity(DesignTokens.Opacity.softFill), in: Capsule()) - } - } - if let subtitle = notification.subtitle { - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(isExpanded ? nil : 1) - .truncationMode(.tail) - } - } - - Spacer(minLength: DesignTokens.Spacing.sm) - - Button(action: onDismiss) { - Image(systemName: "xmark") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - .frame(width: 22, height: 22) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - .contentShape(Rectangle()) - .onTapGesture { - if isExpanded { - onCopyMessage() - } else { - isExpanded = true - } - } - } - - private var expandedBody: some View { - HStack(spacing: DesignTokens.Spacing.sm) { - if notification.deviceName != nil { - Button(action: onGoToDevice) { - Label("Device", systemImage: "sensor.tag.radiowaves.forward.fill") - } - .buttonStyle(.glassProminent) - .controlSize(.small) - } else if !notification.logEntryIDs.isEmpty { - Button(action: onGoToLog) { - Label("Log", systemImage: "list.bullet.rectangle.portrait") - } - .buttonStyle(.glassProminent) - .controlSize(.small) - } - - if notification.deviceName != nil, !notification.logEntryIDs.isEmpty { - Button(action: onGoToLog) { - Label("Log", systemImage: "list.bullet.rectangle.portrait") - } - .buttonStyle(.glass) - .controlSize(.small) - } - - Button(action: onCopyMessage) { - Label("Copy", systemImage: "doc.on.doc") - } - .buttonStyle(.glass) - .controlSize(.small) - - Spacer(minLength: 0) - } - .padding(.leading, 30) - } - - private var dragGesture: some Gesture { - // Vertical: swipe up expands (from collapsed), swipe down dismisses - // the entire stack (from either state). - // Horizontal (expanded only): carousel left/right when stack > 1. - DragGesture(minimumDistance: DesignTokens.Spacing.sm) - .onChanged { value in - let dx = value.translation.width - let dy = value.translation.height - let verticalDominant = abs(dy) > abs(dx) - if verticalDominant { - if isExpanded { - dragOffset = CGSize(width: 0, height: max(dy, 0)) - } else { - dragOffset = CGSize(width: 0, height: dy > 0 ? dy : dy / 3) - } - } else if isExpanded, stackCount > 1 { - dragOffset = CGSize(width: dx, height: 0) - } else { - dragOffset = .zero - } - } - .onEnded { value in - let dx = value.translation.width - let dy = value.translation.height - let verticalDominant = abs(dy) > abs(dx) - var committedSwipe = false - if verticalDominant { - if !isExpanded, dy < -30 { - isExpanded = true - } else if dy > 60 { - onDismiss() - } - } else if isExpanded, stackCount > 1 { - if dx < -60 { - onSwipeNext?() - committedSwipe = true - } else if dx > 60 { - onSwipePrevious?() - committedSwipe = true - } - } - // On a committed carousel swipe, the outgoing banner is now a - // removed view — its position doesn't matter. Drop offset - // without animating so the slide-out transition takes over - // cleanly instead of the finger-follow offset snapping back. - if committedSwipe { - dragOffset = .zero - } else { - withAnimation(Self.settleAnimation) { dragOffset = .zero } - } - } - } -} - -// MARK: - Fast-track banner - -struct FastTrackBanner: View { - let notification: InAppNotification - - var body: some View { - HStack(spacing: DesignTokens.Spacing.sm) { - Image(systemName: notification.level.systemImage) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(notification.level.color) - Text(notification.title) - .font(.footnote.weight(.semibold)) - .foregroundStyle(.primary) - } - .padding(.horizontal, DesignTokens.Spacing.lg) - .padding(.vertical, DesignTokens.Spacing.md) - .glassEffect(in: Capsule(style: .continuous)) - .shadow( - color: .black.opacity(DesignTokens.Shadow.floatingOpacity), - radius: DesignTokens.Shadow.floatingRadius, - y: -DesignTokens.Shadow.floatingY - ) - } -} - -// MARK: - Previews - -#Preview("Info — collapsed") { - PreviewHost( - notification: InAppNotification( - level: .info, - title: "Bind Successful", - subtitle: "hallway_motion_sensor → living_room_light", - logEntryID: UUID() - ) - ) -} - -#Preview("Error — collapsed") { - PreviewHost( - notification: InAppNotification( - level: .error, - title: "Operation Failed", - subtitle: "Publish 'zigbee2mqtt/hallway_ff_sensor/set' failed because the device is offline and no response was received within the timeout window.", - logEntryID: UUID() - ) - ) -} - -#Preview("Warning — collapsed") { - PreviewHost( - notification: InAppNotification( - level: .warning, - title: "Device Left Network", - subtitle: "bedroom_thermostat", - logEntryID: UUID() - ) - ) -} - -#Preview("Error — expanded") { - PreviewHost( - notification: InAppNotification( - level: .error, - title: "Operation Failed", - subtitle: "Publish 'zigbee2mqtt/hallway_ff_sensor/set' failed because the device is offline and no response was received within the timeout window.", - logEntryID: UUID() - ), - expanded: true - ) -} - -#Preview("Coalesced burst (× 12)") { - PreviewHost( - notification: { - var n = InAppNotification( - level: .error, - title: "Operation Failed", - subtitle: "Interview of 'sensor_12' failed", - logEntryID: UUID() - ) - n.count = 12 - n.logEntryIDs = (0..<12).map { _ in UUID() } - return n - }() - ) -} - -#Preview("Coalesced — expanded") { - PreviewHost( - notification: { - var n = InAppNotification( - level: .warning, - title: "Interview Failed", - subtitle: "Most recent: 'attic_sensor_3'", - logEntryID: UUID() - ) - n.count = 4 - n.logEntryIDs = (0..<4).map { _ in UUID() } - return n - }(), - expanded: true - ) -} - -#Preview("Interview started — collapsed") { - PreviewHost( - notification: InAppNotification( - level: .info, - title: "Interviewing Device", - subtitle: "kitchen_motion_sensor", - logEntryID: UUID(), - deviceName: "kitchen_motion_sensor" - ) - ) -} - -#Preview("Interview successful — expanded") { - PreviewHost( - notification: InAppNotification( - level: .info, - title: "Interview Successful", - subtitle: "kitchen_motion_sensor", - logEntryID: UUID(), - deviceName: "kitchen_motion_sensor" - ), - expanded: true - ) -} - -#Preview("Interview failed — expanded") { - PreviewHost( - notification: InAppNotification( - level: .error, - title: "Interview Failed", - subtitle: "attic_thermostat", - logEntryID: UUID(), - deviceName: "attic_thermostat" - ), - expanded: true - ) -} - -#Preview("Info — no logEntry (Go to Log hidden)") { - PreviewHost( - notification: InAppNotification( - level: .info, - title: "Reporting Configured", - subtitle: "living_room_light" - ), - expanded: true - ) -} - -#Preview("Stacked carousel — 1 of 12 expanded") { - @Previewable @State var expanded = true - VStack { - Spacer() - InAppNotificationBanner( - notification: InAppNotification( - level: .error, - title: "Operation Failed", - subtitle: "Publish 'zigbee2mqtt/hallway_ff_sensor/set' failed", - logEntryID: UUID(), - deviceName: "hallway_ff_sensor" - ), - isExpanded: $expanded, - stackCount: 12, - stackPositionLabel: "1/12", - onDismiss: {}, - onGoToLog: {}, - onGoToDevice: {}, - onCopyMessage: {}, - onSwipeNext: {}, - onSwipePrevious: {} - ) - .padding(.bottom, 80) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) -} - -#Preview("Fast-track — Copied") { - VStack { - Spacer() - FastTrackBanner( - notification: InAppNotification( - level: .info, - title: "Copied to Clipboard", - priority: .fastTrack - ) - ) - .padding(.bottom, 80) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) -} - -private struct PreviewHost: View { - let notification: InAppNotification - var expanded: Bool = false - @State private var isExpanded = false - - var body: some View { - VStack { - Spacer() - InAppNotificationBanner( - notification: notification, - isExpanded: $isExpanded, - stackCount: 1, - stackPositionLabel: nil, - onDismiss: {}, - onGoToLog: {}, - onGoToDevice: {}, - onCopyMessage: {} - ) - .padding(.bottom, 80) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - .onAppear { isExpanded = expanded } - } -} diff --git a/Shellbee/Features/Settings/AcknowledgementsView.swift b/Shellbee/Features/Settings/AcknowledgementsView.swift index 77a4ca2..399b9a3 100644 --- a/Shellbee/Features/Settings/AcknowledgementsView.swift +++ b/Shellbee/Features/Settings/AcknowledgementsView.swift @@ -34,7 +34,7 @@ struct AcknowledgementsView: View { Image(systemName: "heart.fill") .font(.body) .foregroundStyle(.pink) - .frame(width: 28) + .frame(width: DesignTokens.Size.settingsIconFrame) VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { Text("Donate to Koenkk") .foregroundStyle(.primary) @@ -61,7 +61,7 @@ struct AcknowledgementsView: View { Image(systemName: "chevron.left.forwardslash.chevron.right") .font(.body) .foregroundStyle(.green) - .frame(width: 28) + .frame(width: DesignTokens.Size.settingsIconFrame) VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { HStack(spacing: DesignTokens.Spacing.sm) { Text(title) @@ -70,7 +70,7 @@ struct AcknowledgementsView: View { .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) .padding(.horizontal, DesignTokens.Spacing.xs) - .padding(.vertical, 2) + .padding(.vertical, DesignTokens.Spacing.xxs) .background(.quaternary, in: Capsule()) } Text(subtitle) diff --git a/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift b/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift index 0a58b75..d118dc0 100644 --- a/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift +++ b/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift @@ -8,7 +8,7 @@ struct RestoreGuideSheet: View { List { Section { Label { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { Text("Host-only operation") .font(.subheadline.weight(.semibold)) .foregroundStyle(.orange) @@ -72,23 +72,23 @@ struct RestoreGuideSheet: View { } private func stepRow(n: Int, title: String, body: String) -> some View { - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .top, spacing: DesignTokens.Spacing.md) { Text("\(n)") .font(.subheadline.weight(.bold)) .foregroundStyle(.white) - .frame(width: 24, height: 24) + .frame(width: DesignTokens.Size.restoreStepCircle, height: DesignTokens.Size.restoreStepCircle) .background(.indigo, in: Circle()) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { Text(title).font(.subheadline.weight(.semibold)) Text(body).font(.footnote).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } - .padding(.vertical, 2) + .padding(.vertical, DesignTokens.Spacing.xxs) } private func bulletRow(_ text: String) -> some View { - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) { Text("\u{2022}") .foregroundStyle(.secondary) Text(text) diff --git a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift index d12f7e0..25f98f2 100644 --- a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift +++ b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift @@ -32,7 +32,7 @@ struct MQTTInspectorView: View { } } .pickerStyle(.segmented) - .frame(width: 220) + .frame(width: DesignTokens.Size.inspectorTabPickerWidth) } } .onAppear { store.attach(session: environment.session) } @@ -40,141 +40,6 @@ struct MQTTInspectorView: View { } } -// MARK: - Model - -@Observable -final class SubscribeStore { - var messages: [InspectorMessage] = [] - var paused: Bool = false - var filter: String = "" - let bufferCap: Int = 1000 - - func attach(session: ConnectionSessionController) { - session.rawInboundTap = { [weak self] topic, payload in - guard let self, !self.paused else { return } - let msg = InspectorMessage(timestamp: .now, topic: topic, payload: payload) - Task { @MainActor [weak self] in - guard let self else { return } - self.messages.append(msg) - if self.messages.count > self.bufferCap { - self.messages.removeFirst(self.messages.count - self.bufferCap) - } - } - } - } - - func detach(session: ConnectionSessionController) { - session.rawInboundTap = nil - } - - func clear() { - messages.removeAll() - } - - var filtered: [InspectorMessage] { - let f = filter.trimmingCharacters(in: .whitespaces) - guard !f.isEmpty else { return messages } - return messages.filter { $0.topic.localizedCaseInsensitiveContains(f) } - } -} - -struct InspectorMessage: Identifiable, Equatable { - let id = UUID() - let timestamp: Date - let topic: String - let payload: JSONValue - - var prettyPayload: String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - guard let data = try? encoder.encode(payload), - let text = String(data: data, encoding: .utf8) else { - return "" - } - return text - } - - /// Z2M log messages on `bridge/logging` carry a `level` field — surface - /// that color on the row icon to match the raw logs view. - var logLevelColor: Color { - guard topic == Z2MTopics.bridgeLogging, - let level = payload.object?["level"]?.stringValue, - let parsed = LogLevel(rawValue: level.lowercased()) else { - return .secondary - } - return parsed.color - } - - var logLevelIcon: String { - guard topic == Z2MTopics.bridgeLogging, - let level = payload.object?["level"]?.stringValue, - let parsed = LogLevel(rawValue: level.lowercased()) else { - return "dot.radiowaves.up.forward" - } - return parsed.systemImage - } -} - -// MARK: - JSON syntax highlighting - -enum JSONHighlighter { - static func attributed(_ source: String) -> AttributedString { - var out = AttributedString(source) - out.font = .caption.monospaced() - out.foregroundColor = .secondary - - // Keys: "" : → blue - if let regex = try? Regex<(Substring, Substring)>("\"([^\"\\\\]+)\"\\s*:") { - for match in source.matches(of: regex) { - let r = match.range - if let lower = AttributedString.Index(r.lowerBound, within: out), - let upper = AttributedString.Index(r.upperBound, within: out) { - out[lower..(":\\s*(\"[^\"\\\\]*\")") { - for match in source.matches(of: regex) { - let inner = match.output.1 - let r = inner.startIndex..("(?("\\b\(word)\\b") { - for match in source.matches(of: regex) { - let r = match.range - if let lower = AttributedString.Index(r.lowerBound, within: out), - let upper = AttributedString.Index(r.upperBound, within: out) { - out[lower.. 6 { Button { - withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } + withAnimation(.easeInOut(duration: DesignTokens.Duration.quickFade)) { expanded.toggle() } } label: { Text(expanded ? "Show less" : "Show more") .font(.caption.weight(.medium)) } .buttonStyle(.borderless) - .padding(.leading, 22) + .padding(.leading, DesignTokens.Size.cardSymbol) } } - .padding(.vertical, 2) + .padding(.vertical, DesignTokens.Spacing.xxs) } } diff --git a/Shellbee/Features/Settings/Developer/SubscribeStore.swift b/Shellbee/Features/Settings/Developer/SubscribeStore.swift new file mode 100644 index 0000000..9828ddd --- /dev/null +++ b/Shellbee/Features/Settings/Developer/SubscribeStore.swift @@ -0,0 +1,77 @@ +import SwiftUI + +/// Captures every inbound MQTT message routed through the active session +/// and exposes a filtered, capped view suitable for the MQTT Inspector's +/// Subscribe tab. Backed by `ConnectionSessionController.rawInboundTap`. +@Observable +final class SubscribeStore { + var messages: [InspectorMessage] = [] + var paused: Bool = false + var filter: String = "" + let bufferCap: Int = 1000 + + func attach(session: ConnectionSessionController) { + session.rawInboundTap = { [weak self] topic, payload in + guard let self, !self.paused else { return } + let msg = InspectorMessage(timestamp: .now, topic: topic, payload: payload) + Task { @MainActor [weak self] in + guard let self else { return } + self.messages.append(msg) + if self.messages.count > self.bufferCap { + self.messages.removeFirst(self.messages.count - self.bufferCap) + } + } + } + } + + func detach(session: ConnectionSessionController) { + session.rawInboundTap = nil + } + + func clear() { + messages.removeAll() + } + + var filtered: [InspectorMessage] { + let f = filter.trimmingCharacters(in: .whitespaces) + guard !f.isEmpty else { return messages } + return messages.filter { $0.topic.localizedCaseInsensitiveContains(f) } + } +} + +struct InspectorMessage: Identifiable, Equatable { + let id = UUID() + let timestamp: Date + let topic: String + let payload: JSONValue + + var prettyPayload: String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(payload), + let text = String(data: data, encoding: .utf8) else { + return "" + } + return text + } + + /// Z2M log messages on `bridge/logging` carry a `level` field — surface + /// that color on the row icon to match the raw logs view. + var logLevelColor: Color { + guard topic == Z2MTopics.bridgeLogging, + let level = payload.object?["level"]?.stringValue, + let parsed = LogLevel(rawValue: level.lowercased()) else { + return .secondary + } + return parsed.color + } + + var logLevelIcon: String { + guard topic == Z2MTopics.bridgeLogging, + let level = payload.object?["level"]?.stringValue, + let parsed = LogLevel(rawValue: level.lowercased()) else { + return "dot.radiowaves.up.forward" + } + return parsed.systemImage + } +} diff --git a/Shellbee/Features/Settings/DocBrowserView.swift b/Shellbee/Features/Settings/DocBrowserView.swift index 60fcb1f..9857b9a 100644 --- a/Shellbee/Features/Settings/DocBrowserView.swift +++ b/Shellbee/Features/Settings/DocBrowserView.swift @@ -202,7 +202,7 @@ private struct DocEntryRow: View { .transition(.opacity.combined(with: .scale(scale: 0.95))) } placeholder: { Image(systemName: entry.deviceType?.systemImage ?? "cpu") - .font(.system(size: size * 0.5, weight: .medium)) + .font(.system(size: size * DesignTokens.Typography.iconRatioHalf, weight: .medium)) .foregroundStyle(Color.accentColor) } .frame(width: size, height: size) diff --git a/Shellbee/Shared/ClimateControl/ClimateControlCard.swift b/Shellbee/Shared/ClimateControl/ClimateControlCard.swift index fafb79d..747eec4 100644 --- a/Shellbee/Shared/ClimateControl/ClimateControlCard.swift +++ b/Shellbee/Shared/ClimateControl/ClimateControlCard.swift @@ -62,7 +62,7 @@ struct ClimateControlCard: View { Color(.secondarySystemGroupedBackground) LinearGradient( colors: [heroTint.opacity(isActive ? 0.18 : 0.06), - heroTint.opacity(0.04)], + heroTint.opacity(DesignTokens.Opacity.subtleFade)], startPoint: .topLeading, endPoint: .bottomTrailing ) @@ -82,13 +82,13 @@ struct ClimateControlCard: View { } private var heroEyebrow: some View { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: heroIcon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(context.runningStateLabel) - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } @@ -106,22 +106,22 @@ struct ClimateControlCard: View { @ViewBuilder private var heroValue: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { Text(context.displayTemperature) - .font(.system(size: 56, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroValue) .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.6) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMedium) // In snapshot mode (and when no interactive setpoint control is // shown), surface the target inside the hero block — otherwise the // setpoint row below already carries it. if let setpoint = context.activeSetpoint, !showsSetpointControl { Text("Target \(formatTemp(setpoint))") - .font(.system(size: 20, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.heroSubtitle) .foregroundStyle(heroTint) .lineLimit(1) - .minimumScaleFactor(0.7) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorRelaxed) } } } @@ -132,8 +132,8 @@ struct ClimateControlCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) + .frame(height: DesignTokens.Size.hairline) } // MARK: - Setpoint row @@ -146,13 +146,13 @@ struct ClimateControlCard: View { private var setpointRow: some View { HStack(alignment: .center, spacing: DesignTokens.Spacing.md) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "target") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text("Target") - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) } .foregroundStyle(.secondary) @@ -168,10 +168,10 @@ struct ClimateControlCard: View { } Text(formatTemp(setpointDraft)) - .font(.system(size: 24, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.identityTileValue) .monospacedDigit() .foregroundStyle(heroTint) - .frame(minWidth: 72) + .frame(minWidth: DesignTokens.Size.climateSetpointMinWidth) .contentTransition(.numericText(value: setpointDraft)) .animation(.snappy, value: setpointDraft) @@ -188,10 +188,10 @@ struct ClimateControlCard: View { private func setpointButton(systemImage: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: systemImage) - .font(.system(size: 14, weight: .bold)) + .font(DesignTokens.Typography.climateActionIcon) .foregroundStyle(heroTint) - .frame(width: 32, height: 32) - .background(heroTint.opacity(0.15), in: Circle()) + .frame(width: DesignTokens.Size.climateActionButton, height: DesignTokens.Size.climateActionButton) + .background(heroTint.opacity(DesignTokens.Opacity.actionButtonFill), in: Circle()) } .buttonStyle(.plain) } @@ -201,13 +201,13 @@ struct ClimateControlCard: View { @ViewBuilder private func modeRow(modes: [String]) -> some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "dial.medium") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text("Mode") - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) } .foregroundStyle(.secondary) @@ -233,7 +233,7 @@ struct ClimateControlCard: View { .padding(.horizontal, DesignTokens.Spacing.md) .padding(.vertical, DesignTokens.Spacing.sm) .background( - isSelected ? chipTint.opacity(0.20) : Color(.tertiarySystemFill), + isSelected ? chipTint.opacity(DesignTokens.Opacity.strongAccentFill) : Color(.tertiarySystemFill), in: Capsule() ) .foregroundStyle(isSelected ? chipTint : Color.primary) diff --git a/Shellbee/Shared/Components/DeviceCard.swift b/Shellbee/Shared/Components/DeviceCard.swift index de7bc7b..0e6db22 100644 --- a/Shellbee/Shared/Components/DeviceCard.swift +++ b/Shellbee/Shared/Components/DeviceCard.swift @@ -37,7 +37,7 @@ struct DeviceCard: View { hairline metricsGrid } - .animation(.easeInOut(duration: 0.2), value: isUpdating) + .animation(.easeInOut(duration: DesignTokens.Duration.fastFade), value: isUpdating) .padding(DesignTokens.Spacing.xl) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.secondarySystemGroupedBackground), @@ -56,10 +56,10 @@ struct DeviceCard: View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { Text(device.friendlyName) - .font(.system(size: 20, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.compactCardTitle) .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.72) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMildLight) Text("\(vendor) · \(model)") .font(.subheadline) @@ -138,28 +138,28 @@ struct DeviceCard: View { private func identityMetric(label: String, icon: String, value: String, unit: String?, color: Color) -> some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(alignment: .firstTextBaseline, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(label) - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(.system(size: 24, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.identityTileValue) .monospacedDigit() .foregroundStyle(color) .lineLimit(1) - .minimumScaleFactor(0.55) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) if let unit { Text(unit) - .font(.system(size: 14, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.identityTileUnit) .foregroundStyle(.secondary) .lineLimit(1) } @@ -183,10 +183,10 @@ struct DeviceCard: View { @ViewBuilder private var nameView: some View { let label = Text(device.friendlyName) - .font(.system(size: 24, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.cardTitle) .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.45) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorAggressive) .allowsTightening(true) if let onRenameTapped { @@ -202,18 +202,18 @@ struct DeviceCard: View { } private var deviceMetadata: some View { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { Text(vendor) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) - .minimumScaleFactor(0.82) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorSubtle) Text(model) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) - .minimumScaleFactor(0.72) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMildLight) } } @@ -228,8 +228,8 @@ struct DeviceCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) + .frame(height: DesignTokens.Size.hairline) } @ViewBuilder @@ -372,7 +372,7 @@ struct DeviceCard: View { } #Preview { - VStack(spacing: 20) { + VStack(spacing: DesignTokens.Spacing.xl) { DeviceCard( device: .preview, state: [ diff --git a/Shellbee/Shared/Components/DeviceCardFooterBar.swift b/Shellbee/Shared/Components/DeviceCardFooterBar.swift index 4768ecc..2dad644 100644 --- a/Shellbee/Shared/Components/DeviceCardFooterBar.swift +++ b/Shellbee/Shared/Components/DeviceCardFooterBar.swift @@ -20,10 +20,10 @@ struct DeviceCardFooterBar: View { private func statCell(value: String, label: String, color: Color) -> some View { VStack(spacing: DesignTokens.Spacing.summaryRowVerticalPadding) { Text(value) - .font(.system(size: 13, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.footerActionLabel) .foregroundStyle(color) .lineLimit(1) - .minimumScaleFactor(0.75) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMild) Text(label) .font(.caption2) .foregroundStyle(.tertiary) @@ -45,14 +45,14 @@ struct DeviceCardFooterBar: View { private var powerStatValue: some View { if device.type == .endDevice, let battery = state.battery { Text("\(battery)%") - .font(.system(size: 13, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.footerActionLabel) .foregroundStyle(battery.batteryColor) } else { Text(normalizedPowerSource) - .font(.system(size: 13, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.footerActionLabel) .foregroundStyle(Color.secondary) .lineLimit(1) - .minimumScaleFactor(0.75) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMild) } } diff --git a/Shellbee/Shared/Components/DeviceCardHeader.swift b/Shellbee/Shared/Components/DeviceCardHeader.swift index 00c1422..04b32d4 100644 --- a/Shellbee/Shared/Components/DeviceCardHeader.swift +++ b/Shellbee/Shared/Components/DeviceCardHeader.swift @@ -52,7 +52,7 @@ struct DeviceCardHeader: View { .font(DesignTokens.Typography.cardHeadline) .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.6) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMedium) .allowsTightening(true) if let onRenameTapped { diff --git a/Shellbee/Shared/Components/DeviceMetricItem.swift b/Shellbee/Shared/Components/DeviceMetricItem.swift index a9c4b0c..bb26720 100644 --- a/Shellbee/Shared/Components/DeviceMetricItem.swift +++ b/Shellbee/Shared/Components/DeviceMetricItem.swift @@ -16,7 +16,7 @@ struct DeviceMetricItem: View { .font(.subheadline.bold()) .monospacedDigit() .lineLimit(1) - .minimumScaleFactor(0.7) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorRelaxed) .frame(height: DesignTokens.Size.deviceCardMetricValueHeight) Text(label) diff --git a/Shellbee/Shared/Components/Doc/DefaultDocSectionView.swift b/Shellbee/Shared/Components/Doc/DefaultDocSectionView.swift index 915861d..274db30 100644 --- a/Shellbee/Shared/Components/Doc/DefaultDocSectionView.swift +++ b/Shellbee/Shared/Components/Doc/DefaultDocSectionView.swift @@ -24,7 +24,7 @@ struct SectionHeader: View { .frame(width: DesignTokens.Size.docSectionIconFrame, height: DesignTokens.Size.docSectionIconFrame) Image(systemName: iconName) - .font(.system(size: 15, weight: .semibold)) + .font(DesignTokens.Typography.sectionHeader) .foregroundStyle(iconColor) } Text(title) @@ -63,7 +63,7 @@ struct SectionHeader: View { #Preview { ScrollView { - VStack(spacing: 20) { + VStack(spacing: DesignTokens.Spacing.xl) { DefaultDocSectionView(section: DocSection( title: "Notes", level: 2, diff --git a/Shellbee/Shared/Components/Doc/DeviceInfoCardView.swift b/Shellbee/Shared/Components/Doc/DeviceInfoCardView.swift index 5929b43..8350760 100644 --- a/Shellbee/Shared/Components/Doc/DeviceInfoCardView.swift +++ b/Shellbee/Shared/Components/Doc/DeviceInfoCardView.swift @@ -32,7 +32,7 @@ struct DeviceInfoCardView: View { Text(label) .font(.subheadline) .foregroundStyle(.secondary) - .frame(width: 90, alignment: .leading) + .frame(width: DesignTokens.Size.docLabelColumnWidth, alignment: .leading) Text(value) .font(.subheadline) diff --git a/Shellbee/Shared/Components/Doc/DocBlockView.swift b/Shellbee/Shared/Components/Doc/DocBlockView.swift index 6ba1a18..7160f6f 100644 --- a/Shellbee/Shared/Components/Doc/DocBlockView.swift +++ b/Shellbee/Shared/Components/Doc/DocBlockView.swift @@ -68,7 +68,7 @@ private struct BulletListView: View { HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) { Text("•") .foregroundStyle(.secondary) - .padding(.top, 1) + .padding(.top, DesignTokens.Spacing.xxs) DocInlineTextView(spans: spans, sourcePath: sourcePath) .font(.body) .fixedSize(horizontal: false, vertical: true) @@ -105,7 +105,7 @@ private struct DocTableView: View { .padding(.vertical, DesignTokens.Spacing.xs) } } - .background(idx % 2 == 0 ? Color.clear : .secondary.opacity(0.05)) + .background(idx % 2 == 0 ? Color.clear : .secondary.opacity(DesignTokens.Opacity.veryLight)) } } .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.sm)) @@ -136,7 +136,7 @@ private struct SubsectionView: View { #Preview { ScrollView { - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xl) { DocBlockView(block: .paragraph([.text("IKEA lights only support transitions on "), .bold("1 attribute"), .text(" at a time.")])) DocBlockView(block: .note([.text("Keep the bulb close to the coordinator during pairing.")])) DocBlockView(block: .stepList([ diff --git a/Shellbee/Shared/Components/Doc/DocHeroCard.swift b/Shellbee/Shared/Components/Doc/DocHeroCard.swift index 4287912..015eddf 100644 --- a/Shellbee/Shared/Components/Doc/DocHeroCard.swift +++ b/Shellbee/Shared/Components/Doc/DocHeroCard.swift @@ -35,7 +35,7 @@ struct DocHeroCard: View { HStack(alignment: .top, spacing: DesignTokens.Spacing.lg) { DeviceImageView(device: device, isAvailable: true, size: DesignTokens.Size.deviceActionSheetImage * 1.4) .frame(width: DesignTokens.Size.deviceActionSheetImage * 1.5, height: DesignTokens.Size.deviceActionSheetImage * 1.5) - .shadow(color: Color.black.opacity(0.08), radius: 8, x: 0, y: 4) + .shadow(color: Color.black.opacity(DesignTokens.Opacity.hairline), radius: 8, x: 0, y: 4) VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { if let eyebrow, !eyebrow.isEmpty { @@ -69,17 +69,17 @@ struct DocHeroCard: View { static var defaultGradient: [Color] { [ - Color.accentColor.opacity(0.22), - Color.blue.opacity(0.14), - Color.blue.opacity(0.06) + Color.accentColor.opacity(DesignTokens.Opacity.mildOpaque), + Color.blue.opacity(DesignTokens.Opacity.mediumAccentFill), + Color.blue.opacity(DesignTokens.Opacity.offStateTint) ] } static var pairingGradient: [Color] { [ - Color.blue.opacity(0.22), - Color.cyan.opacity(0.14), - Color.cyan.opacity(0.06) + Color.blue.opacity(DesignTokens.Opacity.mildOpaque), + Color.cyan.opacity(DesignTokens.Opacity.mediumAccentFill), + Color.cyan.opacity(DesignTokens.Opacity.offStateTint) ] } } diff --git a/Shellbee/Shared/Components/Doc/DocInlineTextView.swift b/Shellbee/Shared/Components/Doc/DocInlineTextView.swift index 5b70b44..2d25886 100644 --- a/Shellbee/Shared/Components/Doc/DocInlineTextView.swift +++ b/Shellbee/Shared/Components/Doc/DocInlineTextView.swift @@ -172,7 +172,7 @@ private struct InAppLinkUnavailableView: View { } #Preview { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.lg) { DocInlineTextView(spans: [ .text("Keep the bulb "), .bold("close to the coordinator"), diff --git a/Shellbee/Shared/Components/Doc/DocNoteView.swift b/Shellbee/Shared/Components/Doc/DocNoteView.swift index 0c8df6f..2f81208 100644 --- a/Shellbee/Shared/Components/Doc/DocNoteView.swift +++ b/Shellbee/Shared/Components/Doc/DocNoteView.swift @@ -26,12 +26,12 @@ struct DocNoteView: View { .padding(.horizontal, DesignTokens.Spacing.sm) .padding(.vertical, DesignTokens.Spacing.sm) .frame(maxWidth: .infinity, alignment: .leading) - .background(.tint.opacity(0.06), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.sm)) + .background(.tint.opacity(DesignTokens.Opacity.offStateTint), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.sm)) } } #Preview { - VStack(spacing: 16) { + VStack(spacing: DesignTokens.Spacing.lg) { DocNoteView(spans: [ .text("Keep the bulb "), .bold("close to the coordinator"), diff --git a/Shellbee/Shared/Components/Doc/DocOptionRowView.swift b/Shellbee/Shared/Components/Doc/DocOptionRowView.swift index a335db4..e5bf8ce 100644 --- a/Shellbee/Shared/Components/Doc/DocOptionRowView.swift +++ b/Shellbee/Shared/Components/Doc/DocOptionRowView.swift @@ -49,7 +49,7 @@ struct DocOptionRowView: View { } #Preview { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.lg) { DocOptionRowView(option: DocOption( name: "transition", type: "number", diff --git a/Shellbee/Shared/Components/Doc/DocStepListView.swift b/Shellbee/Shared/Components/Doc/DocStepListView.swift index aa40435..ee9a089 100644 --- a/Shellbee/Shared/Components/Doc/DocStepListView.swift +++ b/Shellbee/Shared/Components/Doc/DocStepListView.swift @@ -43,7 +43,7 @@ private struct DocStepRow: View { } if showConnector { Rectangle() - .fill(.tint.opacity(0.25)) + .fill(.tint.opacity(DesignTokens.Opacity.pressedAlpha)) .frame(width: DesignTokens.Size.docStepConnector) .frame(maxHeight: .infinity) } @@ -54,7 +54,7 @@ private struct DocStepRow: View { .font(.body) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) + .padding(.top, DesignTokens.Spacing.xs) .padding(.bottom, showConnector ? DesignTokens.Spacing.xl : 0) } } diff --git a/Shellbee/Shared/Components/Doc/DocumentationExperienceView.swift b/Shellbee/Shared/Components/Doc/DocumentationExperienceView.swift index 2567920..cf66015 100644 --- a/Shellbee/Shared/Components/Doc/DocumentationExperienceView.swift +++ b/Shellbee/Shared/Components/Doc/DocumentationExperienceView.swift @@ -439,7 +439,7 @@ private struct DocumentationCalloutView: View { .foregroundStyle(.secondary) } .padding(DesignTokens.Spacing.md) - .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.md)) + .background(tint.opacity(DesignTokens.Opacity.hairline), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.md)) } } diff --git a/Shellbee/Shared/Components/Doc/PairingGuideExperienceView.swift b/Shellbee/Shared/Components/Doc/PairingGuideExperienceView.swift index 41d52fb..3ef3669 100644 --- a/Shellbee/Shared/Components/Doc/PairingGuideExperienceView.swift +++ b/Shellbee/Shared/Components/Doc/PairingGuideExperienceView.swift @@ -158,10 +158,10 @@ private struct PhilipsHueSerialResetCard: View { .buttonStyle(.plain) .frame(maxWidth: .infinity, alignment: .leading) .padding(DesignTokens.Spacing.lg) - .background(Color.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous)) + .background(Color.blue.opacity(DesignTokens.Opacity.hairline), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous) - .strokeBorder(Color.blue.opacity(0.2)) + .strokeBorder(Color.blue.opacity(DesignTokens.Opacity.accentFill)) ) .sheet(isPresented: $showResetSheet) { PhilipsHueResetSheet( @@ -214,10 +214,10 @@ private struct TouchlinkResetCard: View { .buttonStyle(.plain) .frame(maxWidth: .infinity, alignment: .leading) .padding(DesignTokens.Spacing.lg) - .background(Color.teal.opacity(0.08), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous)) + .background(Color.teal.opacity(DesignTokens.Opacity.hairline), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous) - .strokeBorder(Color.teal.opacity(0.2)) + .strokeBorder(Color.teal.opacity(DesignTokens.Opacity.accentFill)) ) } } @@ -301,7 +301,7 @@ private struct PairingCalloutView: View { .font(.subheadline) } .padding(DesignTokens.Spacing.md) - .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.md)) + .background(tint.opacity(DesignTokens.Opacity.hairline), in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.md)) } } diff --git a/Shellbee/Shared/Components/FeatureIconTile.swift b/Shellbee/Shared/Components/FeatureIconTile.swift index 45f2db9..99ea8e4 100644 --- a/Shellbee/Shared/Components/FeatureIconTile.swift +++ b/Shellbee/Shared/Components/FeatureIconTile.swift @@ -9,12 +9,12 @@ struct FeatureIconTile: View { var prominent: Bool = false var body: some View { - RoundedRectangle(cornerRadius: size * 0.27, style: .continuous) + RoundedRectangle(cornerRadius: size * DesignTokens.Ratio.featureTileCorner, style: .continuous) .fill(tint.gradient) .frame(width: size, height: size) .overlay { Image(systemName: symbol) - .font(.system(size: prominent ? size * 0.55 : size * 0.5, + .font(.system(size: prominent ? size * DesignTokens.Typography.iconRatioMedium : size * DesignTokens.Typography.iconRatioHalf, weight: .semibold)) .foregroundStyle(.white) } diff --git a/Shellbee/Shared/Components/FilterChip.swift b/Shellbee/Shared/Components/FilterChip.swift index c972414..d9c34d5 100644 --- a/Shellbee/Shared/Components/FilterChip.swift +++ b/Shellbee/Shared/Components/FilterChip.swift @@ -12,11 +12,11 @@ struct FilterChip: View { .font(.subheadline.weight(isSelected ? .semibold : .regular)) .padding(.horizontal, DesignTokens.Spacing.md) .padding(.vertical, DesignTokens.Spacing.xs) - .background(isSelected ? Color.accentColor : Color.secondary.opacity(0.12), in: Capsule()) + .background(isSelected ? Color.accentColor : Color.secondary.opacity(DesignTokens.Opacity.chipFill), in: Capsule()) .foregroundStyle(isSelected ? .white : .primary) } .buttonStyle(.plain) - .animation(.easeInOut(duration: 0.15), value: isSelected) + .animation(.easeInOut(duration: DesignTokens.Duration.quickFade), value: isSelected) } } diff --git a/Shellbee/Shared/Components/JSONHighlighter.swift b/Shellbee/Shared/Components/JSONHighlighter.swift new file mode 100644 index 0000000..acf2938 --- /dev/null +++ b/Shellbee/Shared/Components/JSONHighlighter.swift @@ -0,0 +1,62 @@ +import SwiftUI + +/// Lightweight JSON syntax highlighting via regex over a pretty-printed +/// source string. Used by the MQTT Inspector to color message payloads, but +/// generic enough to drop into any view that surfaces JSON to the user. +enum JSONHighlighter { + static func attributed(_ source: String) -> AttributedString { + var out = AttributedString(source) + out.font = .caption.monospaced() + out.foregroundColor = .secondary + + // Keys: "" : → blue + if let regex = try? Regex<(Substring, Substring)>("\"([^\"\\\\]+)\"\\s*:") { + for match in source.matches(of: regex) { + let r = match.range + if let lower = AttributedString.Index(r.lowerBound, within: out), + let upper = AttributedString.Index(r.upperBound, within: out) { + out[lower..(":\\s*(\"[^\"\\\\]*\")") { + for match in source.matches(of: regex) { + let inner = match.output.1 + let r = inner.startIndex..("(?("\\b\(word)\\b") { + for match in source.matches(of: regex) { + let r = match.range + if let lower = AttributedString.Index(r.lowerBound, within: out), + let upper = AttributedString.Index(r.upperBound, within: out) { + out[lower.. some View { let parts = formatDurationParts(minutes) return VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(alignment: .firstTextBaseline, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(label) - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(parts.value) - .font(.system(size: 30, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.featureTileValue) .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.55) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) Text(parts.unit) - .font(.system(size: 15, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.featureTileUnit) .foregroundStyle(.secondary) } } @@ -415,8 +415,8 @@ struct FanControlCard: View { private func sectionView(_ section: LayoutSection) -> some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { Text(section.title) - .font(.system(size: 12, weight: .semibold)) - .tracking(0.6) + .font(DesignTokens.Typography.sectionHeaderLabel) + .tracking(DesignTokens.Typography.sectionHeaderTracking) .textCase(.uppercase) .foregroundStyle(.secondary) .padding(.leading, DesignTokens.Spacing.md) @@ -477,56 +477,6 @@ struct FanControlCard: View { } } -// MARK: - Native list sections - -/// Renders the Fan device's feature sections (Behaviour, Indicators, etc.) as -/// native `List` sections. Place inside a `List` whose `.listStyle` is grouped -/// or inset-grouped. The hero / filter cards are still rendered by -/// `FanControlCard` (with `rendersSectionsInline: false`). -struct FanFeatureSections: View { - let context: FanControlContext - let mode: CardDisplayMode - let onSend: (JSONValue) -> Void - - private let filterProps: Set = ["replace_filter", "filter_age", "device_age"] - - private var eligibleExtras: [Expose] { - let claimed: Set = Set(["pm25", "air_quality"]).union(filterProps) - return context.extras.filter { e in - guard let prop = e.property else { return false } - return !claimed.contains(prop) - } - } - - private var sections: [LayoutSection] { FeatureLayout.sections(from: eligibleExtras) } - - var body: some View { - ForEach(sections) { section in - Section(section.title) { - ForEach(section.items, id: \.id) { item in - rowFor(item) - } - } - } - } - - @ViewBuilder - private func rowFor(_ item: LayoutItem) -> some View { - switch item { - case .row(let expose): - SettingsFormRow(expose: expose, state: context.state, mode: mode, onSend: onSend) - case .indexedGroup(let group): - NavigationLink { - FeatureGroupDetailView(group: group, state: context.state, mode: mode, onSend: onSend) - } label: { - LabeledContent(group.label) { - Text("\(group.members.count)") - } - } - } - } -} - // MARK: - Disclosure row (monochrome, local to fan card) private struct DisclosureRow: View { @@ -542,7 +492,7 @@ private struct DisclosureRow: View { Button(action: action) { HStack(spacing: DesignTokens.Spacing.md) { Image(systemName: symbol) - .font(.system(size: 16, weight: .medium)) + .font(DesignTokens.Typography.formRowIcon) .symbolRenderingMode(.hierarchical) .foregroundStyle(.secondary) .frame(width: iconWidth) @@ -563,172 +513,6 @@ private struct DisclosureRow: View { } } -// MARK: - Extra row - -private struct FanExtraRow: View { - let expose: Expose - let state: [String: JSONValue] - let mode: CardDisplayMode - let horizontalPadding: CGFloat - let verticalPadding: CGFloat - let iconWidth: CGFloat - let onSend: (JSONValue) -> Void - - @State private var numericDraft: Double = 0 - - private var property: String { expose.property ?? expose.name ?? "" } - private var meta: FeatureMeta { FeatureCatalog.meta(for: property, exposeType: expose.type) } - private var label: String { meta.label } - private var stateValue: JSONValue? { state[property] } - - var body: some View { - rowContent - .padding(.horizontal, horizontalPadding) - .padding(.vertical, verticalPadding) - } - - @ViewBuilder - private var rowContent: some View { - switch expose.type { - case "binary": binaryRow - case "enum": enumRow - case "numeric": numericRow - default: textRow - } - } - - private var leadingIcon: some View { - Image(systemName: meta.symbol) - .font(.system(size: 16, weight: .medium)) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.secondary) - .frame(width: iconWidth) - } - - @ViewBuilder - private var binaryRow: some View { - let isOn = stateValue == expose.valueOn || stateValue?.boolValue == true - HStack(spacing: DesignTokens.Spacing.md) { - leadingIcon - labelStack - Spacer() - if mode == .interactive, expose.isWritable, - let on = expose.valueOn, let off = expose.valueOff { - Toggle("", isOn: Binding( - get: { isOn }, - set: { v in onSend(.object([property: v ? on : off])) } - )) - .labelsHidden() - } else { - Text(isOn ? "On" : "Off").foregroundStyle(.secondary) - } - } - } - - @ViewBuilder - private var enumRow: some View { - let values = expose.values ?? [] - let current = stateValue?.stringValue ?? "—" - HStack(spacing: DesignTokens.Spacing.md) { - leadingIcon - labelStack - Spacer() - if mode == .interactive, expose.isWritable, !values.isEmpty { - Menu { - ForEach(values, id: \.self) { v in - Button { - onSend(.object([property: .string(v)])) - } label: { - if current == v { - Label(prettify(v), systemImage: "checkmark") - } else { - Text(prettify(v)) - } - } - } - } label: { - HStack(spacing: 4) { - Text(prettify(current)) - Image(systemName: "chevron.up.chevron.down") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.tertiary) - } - } - .tint(.primary) - } else { - Text(prettify(current)).foregroundStyle(.secondary) - } - } - } - - @ViewBuilder - private var numericRow: some View { - let current = stateValue?.numberValue ?? 0 - let unit = expose.unit ?? "" - let writable = mode == .interactive && expose.isWritable - && expose.valueMin != nil && expose.valueMax != nil - - if writable, let min = expose.valueMin, let max = expose.valueMax { - VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(spacing: DesignTokens.Spacing.md) { - leadingIcon - labelStack - Spacer() - Text(formatNumeric(numericDraft, unit: unit)) - .font(.body.monospacedDigit()) - .foregroundStyle(.secondary) - } - Slider(value: $numericDraft, in: min...max, step: expose.valueStep ?? 1) { editing in - guard !editing else { return } - onSend(.object([property: numericPayload(numericDraft, step: expose.valueStep)])) - } - .padding(.leading, iconWidth + DesignTokens.Spacing.md) - } - .onAppear { numericDraft = current } - .onChange(of: current) { _, v in numericDraft = v } - } else { - HStack(spacing: DesignTokens.Spacing.md) { - leadingIcon - labelStack - Spacer() - Text(formatNumeric(current, unit: unit)) - .font(.body.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - } - - private func numericPayload(_ v: Double, step: Double?) -> JSONValue { - if let step, step.truncatingRemainder(dividingBy: 1) == 0 { - return .int(Int(v.rounded())) - } - return .double(v) - } - - @ViewBuilder - private var textRow: some View { - HStack(spacing: DesignTokens.Spacing.md) { - leadingIcon - labelStack - Spacer() - Text(stateValue?.stringified ?? "—").foregroundStyle(.secondary) - } - } - - @ViewBuilder - private var labelStack: some View { - Text(label).font(.body) - } - - private func prettify(_ s: String) -> String { - s.replacingOccurrences(of: "_", with: " ").capitalized - } - - private func formatNumeric(_ v: Double, unit: String) -> String { - let formatted = v.formatted(.number.precision(.fractionLength(0...1))) - return unit.isEmpty ? formatted : "\(formatted) \(unit)" - } -} #Preview { ScrollView { diff --git a/Shellbee/Shared/FanControl/FanExtraRow.swift b/Shellbee/Shared/FanControl/FanExtraRow.swift new file mode 100644 index 0000000..cb05492 --- /dev/null +++ b/Shellbee/Shared/FanControl/FanExtraRow.swift @@ -0,0 +1,170 @@ +import SwiftUI + +/// A row that renders an arbitrary fan-related `Expose` (binary / enum / +/// numeric / text) inline inside the `FanControlCard`'s "Extras" section. +/// Mirrors the row chrome of `SettingsFormRow` but is monochrome and +/// fan-specific so it can sit on the card surface rather than in a `List`. +struct FanExtraRow: View { + let expose: Expose + let state: [String: JSONValue] + let mode: CardDisplayMode + let horizontalPadding: CGFloat + let verticalPadding: CGFloat + let iconWidth: CGFloat + let onSend: (JSONValue) -> Void + + @State private var numericDraft: Double = 0 + + private var property: String { expose.property ?? expose.name ?? "" } + private var meta: FeatureMeta { FeatureCatalog.meta(for: property, exposeType: expose.type) } + private var label: String { meta.label } + private var stateValue: JSONValue? { state[property] } + + var body: some View { + rowContent + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + } + + @ViewBuilder + private var rowContent: some View { + switch expose.type { + case "binary": binaryRow + case "enum": enumRow + case "numeric": numericRow + default: textRow + } + } + + private var leadingIcon: some View { + Image(systemName: meta.symbol) + .font(DesignTokens.Typography.formRowIcon) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(width: iconWidth) + } + + @ViewBuilder + private var binaryRow: some View { + let isOn = stateValue == expose.valueOn || stateValue?.boolValue == true + HStack(spacing: DesignTokens.Spacing.md) { + leadingIcon + labelStack + Spacer() + if mode == .interactive, expose.isWritable, + let on = expose.valueOn, let off = expose.valueOff { + Toggle("", isOn: Binding( + get: { isOn }, + set: { v in onSend(.object([property: v ? on : off])) } + )) + .labelsHidden() + } else { + Text(isOn ? "On" : "Off").foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var enumRow: some View { + let values = expose.values ?? [] + let current = stateValue?.stringValue ?? "—" + HStack(spacing: DesignTokens.Spacing.md) { + leadingIcon + labelStack + Spacer() + if mode == .interactive, expose.isWritable, !values.isEmpty { + Menu { + ForEach(values, id: \.self) { v in + Button { + onSend(.object([property: .string(v)])) + } label: { + if current == v { + Label(prettify(v), systemImage: "checkmark") + } else { + Text(prettify(v)) + } + } + } + } label: { + HStack(spacing: DesignTokens.Spacing.xs) { + Text(prettify(current)) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .tint(.primary) + } else { + Text(prettify(current)).foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var numericRow: some View { + let current = stateValue?.numberValue ?? 0 + let unit = expose.unit ?? "" + let writable = mode == .interactive && expose.isWritable + && expose.valueMin != nil && expose.valueMax != nil + + if writable, let min = expose.valueMin, let max = expose.valueMax { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { + HStack(spacing: DesignTokens.Spacing.md) { + leadingIcon + labelStack + Spacer() + Text(formatNumeric(numericDraft, unit: unit)) + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + } + Slider(value: $numericDraft, in: min...max, step: expose.valueStep ?? 1) { editing in + guard !editing else { return } + onSend(.object([property: numericPayload(numericDraft, step: expose.valueStep)])) + } + .padding(.leading, iconWidth + DesignTokens.Spacing.md) + } + .onAppear { numericDraft = current } + .onChange(of: current) { _, v in numericDraft = v } + } else { + HStack(spacing: DesignTokens.Spacing.md) { + leadingIcon + labelStack + Spacer() + Text(formatNumeric(current, unit: unit)) + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + + private func numericPayload(_ v: Double, step: Double?) -> JSONValue { + if let step, step.truncatingRemainder(dividingBy: 1) == 0 { + return .int(Int(v.rounded())) + } + return .double(v) + } + + @ViewBuilder + private var textRow: some View { + HStack(spacing: DesignTokens.Spacing.md) { + leadingIcon + labelStack + Spacer() + Text(stateValue?.stringified ?? "—").foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var labelStack: some View { + Text(label).font(.body) + } + + private func prettify(_ s: String) -> String { + s.replacingOccurrences(of: "_", with: " ").capitalized + } + + private func formatNumeric(_ v: Double, unit: String) -> String { + let formatted = v.formatted(.number.precision(.fractionLength(0...1))) + return unit.isEmpty ? formatted : "\(formatted) \(unit)" + } +} diff --git a/Shellbee/Shared/FanControl/FanFeatureSections.swift b/Shellbee/Shared/FanControl/FanFeatureSections.swift new file mode 100644 index 0000000..3cd7f3e --- /dev/null +++ b/Shellbee/Shared/FanControl/FanFeatureSections.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// Renders the Fan device's feature sections (Behaviour, Indicators, etc.) as +/// native `List` sections. Place inside a `List` whose `.listStyle` is grouped +/// or inset-grouped. The hero / filter cards are still rendered by +/// `FanControlCard` (with `rendersSectionsInline: false`). +struct FanFeatureSections: View { + let context: FanControlContext + let mode: CardDisplayMode + let onSend: (JSONValue) -> Void + + private let filterProps: Set = ["replace_filter", "filter_age", "device_age"] + + private var eligibleExtras: [Expose] { + let claimed: Set = Set(["pm25", "air_quality"]).union(filterProps) + return context.extras.filter { e in + guard let prop = e.property else { return false } + return !claimed.contains(prop) + } + } + + private var sections: [LayoutSection] { FeatureLayout.sections(from: eligibleExtras) } + + var body: some View { + ForEach(sections) { section in + Section(section.title) { + ForEach(section.items, id: \.id) { item in + rowFor(item) + } + } + } + } + + @ViewBuilder + private func rowFor(_ item: LayoutItem) -> some View { + switch item { + case .row(let expose): + SettingsFormRow(expose: expose, state: context.state, mode: mode, onSend: onSend) + case .indexedGroup(let group): + NavigationLink { + FeatureGroupDetailView(group: group, state: context.state, mode: mode, onSend: onSend) + } label: { + LabeledContent(group.label) { + Text("\(group.members.count)") + } + } + } + } +} diff --git a/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift b/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift index e454d9d..9b81e77 100644 --- a/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift +++ b/Shellbee/Shared/GenericExposeCard/GenericExposeCard.swift @@ -50,13 +50,13 @@ struct GenericExposeCard: View { /// name above the card already names what we're looking at, so a redundant /// "Controls" / "Device State" headline is just noise. private var snapshotHeader: some View { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "cpu") - .font(.system(size: 12, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .foregroundStyle(.tint) Text("Device State") - .font(.system(size: 12, weight: .semibold)) - .tracking(0.6) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .foregroundStyle(.secondary) Spacer(minLength: 0) @@ -128,7 +128,7 @@ private struct GenericExposeRow: View { private var leadingIcon: some View { Image(systemName: meta.symbol) - .font(.system(size: 16, weight: .medium)) + .font(DesignTokens.Typography.formRowIcon) .symbolRenderingMode(.hierarchical) .foregroundStyle(.secondary) .frame(width: iconWidth) @@ -180,7 +180,7 @@ private struct GenericExposeRow: View { } } } label: { - HStack(spacing: 4) { + HStack(spacing: DesignTokens.Spacing.xs) { Text(prettify(current)) Image(systemName: "chevron.up.chevron.down") .font(.caption2.weight(.semibold)) diff --git a/Shellbee/Shared/LightControl/LightBrightnessArea.swift b/Shellbee/Shared/LightControl/LightBrightnessArea.swift index b4714df..0041e67 100644 --- a/Shellbee/Shared/LightControl/LightBrightnessArea.swift +++ b/Shellbee/Shared/LightControl/LightBrightnessArea.swift @@ -60,7 +60,7 @@ struct LightBrightnessArea: View { private var labelRow: some View { HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: isOn ? "lightbulb.max.fill" : "lightbulb.slash.fill") - .font(.system(size: 16, weight: .semibold)) + .font(DesignTokens.Typography.formRowIconBold) Spacer() Text(isOn ? "\(brightnessPercent)%" : "Off") .font(.subheadline.monospacedDigit().weight(.semibold)) diff --git a/Shellbee/Shared/LightControl/LightColorControl.swift b/Shellbee/Shared/LightControl/LightColorControl.swift index 11cd133..ebf23c7 100644 --- a/Shellbee/Shared/LightControl/LightColorControl.swift +++ b/Shellbee/Shared/LightControl/LightColorControl.swift @@ -53,7 +53,7 @@ struct LightColorControl: View { Circle() .fill(color) .frame(width: Self.swatchSize, height: Self.swatchSize) - .overlay(Circle().strokeBorder(isSelected(color) ? Color.primary : Color.clear, lineWidth: 2)) + .overlay(Circle().strokeBorder(isSelected(color) ? Color.primary : Color.clear, lineWidth: DesignTokens.Size.lightSelectionStroke)) .frame(maxWidth: .infinity) .contentShape(Circle().inset(by: -8)) } @@ -68,7 +68,7 @@ struct LightColorControl: View { .frame(width: Self.swatchSize, height: Self.swatchSize) .overlay( Image(systemName: "eyedropper.halffull") - .font(.system(size: 12, weight: .semibold)) + .font(DesignTokens.Typography.sectionHeaderLabel) .foregroundStyle(.secondary) ) .frame(maxWidth: .infinity) diff --git a/Shellbee/Shared/LightControl/LightControlCard.swift b/Shellbee/Shared/LightControl/LightControlCard.swift index 6823f59..ff7ced9 100644 --- a/Shellbee/Shared/LightControl/LightControlCard.swift +++ b/Shellbee/Shared/LightControl/LightControlCard.swift @@ -73,7 +73,7 @@ struct LightControlCard: View { LinearGradient( colors: [ (context.isOn ? context.displayColor : Color(.tertiaryLabel)).opacity(context.isOn ? 0.18 : 0.06), - (context.isOn ? context.displayColor : Color(.tertiaryLabel)).opacity(0.04) + (context.isOn ? context.displayColor : Color(.tertiaryLabel)).opacity(DesignTokens.Opacity.subtleFade) ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -94,13 +94,13 @@ struct LightControlCard: View { @ViewBuilder private var interactiveContent: some View { HStack(spacing: DesignTokens.Spacing.sm) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: context.isOn ? "lightbulb.fill" : "lightbulb") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(eyebrowLabel) - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } @@ -170,13 +170,13 @@ struct LightControlCard: View { private var snapshotHero: some View { HStack(alignment: .top, spacing: DesignTokens.Spacing.md) { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: context.isOn ? "lightbulb.fill" : "lightbulb") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(eyebrowLabel) - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } @@ -192,20 +192,20 @@ struct LightControlCard: View { @ViewBuilder private var snapshotHeroValue: some View { if context.isOn, context.brightness != nil { - HStack(alignment: .firstTextBaseline, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Text("\(context.brightnessPercent)") - .font(.system(size: 56, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroValue) .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.6) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorMedium) Text("%") - .font(.system(size: 18, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.heroUnit) .foregroundStyle(.secondary) } } else { Text(context.isOn ? "On" : "Off") - .font(.system(size: 48, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroStateText) .foregroundStyle(headerTint) } } @@ -225,8 +225,8 @@ struct LightControlCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) + .frame(height: DesignTokens.Size.hairline) } private var hasColorOrTempInfo: Bool { @@ -245,20 +245,20 @@ struct LightControlCard: View { ) } else if isColorMode { HStack(alignment: .firstTextBaseline) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: "paintpalette.fill") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text("Color") - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) } .foregroundStyle(.secondary) Spacer() Circle() .fill(context.displayColor) - .frame(width: 22, height: 22) + .frame(width: DesignTokens.Size.cardSymbol, height: DesignTokens.Size.cardSymbol) .overlay(Circle().stroke(.separator, lineWidth: DesignTokens.Size.badgeStroke)) } } @@ -266,32 +266,32 @@ struct LightControlCard: View { private func snapshotInfoRow(icon: String, label: String, value: String, unit: String) -> some View { HStack(alignment: .firstTextBaseline) { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(label) - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } .foregroundStyle(.secondary) Spacer() - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(.system(size: 20, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.snapshotRowValue) .monospacedDigit() .foregroundStyle(.primary) Text(unit) - .font(.system(size: 13, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.snapshotRowUnit) .foregroundStyle(.secondary) Circle() .fill(context.displayColor) - .frame(width: 18, height: 18) + .frame(width: DesignTokens.Size.summaryRowSymbol, height: DesignTokens.Size.summaryRowSymbol) .overlay(Circle().stroke(.separator, lineWidth: DesignTokens.Size.badgeStroke)) - .padding(.leading, 4) + .padding(.leading, DesignTokens.Spacing.xs) } } } @@ -306,7 +306,7 @@ struct LightControlCard: View { private func configButton(_ systemImage: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: systemImage) - .font(.system(size: 15, weight: .semibold)) + .font(DesignTokens.Typography.sectionHeader) .foregroundStyle(.primary) .frame(width: DesignTokens.Size.lightControlButton, height: DesignTokens.Size.lightControlButton) .contentShape(Circle()) diff --git a/Shellbee/Shared/LightControl/LightTemperatureControl.swift b/Shellbee/Shared/LightControl/LightTemperatureControl.swift index 968395e..8b7b4e1 100644 --- a/Shellbee/Shared/LightControl/LightTemperatureControl.swift +++ b/Shellbee/Shared/LightControl/LightTemperatureControl.swift @@ -61,11 +61,12 @@ struct LightTemperatureControl: View { .fill(LightDisplayColor.temperatureColor(mireds: mireds)) .frame(width: DesignTokens.Size.lightControlButton, height: DesignTokens.Size.lightControlButton) .overlay(Circle().strokeBorder( - isSelected ? Color.primary : Color.clear, lineWidth: 2 + isSelected ? Color.primary : Color.clear, + lineWidth: DesignTokens.Size.lightSelectionStroke )) - .opacity(inRange ? 1 : 0.35) + .opacity(inRange ? 1 : DesignTokens.Opacity.outOfRange) Text(preset.label) - .font(.system(size: 9, weight: .medium)) + .font(DesignTokens.Typography.sliderEndLabel) .foregroundStyle(.secondary) } } diff --git a/Shellbee/Shared/LockControl/LockControlCard.swift b/Shellbee/Shared/LockControl/LockControlCard.swift index 3e3ce3f..f41bbb4 100644 --- a/Shellbee/Shared/LockControl/LockControlCard.swift +++ b/Shellbee/Shared/LockControl/LockControlCard.swift @@ -39,7 +39,7 @@ struct LockControlCard: View { ZStack { Color(.secondarySystemGroupedBackground) LinearGradient( - colors: [heroTint.opacity(0.18), heroTint.opacity(0.04)], + colors: [heroTint.opacity(DesignTokens.Opacity.onStateTint), heroTint.opacity(DesignTokens.Opacity.subtleFade)], startPoint: .topLeading, endPoint: .bottomTrailing ) @@ -60,13 +60,13 @@ struct LockControlCard: View { } private var heroEyebrow: some View { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: context.isLocked ? "lock.fill" : "lock.open.fill") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text("Lock") - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) } .foregroundStyle(heroTint) @@ -74,10 +74,10 @@ struct LockControlCard: View { private var heroValue: some View { Text(context.isLocked ? "Locked" : "Unlocked") - .font(.system(size: 48, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroStateText) .foregroundStyle(heroTint) .lineLimit(1) - .minimumScaleFactor(0.7) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorRelaxed) } private var statePill: some View { @@ -91,8 +91,8 @@ struct LockControlCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) + .frame(height: DesignTokens.Size.hairline) } // MARK: - Action button @@ -107,7 +107,7 @@ struct LockControlCard: View { } label: { HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: context.isLocked ? "lock.open.fill" : "lock.fill") - .font(.system(size: 16, weight: .semibold)) + .font(DesignTokens.Typography.formRowIconBold) Text(context.isLocked ? "Unlock" : "Lock") .font(.headline) } diff --git a/Shellbee/Shared/RemoteCard/RemoteCard.swift b/Shellbee/Shared/RemoteCard/RemoteCard.swift index 47396aa..a50ac04 100644 --- a/Shellbee/Shared/RemoteCard/RemoteCard.swift +++ b/Shellbee/Shared/RemoteCard/RemoteCard.swift @@ -38,11 +38,11 @@ struct RemoteCard: View { private var header: some View { HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: "command") - .font(.system(size: 12, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .foregroundStyle(.tint) Text("Remote") - .font(.system(size: 12, weight: .semibold)) - .tracking(0.6) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .foregroundStyle(.secondary) Spacer(minLength: 0) @@ -83,29 +83,29 @@ private struct ReadingTile: View { var body: some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(alignment: .firstTextBaseline, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(label) - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(.system(size: 30, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.featureTileValue) .monospacedDigit() .foregroundStyle(valueColor) .lineLimit(2) - .minimumScaleFactor(0.55) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) if let unit { Text(unit) - .font(.system(size: 15, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.featureTileUnit) .foregroundStyle(.secondary) .lineLimit(1) } diff --git a/Shellbee/Shared/SensorCard/SensorCard.swift b/Shellbee/Shared/SensorCard/SensorCard.swift index eaa0ea6..468d4af 100644 --- a/Shellbee/Shared/SensorCard/SensorCard.swift +++ b/Shellbee/Shared/SensorCard/SensorCard.swift @@ -46,11 +46,11 @@ struct SensorCard: View { private var header: some View { HStack(spacing: DesignTokens.Spacing.sm) { Image(systemName: "sensor.fill") - .font(.system(size: 12, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .foregroundStyle(.tint) Text("Sensor") - .font(.system(size: 12, weight: .semibold)) - .tracking(0.6) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .foregroundStyle(.secondary) Spacer(minLength: 0) @@ -210,29 +210,29 @@ private struct SensorReadingTile: View { var body: some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(alignment: .firstTextBaseline, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: reading.icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(reading.label) - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(reading.numericDisplayValue) - .font(.system(size: 30, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.featureTileValue) .monospacedDigit() .foregroundStyle(reading.valueColor) .lineLimit(1) - .minimumScaleFactor(0.55) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) if let unit = reading.unitDisplay { Text(unit) - .font(.system(size: 15, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.featureTileUnit) .foregroundStyle(.secondary) .lineLimit(1) } diff --git a/Shellbee/Shared/SwitchControl/SwitchControlCard.swift b/Shellbee/Shared/SwitchControl/SwitchControlCard.swift index 13064b7..8eb54f3 100644 --- a/Shellbee/Shared/SwitchControl/SwitchControlCard.swift +++ b/Shellbee/Shared/SwitchControl/SwitchControlCard.swift @@ -34,7 +34,7 @@ struct SwitchControlCard: View { LinearGradient( colors: [ heroTint.opacity(context.isOn ? 0.18 : 0.06), - heroTint.opacity(0.04) + heroTint.opacity(DesignTokens.Opacity.subtleFade) ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -56,13 +56,13 @@ struct SwitchControlCard: View { } private var heroEyebrow: some View { - HStack(spacing: 5) { + HStack(spacing: DesignTokens.Spacing.xs) { Image(systemName: context.isOn ? "power.circle.fill" : "power.circle") - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(eyebrowLabel) - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(1) } @@ -76,7 +76,7 @@ struct SwitchControlCard: View { private var heroValue: some View { Text(context.isOn ? "On" : "Off") - .font(.system(size: 48, weight: .bold, design: .rounded)) + .font(DesignTokens.Typography.heroStateText) .foregroundStyle(heroTint) } @@ -115,8 +115,8 @@ struct SwitchControlCard: View { private var hairline: some View { Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(height: 0.5) + .fill(Color.primary.opacity(DesignTokens.Opacity.hairline)) + .frame(height: DesignTokens.Size.hairline) } // MARK: - Metering grid @@ -179,28 +179,28 @@ private struct MeteringTile: View { var body: some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack(alignment: .firstTextBaseline, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Image(systemName: icon) - .font(.system(size: 11, weight: .bold)) + .font(DesignTokens.Typography.eyebrowIcon) .symbolRenderingMode(.hierarchical) Text(label) - .font(.system(size: 11, weight: .semibold)) - .tracking(0.5) + .font(DesignTokens.Typography.eyebrowLabel) + .tracking(DesignTokens.Typography.eyebrowTracking) .textCase(.uppercase) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } .foregroundStyle(.secondary) - HStack(alignment: .firstTextBaseline, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xxs) { Text(value) - .font(.system(size: 30, weight: .semibold, design: .rounded)) + .font(DesignTokens.Typography.featureTileValue) .monospacedDigit() .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.55) + .minimumScaleFactor(DesignTokens.Typography.scaleFactorTight) Text(unit) - .font(.system(size: 15, weight: .medium, design: .rounded)) + .font(DesignTokens.Typography.featureTileUnit) .foregroundStyle(.secondary) .lineLimit(1) }