mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 12:02:14 +00:00
WIP Duo UI Optimization
This commit is contained in:
parent
94bc9e8cf3
commit
c65745cf14
9 changed files with 331 additions and 227 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TextLocated<Message>> = []
|
||||
|
||||
|
|
@ -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)) ?? ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue