diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index 641c747..a5b777f 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -471,7 +471,7 @@ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportsDocumentBrowser = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.4; @@ -533,7 +533,7 @@ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportsDocumentBrowser = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.4; diff --git a/StikDebug/Features/InstalledApps/InstalledAppRows.swift b/StikDebug/Features/InstalledApps/InstalledAppRows.swift index 8820fb7..808b4a9 100644 --- a/StikDebug/Features/InstalledApps/InstalledAppRows.swift +++ b/StikDebug/Features/InstalledApps/InstalledAppRows.swift @@ -44,23 +44,12 @@ struct AppButton: View { var body: some View { Button(action: selectApp) { - HStack(spacing: loadAppIconsOnJIT ? 16 : 12) { - AppIconView(image: loadAppIconsOnJIT ? iconLoader.image : nil) - - VStack(alignment: .leading, spacing: 3) { - Text(appName) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - - Text(bundleID) - .font(.system(size: 14)) - .foregroundStyle(.secondary) - .lineLimit(1) - .textSelection(.enabled) - } - - Spacer() + HStack(alignment: .top, spacing: 12) { + AppRowIdentity( + appName: appName, + bundleID: bundleID, + image: loadAppIconsOnJIT ? iconLoader.image : nil + ) if favoriteApps.contains(bundleID) { Image(systemName: "star.fill") @@ -239,6 +228,8 @@ struct AppButton: View { } struct LaunchAppRow: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + let bundleID: String let appName: String let isLaunching: Bool @@ -265,26 +256,17 @@ struct LaunchAppRow: View { guard !isLaunching else { return } launchAction() } label: { - HStack(spacing: loadAppIconsOnJIT ? 16 : 12) { - AppIconView(image: loadAppIconsOnJIT ? iconLoader.image : nil) - - VStack(alignment: .leading, spacing: 3) { - Text(appName) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - - Text(bundleID) - .font(.system(size: 14)) - .foregroundStyle(.secondary) - .lineLimit(1) - .textSelection(.enabled) - } - - Spacer() + rowLayout { + AppRowIdentity( + appName: appName, + bundleID: bundleID, + image: loadAppIconsOnJIT ? iconLoader.image : nil + ) if isLaunching { - ProgressView().controlSize(.small) + ProgressView() + .controlSize(.small) + .frame(minWidth: 44, minHeight: 44) } else { Text("Launch".localized) .font(.footnote.weight(.semibold)) @@ -292,6 +274,8 @@ struct LaunchAppRow: View { .padding(.vertical, 6) .background(Capsule().fill(Color.accentColor.opacity(0.18))) .foregroundStyle(Color.accentColor) + .fixedSize() + .frame(minHeight: 44) } } .padding(.vertical, loadAppIconsOnJIT ? 4 : 8) @@ -319,6 +303,12 @@ struct LaunchAppRow: View { } } + private var rowLayout: AnyLayout { + dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) + : AnyLayout(HStackLayout(spacing: 12)) + } + private var accessibilityValue: String { let state = isLaunching ? "Launching".localized : "Ready".localized return "\(state), \(String(format: "Bundle ID %@".localized, bundleID))" @@ -332,6 +322,43 @@ struct LaunchAppRow: View { } } +private struct AppRowIdentity: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + let appName: String + let bundleID: String + let image: UIImage? + + var body: some View { + identityLayout { + AppIconView(image: image) + + VStack(alignment: .leading, spacing: 3) { + Text(appName) + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(dynamicTypeSize.isAccessibilitySize ? nil : 2) + .fixedSize(horizontal: false, vertical: true) + + Text(bundleID) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var identityLayout: AnyLayout { + dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) + : AnyLayout(HStackLayout(spacing: 12)) + } +} + private struct AppIconView: View { let image: UIImage? diff --git a/StikDebug/JSSupport/ScriptEditorView.swift b/StikDebug/JSSupport/ScriptEditorView.swift index 606a4de..2ca8b4c 100644 --- a/StikDebug/JSSupport/ScriptEditorView.swift +++ b/StikDebug/JSSupport/ScriptEditorView.swift @@ -13,6 +13,7 @@ struct ScriptEditorView: View { let scriptURL: URL @State private var scriptContent: String = "" + @State private var hasLoadedScript = false @State private var position: CodeEditor.Position = .init() @State private var messages: Set> = [] @@ -39,7 +40,6 @@ struct ScriptEditorView: View { .environment(\.codeEditorTheme, editorTheme) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea(edges: .bottom) } .navigationTitle(scriptURL.lastPathComponent) .navigationBarTitleDisplayMode(.inline) @@ -57,6 +57,9 @@ struct ScriptEditorView: View { } private func loadScript() { + // A display transition may make the editor appear again. Preserve drafts. + guard !hasLoadedScript else { return } + hasLoadedScript = true scriptContent = (try? String(contentsOf: scriptURL)) ?? "" } diff --git a/StikDebug/Views/ConsoleLogsView.swift b/StikDebug/Views/ConsoleLogsView.swift index 10db4c4..e129d33 100644 --- a/StikDebug/Views/ConsoleLogsView.swift +++ b/StikDebug/Views/ConsoleLogsView.swift @@ -50,16 +50,19 @@ struct ConsoleLogsView: View { } .navigationTitle("Console") .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - Picker("", selection: $selectedConsoleTab) { - Text("App").tag(ConsoleTab.idevice) - Text("System").tag(ConsoleTab.syslog) - } - .pickerStyle(.segmented) - .frame(width: 180) + .safeAreaInset(edge: .top, spacing: 0) { + Picker("Log source", selection: $selectedConsoleTab) { + Text("App").tag(ConsoleTab.idevice) + Text("System").tag(ConsoleTab.syslog) } - ToolbarItem(placement: .navigationBarTrailing) { + .pickerStyle(.segmented) + .accessibilityLabel("Log source") + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { Menu { if selectedConsoleTab == .idevice { Button("Refresh", systemImage: "arrow.clockwise") { @@ -87,7 +90,7 @@ struct ConsoleLogsView: View { } } } label: { - Image(systemName: "ellipsis.circle") + Label("Console Actions", systemImage: "ellipsis.circle") } } } @@ -107,7 +110,7 @@ struct ConsoleLogsView: View { Text("Choose how quickly new relay entries appear.") } } - .onDisappear { + .onDisappear { systemLogStream.stop() } .onChange(of: systemLogStream.lastError) { _, newError in @@ -124,24 +127,24 @@ struct ConsoleLogsView: View { LazyVStack(spacing: 0) { VStack(alignment: .leading, spacing: 4) { Text("=== DEVICE INFORMATION ===") - .font(.system(size: 11, design: .monospaced)) + .font(.caption2.monospaced()) .foregroundColor(colorScheme == .dark ? .white : .black) .padding(.vertical, 4) Text("iOS Version: \(UIDevice.current.systemVersion)") - .font(.system(size: 11, design: .monospaced)) + .font(.caption2.monospaced()) .foregroundColor(colorScheme == .dark ? .white : .black) Text("Device: \(UIDevice.current.name)") - .font(.system(size: 11, design: .monospaced)) + .font(.caption2.monospaced()) .foregroundColor(colorScheme == .dark ? .white : .black) Text("Model: \(UIDevice.current.model)") - .font(.system(size: 11, design: .monospaced)) + .font(.caption2.monospaced()) .foregroundColor(colorScheme == .dark ? .white : .black) Text("=== LOG ENTRIES ===") - .font(.system(size: 11, design: .monospaced)) + .font(.caption2.monospaced()) .foregroundColor(colorScheme == .dark ? .white : .black) .padding(.vertical, 4) } @@ -199,8 +202,10 @@ struct ConsoleLogsView: View { Button { syslogSearchText = "" } label: { - Image(systemName: "xmark.circle.fill") + Label("Clear Filter", systemImage: "xmark.circle.fill") + .labelStyle(.iconOnly) .foregroundStyle(.secondary) + .frame(minWidth: 44, minHeight: 44) } } } @@ -501,7 +506,7 @@ private struct AppLogRow: View, Equatable { var body: some View { Text(AttributedString(attributedString)) - .font(.system(size: 11, design: .monospaced)) + .font(.caption2.monospaced()) .textSelection(.enabled) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) @@ -550,7 +555,7 @@ private struct SyslogRow: View, Equatable { var body: some View { Text(AttributedString(attributedString)) - .font(.system(size: 11, design: .monospaced)) + .font(.caption2.monospaced()) .textSelection(.enabled) .lineLimit(nil) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/StikDebug/Views/HomeView.swift b/StikDebug/Views/HomeView.swift index 363ba80..3ccf7fc 100644 --- a/StikDebug/Views/HomeView.swift +++ b/StikDebug/Views/HomeView.swift @@ -36,10 +36,10 @@ struct HomeView: View { Haptics.medium() startJITInBackground(bundleID: selectedBundle, displayName: selectedName) }, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }) - .overlay(alignment: .bottom) { + .safeAreaInset(edge: .bottom, spacing: 0) { if let debugFeedback { debugFeedbackView(debugFeedback) - .padding(.bottom, 40) + .padding(.vertical, 8) .transition(.move(edge: .bottom).combined(with: .opacity)) } } diff --git a/StikDebug/Views/InstalledAppsListView.swift b/StikDebug/Views/InstalledAppsListView.swift index 3f22192..a39c19a 100644 --- a/StikDebug/Views/InstalledAppsListView.swift +++ b/StikDebug/Views/InstalledAppsListView.swift @@ -65,14 +65,15 @@ struct InstalledAppsListView: View { placement: .navigationBarDrawer(displayMode: .always), prompt: selectedTab.searchPrompt ) - .toolbar { - tabPickerToolbarItem - leadingToolbarItem - trailingToolbarItem + .safeAreaInset(edge: .top, spacing: 0) { + tabPicker + } + .safeAreaInset(edge: .bottom, spacing: 0) { + launchFeedbackBanner + } + .toolbar { + appActionsToolbarItems } - } - .overlay { - launchFeedbackOverlay } .onAppear(perform: refreshIconPrefetch) .onChange(of: favoriteApps) { _, _ in prefetchPriorityIcons() } @@ -129,65 +130,63 @@ struct InstalledAppsListView: View { ) } - @ToolbarContentBuilder - private var tabPickerToolbarItem: some ToolbarContent { - ToolbarItem(placement: .principal) { - Picker("", selection: $selectedTab) { - ForEach(AppListTab.allCases) { tab in - Text(tab.title.localized).tag(tab) - } + private var tabPicker: some View { + Picker("App category", selection: $selectedTab) { + ForEach(AppListTab.allCases) { tab in + Text(tab.title.localized).tag(tab) } - .pickerStyle(.segmented) - .frame(width: 220) } + .pickerStyle(.segmented) + .accessibilityLabel("App category") + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) } @ToolbarContentBuilder - private var leadingToolbarItem: some ToolbarContent { - if let onImportPairingFile { - ToolbarItem(placement: .navigationBarLeading) { + private var appActionsToolbarItems: some ToolbarContent { + ToolbarItemGroup(placement: .primaryAction) { + if let onImportPairingFile { Button(action: onImportPairingFile) { - Image(systemName: "doc.badge.plus") + Label("Import Pairing File", systemImage: "doc.badge.plus") } } + if !showDoneButton { + Button { + viewModel.refreshAppLists() + } label: { + Label("Refresh Apps", systemImage: "arrow.clockwise") + } + .disabled(viewModel.isLoading) + } } - } - - @ToolbarContentBuilder - private var trailingToolbarItem: some ToolbarContent { - ToolbarItem(placement: .navigationBarTrailing) { - if showDoneButton { + if showDoneButton { + ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } .fontWeight(.semibold) - } else { - Button { - viewModel.refreshAppLists() - } label: { - Image(systemName: "arrow.clockwise") - } - .disabled(viewModel.isLoading) } } } @ViewBuilder - private var launchFeedbackOverlay: some View { + private var launchFeedbackBanner: some View { if let launchFeedback { - VStack { - Spacer() - Text(launchFeedback.message) - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Capsule().fill(.ultraThinMaterial)) - .foregroundStyle(launchFeedback.success ? .green : .red) - .shadow(radius: 4) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .padding(.bottom, 40) - } - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback.id) + Text(launchFeedback.message) + .font(.subheadline.weight(.semibold)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + .foregroundStyle(launchFeedback.success ? .green : .red) + .shadow(radius: 4) + .padding(.horizontal) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback.id) } } diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 037f9f4..974e515 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -24,6 +24,14 @@ private struct CoordinateSnapshot: Equatable { } } +private struct SimulationControlsHeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + private struct RouteSearchSelection { let title: String let coordinate: CLLocationCoordinate2D @@ -726,6 +734,7 @@ final class LocationSearchCompleter: NSObject, ObservableObject, MKLocalSearchCo struct LocationSimulationView: View { @State private var coordinate: CLLocationCoordinate2D? @State private var position: MapCameraPosition = .userLocation(fallback: .automatic) + @State private var simulationControlsHeight: CGFloat = 0 @State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid @State private var resendTimer: Timer? @@ -859,10 +868,7 @@ struct LocationSimulationView: View { } } .listStyle(.plain) - .frame(maxHeight: 350) - .scrollDisabled(true) - .padding(.horizontal, 16) - .padding(.top, 8) + .scrollDismissesKeyboard(.interactively) } @ViewBuilder @@ -876,113 +882,122 @@ struct LocationSimulationView: View { } var body: some View { - ZStack(alignment: .bottom) { - MapReader { proxy in - Map(position: $position) { - if hasRouteContext { - if let routePolyline { - MapPolyline(routePolyline) - .stroke(.blue.opacity(0.8), lineWidth: 5) - } - if let routeStartCoordinate { - Marker("Start", coordinate: routeStartCoordinate) - .tint(.green) - } - if let routeEndCoordinate { - Marker("End", coordinate: routeEndCoordinate) + GeometryReader { geometry in + // Reserve overlay spacing even when the keyboard reduces the visible map. + let panelHeight = max(0, geometry.size.height - 40) + + ZStack(alignment: .bottom) { + MapReader { proxy in + Map(position: $position) { + if hasRouteContext { + if let routePolyline { + MapPolyline(routePolyline) + .stroke(.blue.opacity(0.8), lineWidth: 5) + } + if let routeStartCoordinate { + Marker("Start", coordinate: routeStartCoordinate) + .tint(.green) + } + if let routeEndCoordinate { + Marker("End", coordinate: routeEndCoordinate) + .tint(.red) + } + if let routePlaybackCoordinate { + Marker("Current", coordinate: routePlaybackCoordinate) + .tint(.blue) + } + } else if let coordinate { + Marker("Pin", coordinate: coordinate) .tint(.red) } - if let routePlaybackCoordinate { - Marker("Current", coordinate: routePlaybackCoordinate) - .tint(.blue) + } + .mapStyle(.standard(elevation: .realistic)) + .onTapGesture { point in + if let loc = proxy.convert(point, from: .local) { + applySelection(loc) } - } else if let coordinate { - Marker("Pin", coordinate: coordinate) - .tint(.red) + } + .mapControls { + MapCompass() } } - .mapStyle(.standard(elevation: .realistic)) - .onTapGesture { point in - if let loc = proxy.convert(point, from: .local) { - applySelection(loc) - } - } - .mapControls { - MapCompass() - } - } - .ignoresSafeArea() - .onChange(of: coordinate.map(CoordinateSnapshot.init)) { _, new in - if let new { - position = .region( - MKCoordinateRegion( - center: new.coordinate, - latitudinalMeters: 1000, - longitudinalMeters: 1000 + .ignoresSafeArea() + .onChange(of: coordinate.map(CoordinateSnapshot.init)) { _, new in + if let new { + position = .region( + MKCoordinateRegion( + center: new.coordinate, + latitudinalMeters: 1000, + longitudinalMeters: 1000 + ) ) - ) + } } - } - - VStack(spacing: 0) { - if !searchCompleter.results.isEmpty { - searchResultsList - } - - Spacer() VStack(spacing: 12) { - if isImportingCoordinates { - ProgressView("Importing coordinates…") - .font(.footnote) + if !searchCompleter.results.isEmpty { + searchResultsList + .frame(maxWidth: 600) + .frame(height: min(350, panelHeight * 0.4)) } - if hasRouteContext { - routeControls - } else { - pinControls + Spacer(minLength: 0) + + ScrollView { + simulationControls + .fixedSize(horizontal: false, vertical: true) + .background { + GeometryReader { controlsGeometry in + Color.clear.preference( + key: SimulationControlsHeightKey.self, + value: controlsGeometry.size.height + ) + } + } } + .scrollBounceBehavior(.basedOnSize) + .onPreferenceChange(SimulationControlsHeightKey.self) { + simulationControlsHeight = $0 + } + .frame(maxWidth: 600) + .frame(height: min(simulationControlsHeight, panelHeight * 0.45)) + .background(.regularMaterial, in: .rect(cornerRadius: 16)) + .clipShape(.rect(cornerRadius: 16)) } - .padding(.bottom, 24) - .padding(.horizontal, 16) .padding(.horizontal, 16) + .padding(.vertical, 8) } } .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: "Search location…") + .autocorrectionDisabled() + .onChange(of: searchText) { _, newValue in + searchCompleter.update(query: newValue) + } + .onSubmit(of: .search) { + applyCoordinatesFromSearchText() + } .toolbar { ToolbarItemGroup(placement: .topBarLeading) { Button { showBookmarks = true } label: { - Image(systemName: "bookmark.fill") + Label("Bookmarks", systemImage: "bookmark.fill") } Button { showRouteSearch = true } label: { - Image(systemName: "point.topleft.down.curvedto.point.bottomright.up") + Label("Plan Route", systemImage: "point.topleft.down.curvedto.point.bottomright.up") } .disabled(isBusy || isRouteRunning) Button { showCoordinateImporter = true } label: { - Image(systemName: "square.and.arrow.down") + Label("Import Coordinates", systemImage: "square.and.arrow.down") } .disabled(isBusy || isRouteRunning || isImportingCoordinates) - .accessibilityLabel("Import Coordinates") - } - ToolbarItem(placement: .topBarTrailing) { - TextField("Search location...", text: $searchText) - .padding(.leading, 6) - .autocorrectionDisabled() - .submitLabel(.go) - .onChange(of: searchText) { _, newValue in - searchCompleter.update(query: newValue) - } - .onSubmit { - applyCoordinatesFromSearchText() - } } } .alert(alertTitle, isPresented: $showAlert) { @@ -1212,6 +1227,24 @@ struct LocationSimulationView: View { showAlert = true } + private var simulationControls: some View { + VStack(spacing: 12) { + if isImportingCoordinates { + ProgressView("Importing coordinates…") + .font(.footnote) + } + + if hasRouteContext { + routeControls + } else { + pinControls + } + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(12) + } + @ViewBuilder private var pinControls: some View { if let coord = coordinate { @@ -1219,24 +1252,15 @@ struct LocationSimulationView: View { .font(.footnote.monospaced()) .foregroundStyle(.secondary) - HStack(spacing: 12) { - Button("Stop", action: clear) - .buttonStyle(.bordered) - .tint(.red) - .disabled(!pairingExists || isBusy || !hasActiveSimulation) - - Button("Simulate Location", action: simulate) - .buttonStyle(.borderedProminent) - .disabled(!pairingExists || isBusy || isLoadingRoute) - - Button { - showSaveBookmark = true - } label: { - Image(systemName: "bookmark") + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + pinActionButtons + } + .fixedSize(horizontal: true, vertical: false) + + VStack(spacing: 8) { + pinActionButtons } - .buttonStyle(.bordered) - .tint(.blue) - .disabled(isRouteRunning) } } else { Text("Tap map to drop pin") @@ -1245,6 +1269,28 @@ struct LocationSimulationView: View { } } + @ViewBuilder + private var pinActionButtons: some View { + Button("Stop", action: clear) + .buttonStyle(.bordered) + .tint(.red) + .disabled(!pairingExists || isBusy || !hasActiveSimulation) + + Button("Simulate Location", action: simulate) + .buttonStyle(.borderedProminent) + .disabled(!pairingExists || isBusy || isLoadingRoute) + + Button { + showSaveBookmark = true + } label: { + Label("Save Bookmark", systemImage: "bookmark") + .labelStyle(.iconOnly) + } + .buttonStyle(.bordered) + .tint(.blue) + .disabled(isRouteRunning) + } + private var routeControls: some View { VStack(spacing: 10) { Text(routeStatusText) @@ -1262,30 +1308,42 @@ struct LocationSimulationView: View { routeAttributionLink - HStack(spacing: 12) { - Button("Stop", action: clear) - .buttonStyle(.bordered) - .tint(.red) - .disabled(!pairingExists || isBusy || !hasActiveSimulation) + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + routeActionButtons + } + .fixedSize(horizontal: true, vertical: false) - Button("Play Route", action: simulateRoute) - .buttonStyle(.borderedProminent) - .disabled( - !pairingExists || - isBusy || - isLoadingRoute || - isPrefetchingRouteSpeeds || - routePlan == nil || - routePlaybackSamples.isEmpty - ) - - Button("Reset", action: resetRouteSelection) - .buttonStyle(.bordered) - .disabled(isBusy || isRouteRunning) + VStack(spacing: 8) { + routeActionButtons + } } } } + @ViewBuilder + private var routeActionButtons: some View { + Button("Stop", action: clear) + .buttonStyle(.bordered) + .tint(.red) + .disabled(!pairingExists || isBusy || !hasActiveSimulation) + + Button("Play Route", action: simulateRoute) + .buttonStyle(.borderedProminent) + .disabled( + !pairingExists || + isBusy || + isLoadingRoute || + isPrefetchingRouteSpeeds || + routePlan == nil || + routePlaybackSamples.isEmpty + ) + + Button("Reset", action: resetRouteSelection) + .buttonStyle(.bordered) + .disabled(isBusy || isRouteRunning) + } + private func simulate() { guard pairingExists, let coord = coordinate, !isBusy else { return } runLocationCommand( diff --git a/StikDebug/Views/SettingsView.swift b/StikDebug/Views/SettingsView.swift index eac156b..345a325 100644 --- a/StikDebug/Views/SettingsView.swift +++ b/StikDebug/Views/SettingsView.swift @@ -143,16 +143,14 @@ struct SettingsView: View { } Section("Advanced") { - HStack { + VStack(alignment: .leading, spacing: 6) { Text("Target Device IP") - Spacer() TextField(DeviceConnectionContext.defaultTargetIPAddress, text: $targetDeviceIP) - .multilineTextAlignment(.trailing) .foregroundStyle(.secondary) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) .keyboardType(.numbersAndPunctuation) - .frame(maxWidth: 160) + .accessibilityLabel("Target Device IP") } Button { openAppFolder() } label: { Label("App Folder", systemImage: "folder") diff --git a/StikDebug/Views/ToolsView.swift b/StikDebug/Views/ToolsView.swift index 7e81f88..2ffaf93 100644 --- a/StikDebug/Views/ToolsView.swift +++ b/StikDebug/Views/ToolsView.swift @@ -8,12 +8,14 @@ import SwiftUI struct ToolsView: View { + @State private var selectedTool: AppFeature? + var body: some View { - NavigationStack { - List(AppFeature.toolList) { tool in - NavigationLink { - tool.destination - } label: { + // Let the system expand/collapse the same navigation hierarchy as the + // window resizes, preserving the selected tool and its local state. + NavigationSplitView { + List(AppFeature.toolList, selection: $selectedTool) { tool in + NavigationLink(value: tool) { Label { VStack(alignment: .leading, spacing: 2) { Text(tool.toolTitle) @@ -25,8 +27,20 @@ struct ToolsView: View { Image(systemName: tool.systemImage) } } + .accessibilityIdentifier("tool-\(tool.id)") } .navigationTitle("Tools") + } detail: { + if let selectedTool { + selectedTool.destination + } else { + ContentUnavailableView( + "Select a Tool", + systemImage: "wrench.and.screwdriver", + description: Text("Choose a tool to get started.") + ) + } } + .navigationSplitViewStyle(.balanced) } }