From bae5cc690851220a9f8cd16600c490a325d8df89 Mon Sep 17 00:00:00 2001 From: tashda Date: Fri, 1 May 2026 09:37:35 +0200 Subject: [PATCH 1/4] Lower deployment target to iOS 17 Add `Shellbee/Shared/Compat/iOS26Compat.swift` with helpers that keep native iOS 26 styling (Liquid Glass, minimized search toolbar, indefinite bounce) and fall back to iOS 17 equivalents. Migrate all call sites. Split `MainTabView` into `Tab { }` (iOS 18) vs classic `.tabItem` (iOS 17) and force opaque `UITabBar` appearance on iOS 17/18 so the tab bar no longer fades transparent at the scroll edge. `MeshGradient` falls back to `LinearGradient`. Replace `arrow.trianglehead.2.clockwise` (SF Symbols 6) with `arrow.triangle.2.circlepath`. Replace widget `.repeat(.continuous)` with `.repeating`. Refs #49 --- Shellbee.xcodeproj/project.pbxproj | 9 +- Shellbee/App/MainTabView.swift | 65 +++++++--- .../Features/Devices/DeviceDetailView.swift | 2 +- .../Features/Devices/DeviceFirmwareMenu.swift | 2 +- Shellbee/Features/Devices/DeviceListRow.swift | 6 +- .../Features/Devices/DeviceListView.swift | 2 +- .../Devices/DeviceUpgradeBadgeView.swift | 2 +- Shellbee/Features/Groups/GroupListView.swift | 2 +- .../Home/HomeBackgroundGradient.swift | 112 +++++++++++------- Shellbee/Features/Logs/LogsView.swift | 2 +- .../Notifications/FastTrackBanner.swift | 2 +- .../InAppNotificationBanner.swift | 10 +- .../Onboarding/OnboardingTestPage.swift | 2 +- .../Features/Onboarding/OnboardingView.swift | 2 +- .../Features/Pairing/PairingWizardModel.swift | 2 +- .../Features/Settings/DocBrowserView.swift | 4 +- Shellbee/Shared/Compat/iOS26Compat.swift | 56 +++++++++ .../LightControl/LightControlCard.swift | 3 +- .../ConnectionActivityWidget.swift | 8 +- ShellbeeWidgets/InterviewActivityWidget.swift | 8 +- ShellbeeWidgets/OTAUpdateActivityWidget.swift | 6 +- 21 files changed, 216 insertions(+), 91 deletions(-) create mode 100644 Shellbee/Shared/Compat/iOS26Compat.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 36e18e9..d3eefea 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -251,6 +251,7 @@ Shared/ClimateControl/ClimateControlCard.swift, Shared/ClimateControl/ClimateControlContext.swift, Shared/ClimateControl/ClimateFeatureSections.swift, + Shared/Compat/iOS26Compat.swift, Shared/Components/BeautifulPayloadView.swift, Shared/Components/BeautifulRow.swift, Shared/Components/CopyableRow.swift, @@ -824,7 +825,7 @@ INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Shellbee; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -865,7 +866,7 @@ INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Shellbee; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -904,7 +905,7 @@ INFOPLIST_KEY_CFBundleDisplayName = "Shellbee Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSSupportsLiveActivities = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -946,7 +947,7 @@ INFOPLIST_KEY_CFBundleDisplayName = "Shellbee Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSSupportsLiveActivities = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Shellbee/App/MainTabView.swift b/Shellbee/App/MainTabView.swift index 02d8264..692e882 100644 --- a/Shellbee/App/MainTabView.swift +++ b/Shellbee/App/MainTabView.swift @@ -4,22 +4,21 @@ struct MainTabView: View { @Environment(AppEnvironment.self) private var environment @State private var tabSelection: AppTab = .home - var body: some View { - TabView(selection: $tabSelection) { - Tab("Home", systemImage: "house.fill", value: AppTab.home) { - HomeView() - } - Tab("Devices", systemImage: "sensor.tag.radiowaves.forward.fill", value: AppTab.devices) { - DeviceListView() - } - Tab("Groups", systemImage: "square.on.square.fill", value: AppTab.groups) { - GroupListView() - } - Tab("Settings", systemImage: "gearshape.fill", value: AppTab.settings) { - SettingsView() - } - .badge(environment.store.bridgeInfo?.restartRequired == true ? Text("!") : nil) + init() { + // iOS 26 has the new floating glass tab bar from the Tab { } builder, + // which we don't want to disturb. On iOS 17/18 the classic UITabBar + // goes transparent at the scroll edge by default; force opaque so it + // always shows the system fill instead of fading into content. + if #unavailable(iOS 26.0) { + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + UITabBar.appearance().standardAppearance = appearance + UITabBar.appearance().scrollEdgeAppearance = appearance } + } + + var body: some View { + tabContent .overlay(alignment: .bottom) { InAppNotificationOverlay() .safeAreaPadding(.bottom) @@ -42,6 +41,42 @@ struct MainTabView: View { } } + @ViewBuilder + private var tabContent: some View { + if #available(iOS 18.0, *) { + TabView(selection: $tabSelection) { + Tab("Home", systemImage: "house.fill", value: AppTab.home) { + HomeView() + } + Tab("Devices", systemImage: "sensor.tag.radiowaves.forward.fill", value: AppTab.devices) { + DeviceListView() + } + Tab("Groups", systemImage: "square.on.square.fill", value: AppTab.groups) { + GroupListView() + } + Tab("Settings", systemImage: "gearshape.fill", value: AppTab.settings) { + SettingsView() + } + .badge(environment.store.bridgeInfo?.restartRequired == true ? Text("!") : nil) + } + } else { + TabView(selection: $tabSelection) { + HomeView() + .tabItem { Label("Home", systemImage: "house.fill") } + .tag(AppTab.home) + DeviceListView() + .tabItem { Label("Devices", systemImage: "sensor.tag.radiowaves.forward.fill") } + .tag(AppTab.devices) + GroupListView() + .tabItem { Label("Groups", systemImage: "square.on.square.fill") } + .tag(AppTab.groups) + SettingsView() + .tabItem { Label("Settings", systemImage: "gearshape.fill") } + .tag(AppTab.settings) + .badge(environment.store.bridgeInfo?.restartRequired == true ? Text("!") : nil) + } + } + } } private struct LogSheetHost: View { diff --git a/Shellbee/Features/Devices/DeviceDetailView.swift b/Shellbee/Features/Devices/DeviceDetailView.swift index 0ccb125..0a14522 100644 --- a/Shellbee/Features/Devices/DeviceDetailView.swift +++ b/Shellbee/Features/Devices/DeviceDetailView.swift @@ -280,7 +280,7 @@ struct DeviceDetailView: View { } } else if !otaActive { Button { checkForUpdate(device) } label: { - Label("Check for Update", systemImage: "arrow.trianglehead.2.clockwise") + Label("Check for Update", systemImage: "arrow.triangle.2.circlepath") } if hasUpdateAvailable { if isBattery { diff --git a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift index 619f1f1..33fbe28 100644 --- a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift +++ b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift @@ -51,7 +51,7 @@ struct DeviceFirmwareMenu: View { } environment.otaBulkQueue.enqueue(names, kind: .check) } label: { - Label("Check All for Updates\(otaCount > 0 ? " (\(otaCount))" : "")", systemImage: "arrow.trianglehead.2.clockwise") + Label("Check All for Updates\(otaCount > 0 ? " (\(otaCount))" : "")", systemImage: "arrow.triangle.2.circlepath") } .disabled(otaCount == 0 || bulkActive) diff --git a/Shellbee/Features/Devices/DeviceListRow.swift b/Shellbee/Features/Devices/DeviceListRow.swift index e739dd2..c9160d2 100644 --- a/Shellbee/Features/Devices/DeviceListRow.swift +++ b/Shellbee/Features/Devices/DeviceListRow.swift @@ -39,7 +39,7 @@ struct DeviceListRow: View { return ("OTA not supported", "xmark.circle") } if otaStatus?.phase == .checking { - return ("Checking", "arrow.trianglehead.2.clockwise") + return ("Checking", "arrow.triangle.2.circlepath") } if otaStatus?.isActive == true { return ("Updating", "arrow.up.circle") @@ -92,7 +92,7 @@ struct DeviceListRow: View { .tint(.gray) } else { Button(action: onCheckUpdate) { - Label("Check", systemImage: "arrow.trianglehead.2.clockwise") + Label("Check", systemImage: "arrow.triangle.2.circlepath") } .tint(.blue) if isBatteryPowered { @@ -174,7 +174,7 @@ struct DeviceListRow: View { if supportsOTA { Divider() Button(action: onCheckUpdate) { - Label("Check for Update", systemImage: "arrow.trianglehead.2.clockwise") + Label("Check for Update", systemImage: "arrow.triangle.2.circlepath") } if otaStatus?.phase == .scheduled, let onUnschedule { Button(action: onUnschedule) { diff --git a/Shellbee/Features/Devices/DeviceListView.swift b/Shellbee/Features/Devices/DeviceListView.swift index c895dc7..7d5a1d3 100644 --- a/Shellbee/Features/Devices/DeviceListView.swift +++ b/Shellbee/Features/Devices/DeviceListView.swift @@ -29,7 +29,7 @@ struct DeviceListView: View { DeviceDetailView(device: device) } .searchable(text: $viewModel.searchText, prompt: "Search") - .searchToolbarBehavior(.minimize) + .minimizeSearchToolbarIfAvailable() .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { Button { diff --git a/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift b/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift index 1e4e43c..12c2057 100644 --- a/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift +++ b/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift @@ -112,7 +112,7 @@ struct DeviceUpgradeBadgeView: View { case .updating: return "arrow.down" case .checking: return "magnifyingglass" case .scheduled: return "clock.badge" - default: return "arrow.trianglehead.2.clockwise" + default: return "arrow.triangle.2.circlepath" } } } diff --git a/Shellbee/Features/Groups/GroupListView.swift b/Shellbee/Features/Groups/GroupListView.swift index c3216eb..371d419 100644 --- a/Shellbee/Features/Groups/GroupListView.swift +++ b/Shellbee/Features/Groups/GroupListView.swift @@ -30,7 +30,7 @@ struct GroupListView: View { DeviceDetailView(device: device) } .searchable(text: $viewModel.searchText, prompt: "Search") - .searchToolbarBehavior(.minimize) + .minimizeSearchToolbarIfAvailable() .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { Button { diff --git a/Shellbee/Features/Home/HomeBackgroundGradient.swift b/Shellbee/Features/Home/HomeBackgroundGradient.swift index 7d0dac2..643cdaa 100644 --- a/Shellbee/Features/Home/HomeBackgroundGradient.swift +++ b/Shellbee/Features/Home/HomeBackgroundGradient.swift @@ -26,54 +26,86 @@ struct HomeBackgroundGradient: View { ) } + @ViewBuilder private var lightMesh: some View { - MeshGradient( - width: 3, - height: 3, - points: [ - [0.00, 0.00], [0.55, 0.00], [1.00, 0.00], - [0.00, 0.45], [0.60, 0.50], [1.00, 0.55], - [0.00, 1.00], [0.45, 1.00], [1.00, 1.00] - ], - colors: [ - signatureLight, signaturePale, signatureCool, - signatureMint, signature, signatureBlue, - signatureDeep, signature, signaturePale - ], - smoothsColors: true - ) - .overlay( + if #available(iOS 18.0, *) { + MeshGradient( + width: 3, + height: 3, + points: [ + [0.00, 0.00], [0.55, 0.00], [1.00, 0.00], + [0.00, 0.45], [0.60, 0.50], [1.00, 0.55], + [0.00, 1.00], [0.45, 1.00], [1.00, 1.00] + ], + colors: [ + signatureLight, signaturePale, signatureCool, + signatureMint, signature, signatureBlue, + signatureDeep, signature, signaturePale + ], + smoothsColors: true + ) + .overlay( + LinearGradient( + colors: [.white.opacity(DesignTokens.Opacity.dimmedSurface), .clear], + startPoint: .top, + endPoint: .center + ) + ) + } else { LinearGradient( - colors: [.white.opacity(DesignTokens.Opacity.dimmedSurface), .clear], - startPoint: .top, - endPoint: .center + colors: [signatureLight, signature, signatureDeep], + startPoint: .topLeading, + endPoint: .bottomTrailing ) - ) + .overlay( + LinearGradient( + colors: [.white.opacity(DesignTokens.Opacity.dimmedSurface), .clear], + startPoint: .top, + endPoint: .center + ) + ) + } } + @ViewBuilder private var darkMesh: some View { - MeshGradient( - width: 3, - height: 3, - points: [ - [0.00, 0.00], [0.55, 0.00], [1.00, 0.00], - [0.00, 0.45], [0.60, 0.50], [1.00, 0.55], - [0.00, 1.00], [0.45, 1.00], [1.00, 1.00] - ], - colors: [ - darkLight, darkPale, darkCool, - darkMint, darkBase, darkBlue, - darkDeep, darkBase, darkPale - ], - smoothsColors: true - ) - .overlay( + if #available(iOS 18.0, *) { + MeshGradient( + width: 3, + height: 3, + points: [ + [0.00, 0.00], [0.55, 0.00], [1.00, 0.00], + [0.00, 0.45], [0.60, 0.50], [1.00, 0.55], + [0.00, 1.00], [0.45, 1.00], [1.00, 1.00] + ], + colors: [ + darkLight, darkPale, darkCool, + darkMint, darkBase, darkBlue, + darkDeep, darkBase, darkPale + ], + smoothsColors: true + ) + .overlay( + LinearGradient( + colors: [.black.opacity(DesignTokens.Opacity.pressedAlpha), .clear], + startPoint: .top, + endPoint: .center + ) + ) + } else { LinearGradient( - colors: [.black.opacity(DesignTokens.Opacity.pressedAlpha), .clear], - startPoint: .top, - endPoint: .center + colors: [darkLight, darkBase, darkDeep], + startPoint: .topLeading, + endPoint: .bottomTrailing ) - ) + .overlay( + LinearGradient( + colors: [.black.opacity(DesignTokens.Opacity.pressedAlpha), .clear], + startPoint: .top, + endPoint: .center + ) + ) + } } private let signature = Color(red: 227/255, green: 238/255, blue: 238/255) diff --git a/Shellbee/Features/Logs/LogsView.swift b/Shellbee/Features/Logs/LogsView.swift index 816cda9..a1facc9 100644 --- a/Shellbee/Features/Logs/LogsView.swift +++ b/Shellbee/Features/Logs/LogsView.swift @@ -55,7 +55,7 @@ struct LogsView: View { .navigationDestination(item: $autoOpenedEntry) { entry in LogDetailView(entry: entry) } - .searchToolbarBehavior(.minimize) + .minimizeSearchToolbarIfAvailable() .toolbar { ToolbarItem(placement: .principal) { Picker("Mode", selection: $mode) { diff --git a/Shellbee/Features/Notifications/FastTrackBanner.swift b/Shellbee/Features/Notifications/FastTrackBanner.swift index 6a290b1..d8b2f2c 100644 --- a/Shellbee/Features/Notifications/FastTrackBanner.swift +++ b/Shellbee/Features/Notifications/FastTrackBanner.swift @@ -18,7 +18,7 @@ struct FastTrackBanner: View { } .padding(.horizontal, DesignTokens.Spacing.lg) .padding(.vertical, DesignTokens.Spacing.md) - .glassEffect(in: Capsule(style: .continuous)) + .glassEffectIfAvailable(in: Capsule(style: .continuous)) .shadow( color: .black.opacity(DesignTokens.Shadow.floatingOpacity), radius: DesignTokens.Shadow.floatingRadius, diff --git a/Shellbee/Features/Notifications/InAppNotificationBanner.swift b/Shellbee/Features/Notifications/InAppNotificationBanner.swift index 1428923..9781482 100644 --- a/Shellbee/Features/Notifications/InAppNotificationBanner.swift +++ b/Shellbee/Features/Notifications/InAppNotificationBanner.swift @@ -30,7 +30,7 @@ struct InAppNotificationBanner: View { // 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( + .glassEffectIfAvailable( in: isExpanded ? AnyShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.xl, style: .continuous)) : AnyShape(Capsule(style: .continuous)) @@ -122,13 +122,13 @@ struct InAppNotificationBanner: View { Button(action: onGoToDevice) { Label("Device", systemImage: "sensor.tag.radiowaves.forward.fill") } - .buttonStyle(.glassProminent) + .glassProminentButtonStyleIfAvailable() .controlSize(.small) } else if !notification.logEntryIDs.isEmpty { Button(action: onGoToLog) { Label("Log", systemImage: "list.bullet.rectangle.portrait") } - .buttonStyle(.glassProminent) + .glassProminentButtonStyleIfAvailable() .controlSize(.small) } @@ -136,14 +136,14 @@ struct InAppNotificationBanner: View { Button(action: onGoToLog) { Label("Log", systemImage: "list.bullet.rectangle.portrait") } - .buttonStyle(.glass) + .glassButtonStyleIfAvailable() .controlSize(.small) } Button(action: onCopyMessage) { Label("Copy", systemImage: "doc.on.doc") } - .buttonStyle(.glass) + .glassButtonStyleIfAvailable() .controlSize(.small) Spacer(minLength: 0) diff --git a/Shellbee/Features/Onboarding/OnboardingTestPage.swift b/Shellbee/Features/Onboarding/OnboardingTestPage.swift index cf99409..8352ea0 100644 --- a/Shellbee/Features/Onboarding/OnboardingTestPage.swift +++ b/Shellbee/Features/Onboarding/OnboardingTestPage.swift @@ -47,7 +47,7 @@ struct OnboardingTestPage: View { case .connected: Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) - .symbolEffect(.bounce) + .bounceSymbolEffectIfAvailable() case .failed, .lost: Image(systemName: "xmark.circle.fill") .foregroundStyle(.red) diff --git a/Shellbee/Features/Onboarding/OnboardingView.swift b/Shellbee/Features/Onboarding/OnboardingView.swift index 4680f50..2c9188f 100644 --- a/Shellbee/Features/Onboarding/OnboardingView.swift +++ b/Shellbee/Features/Onboarding/OnboardingView.swift @@ -131,7 +131,7 @@ private struct DonePage: View { Image(systemName: "checkmark.seal.fill") .font(.system(size: 80)) .foregroundStyle(.green) - .symbolEffect(.bounce) + .bounceSymbolEffectIfAvailable() Text("You're all set") .font(.largeTitle.weight(.bold)) let count = environment.store.devices.count diff --git a/Shellbee/Features/Pairing/PairingWizardModel.swift b/Shellbee/Features/Pairing/PairingWizardModel.swift index ad7dcbe..82739f2 100644 --- a/Shellbee/Features/Pairing/PairingWizardModel.swift +++ b/Shellbee/Features/Pairing/PairingWizardModel.swift @@ -47,7 +47,7 @@ final class PairingWizardModel { var systemImage: String { switch self { case .pending: "hourglass" - case .running: "arrow.trianglehead.2.clockwise" + case .running: "arrow.triangle.2.circlepath" case .completed: "checkmark.circle.fill" } } diff --git a/Shellbee/Features/Settings/DocBrowserView.swift b/Shellbee/Features/Settings/DocBrowserView.swift index 9857b9a..786e963 100644 --- a/Shellbee/Features/Settings/DocBrowserView.swift +++ b/Shellbee/Features/Settings/DocBrowserView.swift @@ -54,7 +54,7 @@ struct DocBrowserView: View { .navigationTitle("Device Library") .navigationBarTitleDisplayMode(.large) .searchable(text: $searchText, prompt: "Search model, vendor, description") - .searchToolbarBehavior(.minimize) + .minimizeSearchToolbarIfAvailable() .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { DocBrowserFilterMenu( @@ -334,7 +334,7 @@ private struct ManufacturerFilterSheet: View { } } .searchable(text: $search, prompt: "Search manufacturers") - .searchToolbarBehavior(.minimize) + .minimizeSearchToolbarIfAvailable() .navigationTitle("Manufacturer") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Shellbee/Shared/Compat/iOS26Compat.swift b/Shellbee/Shared/Compat/iOS26Compat.swift new file mode 100644 index 0000000..3f10c41 --- /dev/null +++ b/Shellbee/Shared/Compat/iOS26Compat.swift @@ -0,0 +1,56 @@ +import SwiftUI + +extension View { + @ViewBuilder + func minimizeSearchToolbarIfAvailable() -> some View { + if #available(iOS 26.0, *) { + self.searchToolbarBehavior(.minimize) + } else { + self + } + } + + @ViewBuilder + func glassEffectIfAvailable(in shape: S) -> some View { + if #available(iOS 26.0, *) { + self.glassEffect(in: shape) + } else { + self.background(shape.fill(.ultraThinMaterial)) + } + } + + @ViewBuilder + func glassProminentButtonStyleIfAvailable() -> some View { + if #available(iOS 26.0, *) { + self.buttonStyle(.glassProminent) + } else { + self.buttonStyle(.borderedProminent) + } + } + + @ViewBuilder + func glassButtonStyleIfAvailable() -> some View { + if #available(iOS 26.0, *) { + self.buttonStyle(.glass) + } else { + self.buttonStyle(.bordered) + } + } + + /// `.symbolEffect(.bounce)` (no value) needs iOS 18 because BounceSymbolEffect + /// only conforms to IndefiniteSymbolEffect there. iOS 17 has no equivalent + /// without an external `value:` trigger, so the effect is dropped on 17. + @ViewBuilder + func bounceSymbolEffectIfAvailable() -> some View { + if #available(iOS 18.0, *) { + self.bounceSymbolEffectIndefinite() + } else { + self + } + } + + @available(iOS 18.0, *) + fileprivate func bounceSymbolEffectIndefinite() -> some View { + self.symbolEffect(.bounce) + } +} diff --git a/Shellbee/Shared/LightControl/LightControlCard.swift b/Shellbee/Shared/LightControl/LightControlCard.swift index ff7ced9..ea278c9 100644 --- a/Shellbee/Shared/LightControl/LightControlCard.swift +++ b/Shellbee/Shared/LightControl/LightControlCard.swift @@ -312,7 +312,7 @@ struct LightControlCard: View { .contentShape(Circle()) } .buttonStyle(.plain) - .glassEffect(in: Circle()) + .glassEffectIfAvailable(in: Circle()) } private func togglePower() { @@ -333,6 +333,7 @@ struct LightControlCard: View { } } + #Preview { ScrollView { VStack(spacing: DesignTokens.Spacing.lg) { diff --git a/ShellbeeWidgets/ConnectionActivityWidget.swift b/ShellbeeWidgets/ConnectionActivityWidget.swift index cd58be5..9287abb 100644 --- a/ShellbeeWidgets/ConnectionActivityWidget.swift +++ b/ShellbeeWidgets/ConnectionActivityWidget.swift @@ -15,7 +15,7 @@ struct ConnectionActivityWidget: Widget { .foregroundStyle(context.state.phase.accentColor) .symbolEffect( .variableColor.iterative, - options: .repeat(.continuous), + options: .repeating, isActive: context.state.phase == .reconnecting ) .padding(.leading, DesignTokens.Spacing.xs) @@ -41,7 +41,7 @@ struct ConnectionActivityWidget: Widget { .foregroundStyle(context.state.phase.accentColor) .symbolEffect( .variableColor.iterative, - options: .repeat(.continuous), + options: .repeating, isActive: context.state.phase == .reconnecting ) .symbolEffect(.bounce, value: context.state.phase) @@ -64,7 +64,7 @@ struct ConnectionActivityWidget: Widget { .foregroundStyle(context.state.phase.accentColor) .symbolEffect( .variableColor.iterative, - options: .repeat(.continuous), + options: .repeating, isActive: context.state.phase == .reconnecting ) } @@ -84,7 +84,7 @@ private struct ConnectionLockScreenView: View { .foregroundStyle(context.state.phase.accentColor) .symbolEffect( .variableColor.iterative, - options: .repeat(.continuous), + options: .repeating, isActive: context.state.phase == .reconnecting ) diff --git a/ShellbeeWidgets/InterviewActivityWidget.swift b/ShellbeeWidgets/InterviewActivityWidget.swift index 78961f1..4f313c4 100644 --- a/ShellbeeWidgets/InterviewActivityWidget.swift +++ b/ShellbeeWidgets/InterviewActivityWidget.swift @@ -15,7 +15,7 @@ struct InterviewActivityWidget: Widget { .foregroundStyle(context.state.phase.accentColor) .symbolEffect( .variableColor.iterative, - options: .repeat(.continuous), + options: .repeating, isActive: context.state.phase == .interviewing ) .padding(.leading, DesignTokens.Spacing.xs) @@ -41,7 +41,7 @@ struct InterviewActivityWidget: Widget { .foregroundStyle(context.state.phase.accentColor) .symbolEffect( .variableColor.iterative, - options: .repeat(.continuous), + options: .repeating, isActive: context.state.phase == .interviewing ) } compactTrailing: { @@ -55,7 +55,7 @@ struct InterviewActivityWidget: Widget { .foregroundStyle(context.state.phase.accentColor) .symbolEffect( .variableColor.iterative, - options: .repeat(.continuous), + options: .repeating, isActive: context.state.phase == .interviewing ) } @@ -75,7 +75,7 @@ private struct InterviewLockScreenView: View { .foregroundStyle(context.state.phase.accentColor) .symbolEffect( .variableColor.iterative, - options: .repeat(.continuous), + options: .repeating, isActive: context.state.phase == .interviewing ) diff --git a/ShellbeeWidgets/OTAUpdateActivityWidget.swift b/ShellbeeWidgets/OTAUpdateActivityWidget.swift index 8b856ba..fb02e0d 100644 --- a/ShellbeeWidgets/OTAUpdateActivityWidget.swift +++ b/ShellbeeWidgets/OTAUpdateActivityWidget.swift @@ -89,7 +89,7 @@ struct OTAUpdateActivityWidget: Widget { OTAProgressRing( progress: context.state.progress, phase: context.state.phase, - symbol: "arrow.trianglehead.2.clockwise.circle.fill", + symbol: "arrow.triangle.2.circlepath.circle.fill", size: 22 ) } @@ -324,7 +324,7 @@ private let previewOTAAttributes = OTAUpdateActivityAttributes(identifier: "ota- private extension OTAUpdateActivityAttributes.ContentState { var primarySymbol: String { switch phase { - case .active: return "arrow.trianglehead.2.clockwise.circle.fill" + case .active: return "arrow.triangle.2.circlepath.circle.fill" case .completed: return "checkmark.circle.fill" case .failed: return "xmark.circle.fill" } @@ -332,6 +332,6 @@ private extension OTAUpdateActivityAttributes.ContentState { var compactSymbol: String { if activeCount == 1, let sym = items.first?.categorySymbol { return sym } - return "arrow.trianglehead.2.clockwise.circle.fill" + return "arrow.triangle.2.circlepath.circle.fill" } } From 58217be752701f2e0a30f0ac67fc2f294a44625b Mon Sep 17 00:00:00 2001 From: tashda Date: Fri, 1 May 2026 09:39:14 +0200 Subject: [PATCH 2/4] Bump MARKETING_VERSION to 1.5.0 --- Shellbee.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index d3eefea..2ce386f 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -830,7 +830,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -871,7 +871,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -911,7 +911,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -953,7 +953,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 7c3ff21dd7f9c975d6de488e7aa8478f2bec178f Mon Sep 17 00:00:00 2001 From: tashda Date: Fri, 1 May 2026 11:45:53 +0200 Subject: [PATCH 3/4] Logs: redesign rows + detail, attribute bridge events, clean state changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #52: redesign Activity rows, log detail header, home Recent Events card, and group compact card to share one aesthetic — device image avatar with category corner badge, severity title color, no chevrons. Refs #53: attribute bridge/response/* and /action publishes to the right device or group. Resolves payload.data.id / data.to / data.friendly_name and falls back to scanning payload.error for known device names. Fixes the payload extractor that was truncating JSON at embedded single quotes. Refs #54: filter elapsed and empty-string transitions from diffs, format filter_age / device_age as durations, drop the full-payload snapshot under state-change diffs, scope the device-state card to actual exposes, and stop truncating long string values in BeautifulRow. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Core/Log/LogContext.swift | 1 + Shellbee/Core/Log/LogMapperEngine.swift | 69 ++++++++++- Shellbee/Core/Models/LogEntry.swift | 51 ++++++-- Shellbee/Core/Store/AppStore+Devices.swift | 10 ++ Shellbee/Core/Store/AppStore+Events.swift | 2 + Shellbee/Features/Groups/GroupCard.swift | 14 +-- Shellbee/Features/Home/HomeLogsCard.swift | 107 ++++++++++++++-- Shellbee/Features/Logs/LogDetailView.swift | 105 +++++++++++++--- Shellbee/Features/Logs/LogRowView.swift | 115 +++++++++++++----- Shellbee/Features/Logs/LogsView.swift | 27 ++-- Shellbee/Shared/Components/BeautifulRow.swift | 2 +- 11 files changed, 408 insertions(+), 95 deletions(-) diff --git a/Shellbee/Core/Log/LogContext.swift b/Shellbee/Core/Log/LogContext.swift index 3a169c7..0a0508b 100644 --- a/Shellbee/Core/Log/LogContext.swift +++ b/Shellbee/Core/Log/LogContext.swift @@ -54,6 +54,7 @@ struct LogContext: Sendable { enum LogAction: Sendable { case mqttPublish + case bridgeResponse case stateChange case bindSuccess, bindFailure, unbind case groupAdd, groupRemove diff --git a/Shellbee/Core/Log/LogMapperEngine.swift b/Shellbee/Core/Log/LogMapperEngine.swift index c8d17b1..0d29a55 100644 --- a/Shellbee/Core/Log/LogMapperEngine.swift +++ b/Shellbee/Core/Log/LogMapperEngine.swift @@ -40,6 +40,19 @@ struct LogMapperEngine { if let m = message.firstMatch(of: Z2MLogPatterns.mqttPublish) { var name = String(m.topic) if name.hasPrefix("zigbee2mqtt/") { name = String(name.dropFirst("zigbee2mqtt/".count)) } + // Bridge responses/events carry the real subject inside the payload. + // Parse `payload ''` from the message to surface it. + if name.hasPrefix("bridge/"), + let resolved = bridgeSubject(in: message, topic: name, knownDevices: knownDevices) { + return oneDevice(resolved, .bridgeResponse) + } + // Sub-topics like "/action" or "/availability" should + // attribute to the parent device so the redundant publish row dedupes + // against the .deviceState event (handled in AppStore+Events). + if let slash = name.firstIndex(of: "/") { + let parent = String(name[.. [LogContext.StateChange] { - let excluded: Set = ["last_seen", "update", "update_available", "device"] + let excluded: Set = ["last_seen", "update", "update_available", "device", "elapsed"] var changes: [LogContext.StateChange] = [] let keys = Set(previous.keys).union(next.keys).subtracting(excluded) @@ -69,6 +82,9 @@ struct LogMapperEngine { // Null-valued entries mean "not active" — not a meaningful change to surface if case .null = curr ?? .null { continue } + // z2m clears momentary triggers like `action` by publishing an empty + // string — that's not a meaningful change either. + if case .string(let s) = curr, s.isEmpty { continue } if case .object(let pObj) = prev, case .object(let cObj) = curr { let subKeys = Set(pObj.keys).union(cObj.keys) @@ -144,16 +160,67 @@ struct LogMapperEngine { if property == "brightness" { return "\(Int((Double(i) / 254.0 * 100).rounded()))%" } if property == "color_temp" { return "\(Int((1_000_000.0 / Double(i)).rounded()))K" } if property == "battery" || property == "humidity" { return "\(i)%" } + if Self.minuteDurationProps.contains(property) { return formatMinutesDuration(Double(i)) } return "\(i)" case .double(let d): if property == "temperature" { return String(format: "%.1f°", d) } + if Self.minuteDurationProps.contains(property) { return formatMinutesDuration(d) } return d.formatted(.number.precision(.fractionLength(0...2))) default: return value.stringified } } + private static let minuteDurationProps: Set = ["filter_age", "device_age"] + + private static func formatMinutesDuration(_ minutes: Double) -> String { + let total = Int(minutes.rounded()) + if total < 60 { return "\(total) min" } + let hours = total / 60 + if hours < 48 { return "\(hours) h" } + let days = hours / 24 + if days < 60 { return "\(days) d" } + let months = days / 30 + return "\(months) mo" + } + // MARK: - Private + /// Extract the subject of a `bridge/...` MQTT publish from the embedded payload. + /// Examples: + /// bridge/response/device/ota_update/check → payload.data.id + /// bridge/response/device/rename → payload.data.to + /// bridge/event → payload.data.friendly_name + /// On error responses (`status: "error"`) z2m clears `data` and the device name + /// only appears inside the human-readable `error` string, e.g. + /// {"data":{},"error":"Failed ... for 'office_remote' ...","status":"error"} + /// In that case we scan the error string for a quoted token that matches a + /// known device name. + private static func bridgeSubject( + in message: String, topic: String, knownDevices: Set + ) -> String? { + guard let payloadStr = LogEntry.extractPayload(from: message) else { return nil } + guard let data = payloadStr.data(using: .utf8), + let payload = try? JSONDecoder().decode([String: JSONValue].self, from: data) else { + return nil + } + if case .object(let inner) = payload["data"] ?? .null { + if topic.hasSuffix("/rename"), case .string(let to) = inner["to"] ?? .null { + return to + } + if case .string(let id) = inner["id"] ?? .null { return id } + if case .string(let fn) = inner["friendly_name"] ?? .null { return fn } + } + if case .string(let fn) = payload["friendly_name"] ?? .null { return fn } + // Error path: scan payload.error for a quoted token matching a known device. + if case .string(let err) = payload["error"] ?? .null { + for m in err.matches(of: Z2MLogPatterns.singleQuoted) { + let token = String(m.name) + if knownDevices.contains(token) { return token } + } + } + return nil + } + private static func oneDevice(_ name: String, _ action: LogContext.LogAction) -> LogContext { LogContext(devices: [.init(friendlyName: name, role: .subject)], stateChanges: [], action: action) } diff --git a/Shellbee/Core/Models/LogEntry.swift b/Shellbee/Core/Models/LogEntry.swift index f06b4a1..b83bab1 100644 --- a/Shellbee/Core/Models/LogEntry.swift +++ b/Shellbee/Core/Models/LogEntry.swift @@ -70,19 +70,13 @@ struct LogEntry: Identifiable, Sendable, Hashable { } var parsedMessageKind: MessageKind { - // Find the topic and payload within single quotes - // We look for: topic '([^']+)' and payload '([^']*)' let topicPattern = /topic '([^']+)'/ - let payloadPattern = /payload '([^']*)'/ - guard let topicMatch = message.firstMatch(of: topicPattern) else { return .simple } let topic = String(topicMatch.1) - - guard let payloadMatch = message.firstMatch(of: payloadPattern) else { + + guard let payloadStr = Self.extractPayload(from: message) else { return .mqttPublish(device: topic, topic: topic, payload: [:]) } - - let payloadStr = String(payloadMatch.1) var payload: [String: JSONValue] = [:] if let data = payloadStr.data(using: .utf8), @@ -105,9 +99,50 @@ struct LogEntry: Identifiable, Sendable, Hashable { if device.hasPrefix("zigbee2mqtt/") { device = String(device.dropFirst("zigbee2mqtt/".count)) } + // Bridge responses/events carry the real subject inside the payload. + // Examples: + // bridge/response/device/ota_update/check → payload.data.id + // bridge/response/device/configure → payload.data.id + // bridge/response/device/rename → payload.data.to (post-rename name) + // bridge/event → payload.data.friendly_name + if device.hasPrefix("bridge/") { + if let resolved = Self.resolveBridgeSubject(topic: device, payload: payload) { + device = resolved + } + } return .mqttPublish(device: device, topic: topic, payload: payload) } + /// Extract the JSON payload from a z2m log line of the form + /// `... topic '', payload ''` where the JSON itself can contain + /// single quotes (e.g. `'office_remote'`, `didn't`). The payload always runs + /// from `payload '` to the final single quote in the string. + static func extractPayload(from message: String) -> String? { + guard let range = message.range(of: "payload '") else { return nil } + let afterOpen = range.upperBound + guard let lastQuote = message.lastIndex(of: "'"), lastQuote > afterOpen else { + return nil + } + return String(message[afterOpen.. String? { + if case .object(let data) = payload["data"] ?? .null { + if topic.hasSuffix("/rename"), case .string(let to) = data["to"] ?? .null { + return to + } + if case .string(let id) = data["id"] ?? .null { return id } + if case .string(let fn) = data["friendly_name"] ?? .null { return fn } + } + if case .string(let fn) = payload["friendly_name"] ?? .null { return fn } + // Error responses: device name only appears quoted inside `error`. + if case .string(let err) = payload["error"] ?? .null { + let pattern = /'([^']+)'/ + if let m = err.firstMatch(of: pattern) { return String(m.1) } + } + return nil + } + var summaryTitle: String { if let name = context?.primaryDevice?.friendlyName { return name } if let name = deviceName { return name } diff --git a/Shellbee/Core/Store/AppStore+Devices.swift b/Shellbee/Core/Store/AppStore+Devices.swift index 46ff3bc..e501809 100644 --- a/Shellbee/Core/Store/AppStore+Devices.swift +++ b/Shellbee/Core/Store/AppStore+Devices.swift @@ -5,6 +5,16 @@ extension AppStore { devices.first { $0.friendlyName == friendlyName } } + func group(named friendlyName: String) -> Group? { + groups.first { $0.friendlyName == friendlyName } + } + + func memberDevices(of group: Group) -> [Device] { + group.members.compactMap { member in + devices.first { $0.ieeeAddress == member.ieeeAddress } + } + } + func state(for friendlyName: String) -> [String: JSONValue] { deviceStates[friendlyName] ?? [:] } diff --git a/Shellbee/Core/Store/AppStore+Events.swift b/Shellbee/Core/Store/AppStore+Events.swift index dffd4de..5b91554 100644 --- a/Shellbee/Core/Store/AppStore+Events.swift +++ b/Shellbee/Core/Store/AppStore+Events.swift @@ -61,6 +61,8 @@ extension AppStore { ) // MQTT publish for a known device/group state topic is redundant — the // .deviceState event creates a richer stateChange entry for the same update. + // Bridge responses (.bridgeResponse) are *not* redundant — they carry distinct + // payload (status, source URL, etc.) that the device-state event doesn't. if case .mqttPublish = ctx.action, let deviceName = ctx.primaryDevice?.friendlyName, knownNames.contains(deviceName) { diff --git a/Shellbee/Features/Groups/GroupCard.swift b/Shellbee/Features/Groups/GroupCard.swift index c3b2401..269e209 100644 --- a/Shellbee/Features/Groups/GroupCard.swift +++ b/Shellbee/Features/Groups/GroupCard.swift @@ -77,24 +77,14 @@ struct GroupCard: View { Spacer(minLength: DesignTokens.Spacing.sm) - VStack(alignment: .trailing, spacing: DesignTokens.Spacing.sm) { - statusPill - Text(scenesTitle == "—" ? "No scenes" : "\(scenesTitle) scenes") - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(.tertiary) } .padding(DesignTokens.Spacing.md) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.secondarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous)) - .shadow(color: .black.opacity(DesignTokens.Shadow.badgeOpacity), - radius: DesignTokens.Spacing.sm, y: DesignTokens.Spacing.xs) + .background(Color(.secondarySystemGroupedBackground), + in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous)) } private var identityRow: some View { diff --git a/Shellbee/Features/Home/HomeLogsCard.swift b/Shellbee/Features/Home/HomeLogsCard.swift index 66e695f..d7f27b0 100644 --- a/Shellbee/Features/Home/HomeLogsCard.swift +++ b/Shellbee/Features/Home/HomeLogsCard.swift @@ -42,18 +42,19 @@ struct HomeLogsCard: View { } struct HomeLogRow: View { + @Environment(AppEnvironment.self) private var environment let entry: LogEntry - static let badgeSize: CGFloat = 26 + static let badgeSize: CGFloat = 32 static var leadingInset: CGFloat { badgeSize + DesignTokens.Spacing.md } var body: some View { HStack(alignment: .center, spacing: DesignTokens.Spacing.md) { - badge + leadingVisual VStack(alignment: .leading, spacing: DesignTokens.Spacing.xxs) { Text(entry.summaryTitle) .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) + .foregroundStyle(titleColor) .lineLimit(1) .truncationMode(.tail) Text(entry.summarySubtitle) @@ -66,21 +67,101 @@ struct HomeLogRow: View { Text(entry.timestamp, format: .dateTime.hour().minute().second()) .font(.caption2.monospacedDigit()) .foregroundStyle(.tertiary) - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) } .padding(.vertical, DesignTokens.Spacing.sm) } - private var badge: some View { - Circle() - .fill(entry.level.color.opacity(DesignTokens.Opacity.onStateTint)) - .frame(width: Self.badgeSize, height: Self.badgeSize) + private var titleColor: Color { + switch entry.level { + case .error: return .red + case .warning: return .orange + default: return .primary + } + } + + private enum Subject { + case device(Device) + case group(Group, members: [Device]) + case none + } + + private var subject: Subject { + let candidate: String? + if let ctx = entry.context, !ctx.devices.isEmpty { + candidate = ctx.devices.first?.friendlyName + } else if let n = entry.deviceName { + candidate = n + } else if case .mqttPublish(let d, _, _) = entry.parsedMessageKind { + candidate = d + } else { + candidate = nil + } + guard let name = candidate else { return .none } + if let device = environment.store.device(named: name) { return .device(device) } + if let group = environment.store.group(named: name) { + return .group(group, members: environment.store.memberDevices(of: group)) + } + return .none + } + + private var leadingVisual: some View { + let size = Self.badgeSize + let badgeSize = size * DesignTokens.Ratio.logRowBadgeSize + let hasSubject: Bool + switch subject { + case .device, .group: hasSubject = true + case .none: hasSubject = false + } + return ZStack(alignment: .bottomTrailing) { + avatar(size: size) + if hasSubject { + categoryBadge(size: badgeSize) + .offset(x: DesignTokens.Size.logRowBadgeOffset, + y: DesignTokens.Size.logRowBadgeOffset) + } + } + .frame(width: size, height: size) + } + + @ViewBuilder + private func avatar(size: CGFloat) -> some View { + switch subject { + case .device(let device): + DeviceImageView(device: device, isAvailable: true, size: size) + .frame(width: size, height: size) + case .group(_, let members): + GroupIconView(memberDevices: Array(members.prefix(2)), size: size) + .frame(width: size, height: size) + case .none: + Circle() + .fill(entry.category.chipTint) + .frame(width: size, height: size) + .overlay { + Image(systemName: entry.category.systemImage) + .font(.system(size: size * DesignTokens.Typography.iconRatioSmall, weight: .semibold)) + .foregroundStyle(.white) + } + } + } + + private func categoryBadge(size: CGFloat) -> some View { + let stroke = max(DesignTokens.Ratio.logRowBadgeBorderMin, + size * DesignTokens.Ratio.logRowBadgeBorder) + let inner = size - stroke * 2 + return Circle() + .fill(Color(.systemBackground)) + .frame(width: size, height: size) .overlay { - Image(systemName: entry.category.systemImage) - .font(DesignTokens.Typography.sectionHeaderLabel) - .foregroundStyle(entry.level.color) + Circle() + .fill(entry.category.chipTint) + .frame(width: inner, height: inner) + .overlay { + Image(systemName: entry.category.systemImage) + .resizable() + .scaledToFit() + .foregroundStyle(.white) + .padding(inner * 0.22) + } } } } diff --git a/Shellbee/Features/Logs/LogDetailView.swift b/Shellbee/Features/Logs/LogDetailView.swift index befe47f..e191308 100644 --- a/Shellbee/Features/Logs/LogDetailView.swift +++ b/Shellbee/Features/Logs/LogDetailView.swift @@ -35,7 +35,7 @@ struct LogDetailView: View { } private static let stateMetadataKeys: Set = [ - "linkquality", "last_seen", "update", "update_available", "device" + "linkquality", "last_seen", "update", "update_available", "device", "elapsed" ] private var logTimeState: [String: JSONValue]? { @@ -52,11 +52,28 @@ struct LogDetailView: View { return nil } + private var resolvedGroup: Group? { + let candidate: String? + if let ctx = entry.context, !ctx.devices.isEmpty { + candidate = ctx.devices.first?.friendlyName + } else if let n = entry.deviceName { + candidate = n + } else if case .mqttPublish(let d, _, _) = entry.parsedMessageKind { + candidate = d + } else { + candidate = nil + } + guard let name = candidate else { return nil } + // Only resolve as group when no real device exists with that name + if environment.store.device(named: name) != nil { return nil } + return environment.store.group(named: name) + } + var body: some View { List { - metadataSection - - if displayDevices.count == 1, let (_, device) = displayDevices.first { + if let group = resolvedGroup { + singleGroupSection(group) + } else if displayDevices.count == 1, let (_, device) = displayDevices.first { singleDeviceSection(device) } else if displayDevices.count > 1 { LogDetailDevicesSection(devices: displayDevices) @@ -72,6 +89,18 @@ struct LogDetailView: View { .navigationTitle(headerTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .principal) { + VStack(spacing: 0) { + Text(headerTitle) + .font(.headline) + Text(timestampSubtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(headerTitle), \(timestampSubtitle)") + } if let doneAction { if entry.category != .stateChange { ToolbarItem(placement: .topBarTrailing) { @@ -100,6 +129,30 @@ struct LogDetailView: View { .accessibilityLabel("Format") } + @ViewBuilder + private func singleGroupSection(_ group: Group) -> some View { + let members = environment.store.memberDevices(of: group) + let groupState = members.reduce(into: [String: JSONValue]()) { acc, d in + for (k, v) in environment.store.state(for: d.friendlyName) where acc[k] == nil { + acc[k] = v + } + } + Section { + ZStack { + GroupCard( + group: group, + memberDevices: members, + state: groupState, + displayMode: .compact + ) + NavigationLink(destination: GroupDetailView(group: group)) { EmptyView() } + .opacity(0) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + } + @ViewBuilder private func singleDeviceSection(_ device: Device) -> some View { Section { @@ -118,7 +171,7 @@ struct LogDetailView: View { .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) } - if let state = logTimeState { + if let state = exposesScopedState(for: device) { Section { ExposeCardView(device: device, state: state, mode: .snapshot) .listRowInsets(EdgeInsets()) @@ -127,20 +180,34 @@ struct LogDetailView: View { } } - private var metadataSection: some View { - Section { - Label { - Text(entry.timestamp, format: .dateTime.month(.abbreviated).day().hour().minute().second()) - .monospacedDigit() - } icon: { - Image(systemName: entry.level.systemImage) - .foregroundStyle(entry.level.color) + /// Filter `logTimeState` to only the keys that are actual exposes of this + /// device. For bridge responses (payload `{data, error, status}`), nothing + /// matches and we return nil — so the device section just shows the hero + /// card. For real state publishes / state-change diffs, the payload keys + /// match exposes and we render the relevant control card with those values. + private func exposesScopedState(for device: Device) -> [String: JSONValue]? { + guard let state = logTimeState else { return nil } + let exposeProps: Set = Set( + (device.definition?.exposes ?? []).flattenedLeaves.compactMap { + $0.property ?? $0.name } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) + ) + let scoped = state.filter { exposeProps.contains($0.key) } + return scoped.isEmpty ? nil : scoped + } + + private var timestampSubtitle: String { + let cal = Calendar.current + let day: String + if cal.isDateInToday(entry.timestamp) { + day = "Today" + } else if cal.isDateInYesterday(entry.timestamp) { + day = "Yesterday" + } else { + day = entry.timestamp.formatted(.dateTime.month(.abbreviated).day()) } - .listSectionSeparator(.hidden) - .listSectionSpacing(.compact) + let time = entry.timestamp.formatted(.dateTime.hour().minute().second()) + return "\(day) at \(time)" } private var jsonSection: some View { @@ -167,7 +234,9 @@ struct LogDetailView: View { if !changes.isEmpty { LogDetailChangesSection(changes: changes) } - if !payload.isEmpty { + // Skip the full-payload snapshot for state-change events — the diff is + // what actually happened, the rest is noise. + if !payload.isEmpty && entry.category != .stateChange { BeautifulPayloadView(payload: payload, device: displayDevices.first?.device) } if changes.isEmpty && payload.isEmpty { diff --git a/Shellbee/Features/Logs/LogRowView.swift b/Shellbee/Features/Logs/LogRowView.swift index 4c72334..89feb84 100644 --- a/Shellbee/Features/Logs/LogRowView.swift +++ b/Shellbee/Features/Logs/LogRowView.swift @@ -12,6 +12,7 @@ struct LogRowView: View { HStack(alignment: .top, spacing: DesignTokens.Spacing.sm) { Text(entry.summaryTitle) .font(.subheadline.bold()) + .foregroundStyle(titleColor) .lineLimit(1) Spacer(minLength: DesignTokens.Spacing.sm) absoluteTimestamp @@ -30,54 +31,104 @@ struct LogRowView: View { .padding(.vertical, DesignTokens.Spacing.summaryRowVerticalPadding) } + // MARK: - Title tint + + private var titleColor: Color { + switch entry.level { + case .error: return .red + case .warning: return .orange + default: return .primary + } + } + // MARK: - Leading visual + private enum Subject { + case device(Device) + case group(Group, members: [Device]) + case none + } + + private var subject: Subject { + let candidate: String? + if let ctx = entry.context, !ctx.devices.isEmpty { + candidate = ctx.devices.first?.friendlyName + } else if let n = entry.deviceName { + candidate = n + } else if case .mqttPublish(let d, _, _) = entry.parsedMessageKind { + candidate = d + } else { + candidate = nil + } + guard let name = candidate else { return .none } + if let device = environment.store.device(named: name) { return .device(device) } + if let group = environment.store.group(named: name) { + return .group(group, members: environment.store.memberDevices(of: group)) + } + return .none + } + private var leadingVisual: some View { let size = DesignTokens.Size.logRowDeviceImage let badgeSize = size * DesignTokens.Ratio.logRowBadgeSize + let hasSubject: Bool + switch subject { + case .device, .group: hasSubject = true + case .none: hasSubject = false + } return ZStack(alignment: .bottomTrailing) { - Circle() - .fill(entry.level.color) - .frame(width: size, height: size) - .overlay { - Image(systemName: entry.category.systemImage) - .font(.system(size: size * DesignTokens.Typography.iconRatioSmall, weight: .semibold)) - .foregroundStyle(iconForeground) - } + avatar(size: size) - if let device = resolvedDevice { - deviceThumbnail(device, size: badgeSize) + if hasSubject { + categoryBadge(size: badgeSize) .offset(x: DesignTokens.Size.logRowBadgeOffset, y: DesignTokens.Size.logRowBadgeOffset) } } } - private var iconForeground: Color { - 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(DesignTokens.Ratio.logRowBadgeBorderMin, - size * DesignTokens.Ratio.logRowBadgeBorder))) + @ViewBuilder + private func avatar(size: CGFloat) -> some View { + switch subject { + case .device(let device): + DeviceImageView(device: device, isAvailable: true, size: size) + .frame(width: size, height: size) + case .group(_, let members): + GroupIconView(memberDevices: Array(members.prefix(2)), size: size) + .frame(width: size, height: size) + case .none: + Circle() + .fill(entry.category.chipTint) + .frame(width: size, height: size) + .overlay { + Image(systemName: entry.category.systemImage) + .font(.system(size: size * DesignTokens.Typography.iconRatioSmall, weight: .semibold)) + .foregroundStyle(.white) + } + } } - private var resolvedDevice: Device? { - let name: String? - if let ctx = entry.context, !ctx.devices.isEmpty { - name = ctx.devices.first?.friendlyName - } else if let n = entry.deviceName { - name = n - } else if case .mqttPublish(let d, _, _) = entry.parsedMessageKind { - name = d - } else { - name = nil - } - return name.flatMap { environment.store.device(named: $0) } + private func categoryBadge(size: CGFloat) -> some View { + let stroke = max(DesignTokens.Ratio.logRowBadgeBorderMin, + size * DesignTokens.Ratio.logRowBadgeBorder) + let inner = size - stroke * 2 + return Circle() + .fill(Color(.systemBackground)) + .frame(width: size, height: size) + .overlay { + Circle() + .fill(entry.category.chipTint) + .frame(width: inner, height: inner) + .overlay { + Image(systemName: entry.category.systemImage) + .resizable() + .scaledToFit() + .font(.system(size: 1, weight: .bold)) + .foregroundStyle(.white) + .padding(inner * 0.22) + } + } } // MARK: - Timestamps diff --git a/Shellbee/Features/Logs/LogsView.swift b/Shellbee/Features/Logs/LogsView.swift index a1facc9..e2f7cff 100644 --- a/Shellbee/Features/Logs/LogsView.swift +++ b/Shellbee/Features/Logs/LogsView.swift @@ -41,13 +41,7 @@ struct LogsView: View { } } } else { - TabView(selection: $mode) { - ActivityLogContent(viewModel: activityVM) - .tag(LogMode.activity) - BridgeLogView(viewModel: bridgeVM) - .tag(LogMode.log) - } - .tabViewStyle(.page(indexDisplayMode: .never)) + modeContent .navigationTitle("Logs") .navigationBarTitleDisplayMode(.inline) .searchable(text: searchBinding, prompt: searchPrompt) @@ -56,6 +50,7 @@ struct LogsView: View { LogDetailView(entry: entry) } .minimizeSearchToolbarIfAvailable() + .toolbar(.hidden, for: .tabBar) .toolbar { ToolbarItem(placement: .principal) { Picker("Mode", selection: $mode) { @@ -85,6 +80,16 @@ struct LogsView: View { } } + @ViewBuilder + private var modeContent: some View { + switch mode { + case .activity: + ActivityLogContent(viewModel: activityVM) + case .log: + BridgeLogView(viewModel: bridgeVM) + } + } + private var searchBinding: Binding { Binding( get: { mode == .activity ? activityVM.searchText : bridgeVM.searchText }, @@ -115,10 +120,12 @@ private struct ActivityLogContent: View { let entries = viewModel.filteredEntries(store: environment.store) List { ForEach(entries) { entry in - NavigationLink { - LogDetailView(entry: entry) - } label: { + ZStack { LogRowView(entry: entry) + NavigationLink { + LogDetailView(entry: entry) + } label: { EmptyView() } + .opacity(0) } } } diff --git a/Shellbee/Shared/Components/BeautifulRow.swift b/Shellbee/Shared/Components/BeautifulRow.swift index 76744ed..aed367f 100644 --- a/Shellbee/Shared/Components/BeautifulRow.swift +++ b/Shellbee/Shared/Components/BeautifulRow.swift @@ -32,7 +32,7 @@ struct BeautifulRow: View { } else if s.hasPrefix("http"), let url = URL(string: s) { URLRow(url: url) } else { - Text(s).font(.subheadline).foregroundStyle(.secondary).lineLimit(2) + Text(s).font(.subheadline).foregroundStyle(.secondary) } case .int(let i): Text(verbatim: unit != nil ? "\(i)\(unit!)" : "\(i)") From 47c38e9d4ff0feae58f8583fe5481ea81b70dfc5 Mon Sep 17 00:00:00 2001 From: tashda Date: Fri, 1 May 2026 11:50:22 +0200 Subject: [PATCH 4/4] Bulk OTA toolbar spinner: use default size Drop .controlSize(.small) on the ProgressView so the spinner matches the surrounding arrow.up.circle glyph it replaces. Fixes #55 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Features/Devices/DeviceFirmwareMenu.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift index 33fbe28..a1d6bf3 100644 --- a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift +++ b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift @@ -68,7 +68,6 @@ struct DeviceFirmwareMenu: View { ZStack(alignment: .topTrailing) { if bulkActive { ProgressView() - .controlSize(.small) } else { Image(systemName: "arrow.up.circle") }