Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Config/BuildSettings.local.example.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// archives. Do not commit the local file.

APP_DEVELOPMENT_TEAM = YOURTEAMID
APP_IPHONEOS_CODE_SIGN_IDENTITY =
APP_BUNDLE_ID = com.example.shellbee
APP_WIDGET_BUNDLE_SUFFIX = widgets
APP_WIDGET_BUNDLE_ID = $(APP_BUNDLE_ID).$(APP_WIDGET_BUNDLE_SUFFIX)
Expand Down
1 change: 1 addition & 0 deletions Config/BuildSettings.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// which is ignored by git and should be used for device builds and App Store archives.

APP_DEVELOPMENT_TEAM =
APP_IPHONEOS_CODE_SIGN_IDENTITY =
APP_BUNDLE_ID = dev.echodb.shellbee
APP_WIDGET_BUNDLE_SUFFIX = shellbeeWidgets
APP_WIDGET_BUNDLE_ID = $(APP_BUNDLE_ID).$(APP_WIDGET_BUNDLE_SUFFIX)
Expand Down
24 changes: 12 additions & 12 deletions Shellbee.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -833,8 +833,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = Shellbee.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution";
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "$(APP_DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
Expand All @@ -847,7 +846,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.6.1;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand All @@ -871,8 +870,9 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = Shellbee.entitlements;
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_ENTITLEMENTS = Shellbee.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "$(APP_IPHONEOS_CODE_SIGN_IDENTITY)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "$(APP_DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
Expand All @@ -885,7 +885,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.6.1;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand All @@ -909,8 +909,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = ShellbeeWidgets.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution";
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "$(APP_DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
Expand All @@ -924,7 +923,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.6.1;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
Expand All @@ -948,8 +947,9 @@
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = ShellbeeWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_ENTITLEMENTS = ShellbeeWidgets.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "$(APP_IPHONEOS_CODE_SIGN_IDENTITY)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "$(APP_DEVELOPMENT_TEAM)";
GENERATE_INFOPLIST_FILE = YES;
Expand All @@ -963,7 +963,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.6.1;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
Expand Down
18 changes: 13 additions & 5 deletions Shellbee/App/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,19 @@ private struct LogSheetHost: View {
}
}
} else {
LogsView(
initialEntryFilter: Set(request.entryIDs),
notificationSheetStyle: true,
onDone: { dismiss() }
)
NavigationStack {
LogsView(
initialEntryFilter: Set(request.entryIDs),
notificationSheetStyle: true,
onDone: { dismiss() }
)
.navigationDestination(for: DeviceRoute.self) { route in
DeviceDetailView(bridgeID: route.bridgeID, device: route.device)
}
.navigationDestination(for: GroupRoute.self) { route in
GroupDetailView(bridgeID: route.bridgeID, group: route.group)
}
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Shellbee/Features/Devices/DeviceDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ struct DeviceDetailView: View {
} else {
ForEach(recent) { entry in
NavigationLink {
LogDetailView(bridgeID: bridgeID, entry: entry, originDeviceIEEE: device.ieeeAddress)
LogDetailView(bridgeID: bridgeID, entry: entry)
} label: {
LogRowView(entry: entry, store: scope.store, bridgeID: bridgeID)
}
Expand Down
2 changes: 1 addition & 1 deletion Shellbee/Features/Devices/DeviceLogsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ struct DeviceLogsView: View {
List {
ForEach(entries) { entry in
NavigationLink {
LogDetailView(bridgeID: bridgeID, entry: entry, originDeviceIEEE: device.ieeeAddress)
LogDetailView(bridgeID: bridgeID, entry: entry)
} label: {
LogRowView(entry: entry, store: scope.store, bridgeID: bridgeID)
}
Expand Down
12 changes: 6 additions & 6 deletions Shellbee/Features/Logs/LogDetailDevicesSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ struct LogDetailDevicesSection: View {
var body: some View {
Section("Devices") {
ForEach(devices, id: \.device.ieeeAddress) { ref, device in
ZStack {
// Closure-based NavigationLink — see singleDeviceSection in
// LogDetailView for why we don't mix value-based pushes
// with the closure-based log row push that got us here.
NavigationLink {
DeviceDetailView(bridgeID: bridgeID, device: device)
} label: {
HStack(spacing: DesignTokens.Spacing.md) {
DeviceImageView(
device: device,
Expand All @@ -30,12 +35,7 @@ struct LogDetailDevicesSection: View {
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
NavigationLink(value: DeviceRoute(bridgeID: bridgeID, device: device)) { EmptyView() }
.opacity(0)
}
}
}
Expand Down
32 changes: 18 additions & 14 deletions Shellbee/Features/Logs/LogDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,13 @@ struct LogDetailView: View {
/// the entry resolve against the right store.
let bridgeID: UUID
let entry: LogEntry
/// IEEE address of the device whose log feed pushed this view, if any.
/// When set, the device hero card for that same device renders without a
/// NavigationLink — tapping it would push back to the device the user
/// just came from, which is a dead-end interaction.
private let originDeviceIEEE: String?
private let doneAction: (() -> Void)?

enum ViewMode { case beautiful, json }

init(bridgeID: UUID, entry: LogEntry, originDeviceIEEE: String? = nil, doneAction: (() -> Void)? = nil) {
init(bridgeID: UUID, entry: LogEntry, doneAction: (() -> Void)? = nil) {
self.bridgeID = bridgeID
self.entry = entry
self.originDeviceIEEE = originDeviceIEEE
self.doneAction = doneAction
}

Expand Down Expand Up @@ -161,6 +155,9 @@ struct LogDetailView: View {
}
}
Section {
// ZStack + closure-based NavigationLink overlay — same pattern
// as singleDeviceSection. Card's internal chevron is the only
// disclosure indicator; List doesn't auto-add its own.
ZStack {
GroupCard(
group: group,
Expand All @@ -170,8 +167,10 @@ struct LogDetailView: View {
bridgeName: environment.registry.session(for: bridgeID)?.displayName,
displayMode: .compact
)
NavigationLink(value: GroupRoute(bridgeID: bridgeID, group: group)) { EmptyView() }
.opacity(0)
NavigationLink {
GroupDetailView(bridgeID: bridgeID, group: group)
} label: { EmptyView() }
.opacity(0)
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
Expand Down Expand Up @@ -201,8 +200,13 @@ struct LogDetailView: View {

@ViewBuilder
private func singleDeviceSection(_ device: Device) -> some View {
let isOrigin = device.ieeeAddress == originDeviceIEEE
Section {
// ZStack with a closure-based NavigationLink overlay: the card's
// internal chevron is the only disclosure indicator (the List
// doesn't auto-add its own because the row's primary content is
// the card, not the link). Closure-based push avoids the
// value-based path mixing that previously re-fired the row's
// own NavigationLink.
ZStack {
DeviceCard(
device: device,
Expand All @@ -214,10 +218,10 @@ struct LogDetailView: View {
lastSeenEnabled: (scope.store.bridgeInfo?.config?.advanced?.lastSeen ?? "disable") != "disable",
displayMode: .compact
)
if !isOrigin {
NavigationLink(value: DeviceRoute(bridgeID: bridgeID, device: device)) { EmptyView() }
.opacity(0)
}
NavigationLink {
DeviceDetailView(bridgeID: bridgeID, device: device)
} label: { EmptyView() }
.opacity(0)
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
Expand Down
120 changes: 54 additions & 66 deletions Shellbee/Features/Logs/LogsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,80 +26,68 @@ struct LogsView: View {
}

var body: some View {
NavigationStack {
if notificationSheetStyle {
ActivityLogContent(viewModel: activityVM)
.navigationTitle("Logs")
.navigationBarTitleDisplayMode(.inline)
.onAppear { applyInitialFilter(autoOpenSingle: false) }
// Mirror the in-tab Logs stack so log detail's device /
// group hero card pushes within the sheet instead of
// emitting a runtime warning and silently failing.
.navigationDestination(for: DeviceRoute.self) { route in
DeviceDetailView(bridgeID: route.bridgeID, device: route.device)
}
.navigationDestination(for: GroupRoute.self) { route in
GroupDetailView(bridgeID: route.bridgeID, group: route.group)
}
.toolbar {
if let onDone {
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: onDone)
.fontWeight(.semibold)
}
}
}
} else {
modeContent
// Intentionally NOT wrapped in its own NavigationStack. LogsView is
// never a tab root — every entry point already provides a stack:
// - Settings → Logs and BridgeSettings → Logs push LogsView onto
// that tab's stack via NavigationLink.
// - LogSheetHost (Home → Recent Events, notification taps) wraps
// LogsView in a NavigationStack at the sheet level.
// A nested NavigationStack here breaks SwiftUI's value-based push
// routing: NavigationLink(value: DeviceRoute) from inside LogDetail
// could land on either stack, depending on iOS version, leaving the
// user dropped back to a parent screen with nothing pushed.
if notificationSheetStyle {
ActivityLogContent(viewModel: activityVM)
.navigationTitle("Logs")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: searchBinding, prompt: searchPrompt)
.onAppear { applyInitialFilter(autoOpenSingle: true) }
.navigationDestination(item: $autoOpenedEntry) { route in
LogDetailView(bridgeID: route.bridgeID, entry: route.entry)
}
// LogDetailView's device/group hero card pushes these routes
// when the user taps it. Without handlers on this stack the
// links emit a runtime warning and don't navigate; the device
// and group tabs each register the same destinations on their
// own stacks.
.navigationDestination(for: DeviceRoute.self) { route in
DeviceDetailView(bridgeID: route.bridgeID, device: route.device)
}
.navigationDestination(for: GroupRoute.self) { route in
GroupDetailView(bridgeID: route.bridgeID, group: route.group)
}
.minimizeSearchToolbarIfAvailable()
.toolbar(.hidden, for: .tabBar)
.onAppear { applyInitialFilter(autoOpenSingle: false) }
.toolbar {
ToolbarItem(placement: .principal) {
Picker("Mode", selection: $mode) {
ForEach(LogMode.allCases, id: \.self) { Text($0.rawValue).tag($0) }
if let onDone {
ToolbarItem(placement: .confirmationAction) {
Button("Done", action: onDone)
.fontWeight(.semibold)
}
.pickerStyle(.segmented)
.fixedSize()
}
if mode == .activity {
ToolbarItem(placement: .topBarTrailing) {
LogFilterMenu(viewModel: activityVM)
}
} else {
ToolbarItem(placement: .topBarTrailing) {
BridgeLevelFilterMenu(viewModel: bridgeVM)
}
}
} else {
modeContent
.navigationTitle("Logs")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: searchBinding, prompt: searchPrompt)
.onAppear { applyInitialFilter(autoOpenSingle: true) }
.navigationDestination(item: $autoOpenedEntry) { route in
LogDetailView(bridgeID: route.bridgeID, entry: route.entry)
}
.minimizeSearchToolbarIfAvailable()
.toolbar(.hidden, for: .tabBar)
.toolbar {
ToolbarItem(placement: .principal) {
Picker("Mode", selection: $mode) {
ForEach(LogMode.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.fixedSize()
}
if mode == .activity {
ToolbarItem(placement: .topBarTrailing) {
Button(role: .destructive) {
// Phase 1 multi-bridge: always clear across every
// connected session — the activity tab merges by
// default, and per-bridge clearing belongs in a
// future per-bridge logs picker.
for session in environment.registry.orderedSessions {
session.store.clearLogs()
}
} label: {
Image(systemName: "trash")
LogFilterMenu(viewModel: activityVM)
}
} else {
ToolbarItem(placement: .topBarTrailing) {
BridgeLevelFilterMenu(viewModel: bridgeVM)
}
}
ToolbarItem(placement: .topBarTrailing) {
Button(role: .destructive) {
// Phase 1 multi-bridge: always clear across every
// connected session — the activity tab merges by
// default, and per-bridge clearing belongs in a
// future per-bridge logs picker.
for session in environment.registry.orderedSessions {
session.store.clearLogs()
}
} label: {
Image(systemName: "trash")
}
}
}
Expand Down
Loading
Loading