WIP Duo UI Optimization

This commit is contained in:
Stephen 2026-09-10 09:01:49 -04:00
parent 94bc9e8cf3
commit c65745cf14
9 changed files with 331 additions and 227 deletions

View file

@ -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;

View file

@ -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?

View file

@ -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)) ?? ""
}

View file

@ -50,16 +50,19 @@ struct ConsoleLogsView: View {
}
.navigationTitle("Console")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Picker("", selection: $selectedConsoleTab) {
.safeAreaInset(edge: .top, spacing: 0) {
Picker("Log source", selection: $selectedConsoleTab) {
Text("App").tag(ConsoleTab.idevice)
Text("System").tag(ConsoleTab.syslog)
}
.pickerStyle(.segmented)
.frame(width: 180)
.accessibilityLabel("Log source")
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
}
ToolbarItem(placement: .navigationBarTrailing) {
.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")
}
}
}
@ -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)

View file

@ -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))
}
}

View file

@ -65,15 +65,16 @@ struct InstalledAppsListView: View {
placement: .navigationBarDrawer(displayMode: .always),
prompt: selectedTab.searchPrompt
)
.safeAreaInset(edge: .top, spacing: 0) {
tabPicker
}
.safeAreaInset(edge: .bottom, spacing: 0) {
launchFeedbackBanner
}
.toolbar {
tabPickerToolbarItem
leadingToolbarItem
trailingToolbarItem
appActionsToolbarItems
}
}
.overlay {
launchFeedbackOverlay
}
.onAppear(perform: refreshIconPrefetch)
.onChange(of: favoriteApps) { _, _ in prefetchPriorityIcons() }
.onChange(of: recentApps) { _, _ in prefetchPriorityIcons() }
@ -129,64 +130,62 @@ struct InstalledAppsListView: View {
)
}
@ToolbarContentBuilder
private var tabPickerToolbarItem: some ToolbarContent {
ToolbarItem(placement: .principal) {
Picker("", selection: $selectedTab) {
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)
}
.accessibilityLabel("App category")
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
}
@ToolbarContentBuilder
private var leadingToolbarItem: some ToolbarContent {
private var appActionsToolbarItems: some ToolbarContent {
ToolbarItemGroup(placement: .primaryAction) {
if let onImportPairingFile {
ToolbarItem(placement: .navigationBarLeading) {
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 {
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))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Capsule().fill(.ultraThinMaterial))
.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))
.padding(.bottom, 40)
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback.id)
}
}

View file

@ -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,6 +882,10 @@ struct LocationSimulationView: 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) {
MapReader { proxy in
Map(position: $position) {
@ -924,65 +934,70 @@ struct LocationSimulationView: View {
}
}
VStack(spacing: 0) {
VStack(spacing: 12) {
if !searchCompleter.results.isEmpty {
searchResultsList
.frame(maxWidth: 600)
.frame(height: min(350, panelHeight * 0.4))
}
Spacer()
Spacer(minLength: 0)
VStack(spacing: 12) {
if isImportingCoordinates {
ProgressView("Importing coordinates…")
.font(.footnote)
}
if hasRouteContext {
routeControls
} else {
pinControls
ScrollView {
simulationControls
.fixedSize(horizontal: false, vertical: true)
.background {
GeometryReader { controlsGeometry in
Color.clear.preference(
key: SimulationControlsHeightKey.self,
value: controlsGeometry.size.height
)
}
}
.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(.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,7 +1252,25 @@ struct LocationSimulationView: View {
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
ViewThatFits(in: .horizontal) {
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)
.buttonStyle(.bordered)
.tint(.red)
@ -1232,18 +1283,13 @@ struct LocationSimulationView: View {
Button {
showSaveBookmark = true
} label: {
Image(systemName: "bookmark")
Label("Save Bookmark", systemImage: "bookmark")
.labelStyle(.iconOnly)
}
.buttonStyle(.bordered)
.tint(.blue)
.disabled(isRouteRunning)
}
} else {
Text("Tap map to drop pin")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
private var routeControls: some View {
VStack(spacing: 10) {
@ -1262,7 +1308,21 @@ struct LocationSimulationView: View {
routeAttributionLink
ViewThatFits(in: .horizontal) {
HStack(spacing: 12) {
routeActionButtons
}
.fixedSize(horizontal: true, vertical: false)
VStack(spacing: 8) {
routeActionButtons
}
}
}
}
@ViewBuilder
private var routeActionButtons: some View {
Button("Stop", action: clear)
.buttonStyle(.bordered)
.tint(.red)
@ -1283,8 +1343,6 @@ struct LocationSimulationView: View {
.buttonStyle(.bordered)
.disabled(isBusy || isRouteRunning)
}
}
}
private func simulate() {
guard pairingExists, let coord = coordinate, !isBusy else { return }

View file

@ -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")

View file

@ -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)
}
}