mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +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_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = 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_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
|
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
|
||||||
|
|
@ -533,7 +533,7 @@
|
||||||
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
|
||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = 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_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
|
INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
|
||||||
|
|
|
||||||
|
|
@ -44,23 +44,12 @@ struct AppButton: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: selectApp) {
|
Button(action: selectApp) {
|
||||||
HStack(spacing: loadAppIconsOnJIT ? 16 : 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
AppIconView(image: loadAppIconsOnJIT ? iconLoader.image : nil)
|
AppRowIdentity(
|
||||||
|
appName: appName,
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
bundleID: bundleID,
|
||||||
Text(appName)
|
image: loadAppIconsOnJIT ? iconLoader.image : nil
|
||||||
.font(.system(size: 17, weight: .semibold))
|
)
|
||||||
.foregroundStyle(.primary)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text(bundleID)
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if favoriteApps.contains(bundleID) {
|
if favoriteApps.contains(bundleID) {
|
||||||
Image(systemName: "star.fill")
|
Image(systemName: "star.fill")
|
||||||
|
|
@ -239,6 +228,8 @@ struct AppButton: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LaunchAppRow: View {
|
struct LaunchAppRow: View {
|
||||||
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
|
|
||||||
let bundleID: String
|
let bundleID: String
|
||||||
let appName: String
|
let appName: String
|
||||||
let isLaunching: Bool
|
let isLaunching: Bool
|
||||||
|
|
@ -265,26 +256,17 @@ struct LaunchAppRow: View {
|
||||||
guard !isLaunching else { return }
|
guard !isLaunching else { return }
|
||||||
launchAction()
|
launchAction()
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: loadAppIconsOnJIT ? 16 : 12) {
|
rowLayout {
|
||||||
AppIconView(image: loadAppIconsOnJIT ? iconLoader.image : nil)
|
AppRowIdentity(
|
||||||
|
appName: appName,
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
bundleID: bundleID,
|
||||||
Text(appName)
|
image: loadAppIconsOnJIT ? iconLoader.image : nil
|
||||||
.font(.system(size: 17, weight: .semibold))
|
)
|
||||||
.foregroundStyle(.primary)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text(bundleID)
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if isLaunching {
|
if isLaunching {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.frame(minWidth: 44, minHeight: 44)
|
||||||
} else {
|
} else {
|
||||||
Text("Launch".localized)
|
Text("Launch".localized)
|
||||||
.font(.footnote.weight(.semibold))
|
.font(.footnote.weight(.semibold))
|
||||||
|
|
@ -292,6 +274,8 @@ struct LaunchAppRow: View {
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Capsule().fill(Color.accentColor.opacity(0.18)))
|
.background(Capsule().fill(Color.accentColor.opacity(0.18)))
|
||||||
.foregroundStyle(Color.accentColor)
|
.foregroundStyle(Color.accentColor)
|
||||||
|
.fixedSize()
|
||||||
|
.frame(minHeight: 44)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, loadAppIconsOnJIT ? 4 : 8)
|
.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 {
|
private var accessibilityValue: String {
|
||||||
let state = isLaunching ? "Launching".localized : "Ready".localized
|
let state = isLaunching ? "Launching".localized : "Ready".localized
|
||||||
return "\(state), \(String(format: "Bundle ID %@".localized, bundleID))"
|
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 {
|
private struct AppIconView: View {
|
||||||
let image: UIImage?
|
let image: UIImage?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ struct ScriptEditorView: View {
|
||||||
let scriptURL: URL
|
let scriptURL: URL
|
||||||
|
|
||||||
@State private var scriptContent: String = ""
|
@State private var scriptContent: String = ""
|
||||||
|
@State private var hasLoadedScript = false
|
||||||
@State private var position: CodeEditor.Position = .init()
|
@State private var position: CodeEditor.Position = .init()
|
||||||
@State private var messages: Set<TextLocated<Message>> = []
|
@State private var messages: Set<TextLocated<Message>> = []
|
||||||
|
|
||||||
|
|
@ -39,7 +40,6 @@ struct ScriptEditorView: View {
|
||||||
.environment(\.codeEditorTheme, editorTheme)
|
.environment(\.codeEditorTheme, editorTheme)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.ignoresSafeArea(edges: .bottom)
|
|
||||||
}
|
}
|
||||||
.navigationTitle(scriptURL.lastPathComponent)
|
.navigationTitle(scriptURL.lastPathComponent)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
|
@ -57,6 +57,9 @@ struct ScriptEditorView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadScript() {
|
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)) ?? ""
|
scriptContent = (try? String(contentsOf: scriptURL)) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,16 +50,19 @@ struct ConsoleLogsView: View {
|
||||||
}
|
}
|
||||||
.navigationTitle("Console")
|
.navigationTitle("Console")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
ToolbarItem(placement: .principal) {
|
Picker("Log source", selection: $selectedConsoleTab) {
|
||||||
Picker("", selection: $selectedConsoleTab) {
|
|
||||||
Text("App").tag(ConsoleTab.idevice)
|
Text("App").tag(ConsoleTab.idevice)
|
||||||
Text("System").tag(ConsoleTab.syslog)
|
Text("System").tag(ConsoleTab.syslog)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.frame(width: 180)
|
.accessibilityLabel("Log source")
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.bar)
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
Menu {
|
Menu {
|
||||||
if selectedConsoleTab == .idevice {
|
if selectedConsoleTab == .idevice {
|
||||||
Button("Refresh", systemImage: "arrow.clockwise") {
|
Button("Refresh", systemImage: "arrow.clockwise") {
|
||||||
|
|
@ -87,7 +90,7 @@ struct ConsoleLogsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "ellipsis.circle")
|
Label("Console Actions", systemImage: "ellipsis.circle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,24 +127,24 @@ struct ConsoleLogsView: View {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("=== DEVICE INFORMATION ===")
|
Text("=== DEVICE INFORMATION ===")
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.caption2.monospaced())
|
||||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
Text("iOS Version: \(UIDevice.current.systemVersion)")
|
Text("iOS Version: \(UIDevice.current.systemVersion)")
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.caption2.monospaced())
|
||||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||||
|
|
||||||
Text("Device: \(UIDevice.current.name)")
|
Text("Device: \(UIDevice.current.name)")
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.caption2.monospaced())
|
||||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||||
|
|
||||||
Text("Model: \(UIDevice.current.model)")
|
Text("Model: \(UIDevice.current.model)")
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.caption2.monospaced())
|
||||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||||
|
|
||||||
Text("=== LOG ENTRIES ===")
|
Text("=== LOG ENTRIES ===")
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.caption2.monospaced())
|
||||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
@ -199,8 +202,10 @@ struct ConsoleLogsView: View {
|
||||||
Button {
|
Button {
|
||||||
syslogSearchText = ""
|
syslogSearchText = ""
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "xmark.circle.fill")
|
Label("Clear Filter", systemImage: "xmark.circle.fill")
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(minWidth: 44, minHeight: 44)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -501,7 +506,7 @@ private struct AppLogRow: View, Equatable {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(AttributedString(attributedString))
|
Text(AttributedString(attributedString))
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.caption2.monospaced())
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.lineLimit(nil)
|
.lineLimit(nil)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
@ -550,7 +555,7 @@ private struct SyslogRow: View, Equatable {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(AttributedString(attributedString))
|
Text(AttributedString(attributedString))
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.caption2.monospaced())
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.lineLimit(nil)
|
.lineLimit(nil)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,10 @@ struct HomeView: View {
|
||||||
Haptics.medium()
|
Haptics.medium()
|
||||||
startJITInBackground(bundleID: selectedBundle, displayName: selectedName)
|
startJITInBackground(bundleID: selectedBundle, displayName: selectedName)
|
||||||
}, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true })
|
}, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true })
|
||||||
.overlay(alignment: .bottom) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
if let debugFeedback {
|
if let debugFeedback {
|
||||||
debugFeedbackView(debugFeedback)
|
debugFeedbackView(debugFeedback)
|
||||||
.padding(.bottom, 40)
|
.padding(.vertical, 8)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,15 +65,16 @@ struct InstalledAppsListView: View {
|
||||||
placement: .navigationBarDrawer(displayMode: .always),
|
placement: .navigationBarDrawer(displayMode: .always),
|
||||||
prompt: selectedTab.searchPrompt
|
prompt: selectedTab.searchPrompt
|
||||||
)
|
)
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
tabPicker
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
|
launchFeedbackBanner
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
tabPickerToolbarItem
|
appActionsToolbarItems
|
||||||
leadingToolbarItem
|
|
||||||
trailingToolbarItem
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay {
|
|
||||||
launchFeedbackOverlay
|
|
||||||
}
|
|
||||||
.onAppear(perform: refreshIconPrefetch)
|
.onAppear(perform: refreshIconPrefetch)
|
||||||
.onChange(of: favoriteApps) { _, _ in prefetchPriorityIcons() }
|
.onChange(of: favoriteApps) { _, _ in prefetchPriorityIcons() }
|
||||||
.onChange(of: recentApps) { _, _ in prefetchPriorityIcons() }
|
.onChange(of: recentApps) { _, _ in prefetchPriorityIcons() }
|
||||||
|
|
@ -129,64 +130,62 @@ struct InstalledAppsListView: View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
private var tabPicker: some View {
|
||||||
private var tabPickerToolbarItem: some ToolbarContent {
|
Picker("App category", selection: $selectedTab) {
|
||||||
ToolbarItem(placement: .principal) {
|
|
||||||
Picker("", selection: $selectedTab) {
|
|
||||||
ForEach(AppListTab.allCases) { tab in
|
ForEach(AppListTab.allCases) { tab in
|
||||||
Text(tab.title.localized).tag(tab)
|
Text(tab.title.localized).tag(tab)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.frame(width: 220)
|
.accessibilityLabel("App category")
|
||||||
}
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.bar)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
@ToolbarContentBuilder
|
||||||
private var leadingToolbarItem: some ToolbarContent {
|
private var appActionsToolbarItems: some ToolbarContent {
|
||||||
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
if let onImportPairingFile {
|
if let onImportPairingFile {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
Button(action: 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") {
|
Button("Done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
} else {
|
|
||||||
Button {
|
|
||||||
viewModel.refreshAppLists()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var launchFeedbackOverlay: some View {
|
private var launchFeedbackBanner: some View {
|
||||||
if let launchFeedback {
|
if let launchFeedback {
|
||||||
VStack {
|
|
||||||
Spacer()
|
|
||||||
Text(launchFeedback.message)
|
Text(launchFeedback.message)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 12)
|
||||||
.background(Capsule().fill(.ultraThinMaterial))
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||||
.foregroundStyle(launchFeedback.success ? .green : .red)
|
.foregroundStyle(launchFeedback.success ? .green : .red)
|
||||||
.shadow(radius: 4)
|
.shadow(radius: 4)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
.padding(.bottom, 40)
|
|
||||||
}
|
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback.id)
|
.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 {
|
private struct RouteSearchSelection {
|
||||||
let title: String
|
let title: String
|
||||||
let coordinate: CLLocationCoordinate2D
|
let coordinate: CLLocationCoordinate2D
|
||||||
|
|
@ -726,6 +734,7 @@ final class LocationSearchCompleter: NSObject, ObservableObject, MKLocalSearchCo
|
||||||
struct LocationSimulationView: View {
|
struct LocationSimulationView: View {
|
||||||
@State private var coordinate: CLLocationCoordinate2D?
|
@State private var coordinate: CLLocationCoordinate2D?
|
||||||
@State private var position: MapCameraPosition = .userLocation(fallback: .automatic)
|
@State private var position: MapCameraPosition = .userLocation(fallback: .automatic)
|
||||||
|
@State private var simulationControlsHeight: CGFloat = 0
|
||||||
|
|
||||||
@State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
@State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
||||||
@State private var resendTimer: Timer?
|
@State private var resendTimer: Timer?
|
||||||
|
|
@ -859,10 +868,7 @@ struct LocationSimulationView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.frame(maxHeight: 350)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.scrollDisabled(true)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
@ -876,6 +882,10 @@ struct LocationSimulationView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
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) {
|
ZStack(alignment: .bottom) {
|
||||||
MapReader { proxy in
|
MapReader { proxy in
|
||||||
Map(position: $position) {
|
Map(position: $position) {
|
||||||
|
|
@ -924,65 +934,70 @@ struct LocationSimulationView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 12) {
|
||||||
if !searchCompleter.results.isEmpty {
|
if !searchCompleter.results.isEmpty {
|
||||||
searchResultsList
|
searchResultsList
|
||||||
|
.frame(maxWidth: 600)
|
||||||
|
.frame(height: min(350, panelHeight * 0.4))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
ScrollView {
|
||||||
if isImportingCoordinates {
|
simulationControls
|
||||||
ProgressView("Importing coordinates…")
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.font(.footnote)
|
.background {
|
||||||
}
|
GeometryReader { controlsGeometry in
|
||||||
|
Color.clear.preference(
|
||||||
if hasRouteContext {
|
key: SimulationControlsHeightKey.self,
|
||||||
routeControls
|
value: controlsGeometry.size.height
|
||||||
} else {
|
)
|
||||||
pinControls
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 24)
|
}
|
||||||
.padding(.horizontal, 16)
|
.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(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.searchable(text: $searchText, prompt: "Search location…")
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.onChange(of: searchText) { _, newValue in
|
||||||
|
searchCompleter.update(query: newValue)
|
||||||
|
}
|
||||||
|
.onSubmit(of: .search) {
|
||||||
|
applyCoordinatesFromSearchText()
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .topBarLeading) {
|
ToolbarItemGroup(placement: .topBarLeading) {
|
||||||
Button {
|
Button {
|
||||||
showBookmarks = true
|
showBookmarks = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "bookmark.fill")
|
Label("Bookmarks", systemImage: "bookmark.fill")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showRouteSearch = true
|
showRouteSearch = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "point.topleft.down.curvedto.point.bottomright.up")
|
Label("Plan Route", systemImage: "point.topleft.down.curvedto.point.bottomright.up")
|
||||||
}
|
}
|
||||||
.disabled(isBusy || isRouteRunning)
|
.disabled(isBusy || isRouteRunning)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showCoordinateImporter = true
|
showCoordinateImporter = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "square.and.arrow.down")
|
Label("Import Coordinates", systemImage: "square.and.arrow.down")
|
||||||
}
|
}
|
||||||
.disabled(isBusy || isRouteRunning || isImportingCoordinates)
|
.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) {
|
.alert(alertTitle, isPresented: $showAlert) {
|
||||||
|
|
@ -1212,6 +1227,24 @@ struct LocationSimulationView: View {
|
||||||
showAlert = true
|
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
|
@ViewBuilder
|
||||||
private var pinControls: some View {
|
private var pinControls: some View {
|
||||||
if let coord = coordinate {
|
if let coord = coordinate {
|
||||||
|
|
@ -1219,7 +1252,25 @@ struct LocationSimulationView: View {
|
||||||
.font(.footnote.monospaced())
|
.font(.footnote.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ViewThatFits(in: .horizontal) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
|
pinActionButtons
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
pinActionButtons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Tap map to drop pin")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var pinActionButtons: some View {
|
||||||
Button("Stop", action: clear)
|
Button("Stop", action: clear)
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
|
|
@ -1232,18 +1283,13 @@ struct LocationSimulationView: View {
|
||||||
Button {
|
Button {
|
||||||
showSaveBookmark = true
|
showSaveBookmark = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "bookmark")
|
Label("Save Bookmark", systemImage: "bookmark")
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.blue)
|
.tint(.blue)
|
||||||
.disabled(isRouteRunning)
|
.disabled(isRouteRunning)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Text("Tap map to drop pin")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var routeControls: some View {
|
private var routeControls: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
|
|
@ -1262,7 +1308,21 @@ struct LocationSimulationView: View {
|
||||||
|
|
||||||
routeAttributionLink
|
routeAttributionLink
|
||||||
|
|
||||||
|
ViewThatFits(in: .horizontal) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
|
routeActionButtons
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
routeActionButtons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var routeActionButtons: some View {
|
||||||
Button("Stop", action: clear)
|
Button("Stop", action: clear)
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
|
|
@ -1283,8 +1343,6 @@ struct LocationSimulationView: View {
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.disabled(isBusy || isRouteRunning)
|
.disabled(isBusy || isRouteRunning)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func simulate() {
|
private func simulate() {
|
||||||
guard pairingExists, let coord = coordinate, !isBusy else { return }
|
guard pairingExists, let coord = coordinate, !isBusy else { return }
|
||||||
|
|
|
||||||
|
|
@ -143,16 +143,14 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Advanced") {
|
Section("Advanced") {
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Target Device IP")
|
Text("Target Device IP")
|
||||||
Spacer()
|
|
||||||
TextField(DeviceConnectionContext.defaultTargetIPAddress, text: $targetDeviceIP)
|
TextField(DeviceConnectionContext.defaultTargetIPAddress, text: $targetDeviceIP)
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
.keyboardType(.numbersAndPunctuation)
|
.keyboardType(.numbersAndPunctuation)
|
||||||
.frame(maxWidth: 160)
|
.accessibilityLabel("Target Device IP")
|
||||||
}
|
}
|
||||||
Button { openAppFolder() } label: {
|
Button { openAppFolder() } label: {
|
||||||
Label("App Folder", systemImage: "folder")
|
Label("App Folder", systemImage: "folder")
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ToolsView: View {
|
struct ToolsView: View {
|
||||||
|
@State private var selectedTool: AppFeature?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
// Let the system expand/collapse the same navigation hierarchy as the
|
||||||
List(AppFeature.toolList) { tool in
|
// window resizes, preserving the selected tool and its local state.
|
||||||
NavigationLink {
|
NavigationSplitView {
|
||||||
tool.destination
|
List(AppFeature.toolList, selection: $selectedTool) { tool in
|
||||||
} label: {
|
NavigationLink(value: tool) {
|
||||||
Label {
|
Label {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(tool.toolTitle)
|
Text(tool.toolTitle)
|
||||||
|
|
@ -25,8 +27,20 @@ struct ToolsView: View {
|
||||||
Image(systemName: tool.systemImage)
|
Image(systemName: tool.systemImage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("tool-\(tool.id)")
|
||||||
}
|
}
|
||||||
.navigationTitle("Tools")
|
.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