List screen: ditch nav bar, build flush top chrome
The system nav bar floated low on iOS 26 and left a wedge of black above the title. Replaced it with a custom top row pinned to the safe-area top: gear ⟶ big "Screens" wordmark ⟶ +. Search bar lives directly beneath, sections start right after — no centered-in-the-void layout. Background gets a subtle blue radial bloom so the floating glass buttons have something to anchor to. Saved-empty state is now a glass card with an icon and a gradient CTA button. Connection rows are full-width glass cards with rounded corners; long-press gives Edit / Open in New Window / Delete. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,51 +19,30 @@ public struct ConnectionListView: View {
|
|||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
NavigationStack(path: $path) {
|
NavigationStack(path: $path) {
|
||||||
List {
|
ZStack(alignment: .top) {
|
||||||
if !discovery.hosts.isEmpty || discovery.isBrowsing {
|
backgroundGradient.ignoresSafeArea()
|
||||||
discoveredSection
|
|
||||||
}
|
ScrollView {
|
||||||
savedSection
|
VStack(spacing: 22) {
|
||||||
}
|
topChrome
|
||||||
#if os(iOS)
|
|
||||||
.listStyle(.insetGrouped)
|
if !discovery.hosts.isEmpty || discovery.isBrowsing {
|
||||||
.scrollContentBackground(.hidden)
|
discoveredSection
|
||||||
#endif
|
}
|
||||||
.softScrollEdge()
|
|
||||||
.background(backgroundGradient.ignoresSafeArea())
|
savedSection
|
||||||
.navigationTitle("Screens")
|
|
||||||
#if os(iOS)
|
|
||||||
.searchable(text: $search,
|
|
||||||
placement: .navigationBarDrawer(displayMode: .always),
|
|
||||||
prompt: "Search connections")
|
|
||||||
#else
|
|
||||||
.searchable(text: $search, prompt: "Search connections")
|
|
||||||
#endif
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
|
||||||
Button {
|
|
||||||
addPrefill = nil
|
|
||||||
showingAdd = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Add connection")
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 32)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
.scrollIndicators(.hidden)
|
||||||
Button {
|
|
||||||
showingSettings = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "gear")
|
|
||||||
}
|
|
||||||
.accessibilityLabel("Settings")
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
ToolbarItem {
|
|
||||||
Button { showingSettings = true } label: { Image(systemName: "gear") }
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
|
#endif
|
||||||
.sheet(isPresented: $showingAdd) {
|
.sheet(isPresented: $showingAdd) {
|
||||||
AddConnectionView(prefill: addPrefill)
|
AddConnectionView(prefill: addPrefill)
|
||||||
}
|
}
|
||||||
@@ -90,143 +69,279 @@ public struct ConnectionListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Background
|
||||||
|
|
||||||
private var backgroundGradient: some View {
|
private var backgroundGradient: some View {
|
||||||
LinearGradient(
|
ZStack {
|
||||||
colors: [
|
LinearGradient(
|
||||||
Color(red: 0.04, green: 0.05, blue: 0.10),
|
colors: [
|
||||||
Color(red: 0.02, green: 0.02, blue: 0.05)
|
Color(red: 0.06, green: 0.08, blue: 0.16),
|
||||||
],
|
Color(red: 0.02, green: 0.02, blue: 0.06)
|
||||||
startPoint: .top,
|
],
|
||||||
endPoint: .bottom
|
startPoint: .top,
|
||||||
)
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
// soft accent bloom up top so the floating buttons feel anchored
|
||||||
|
RadialGradient(
|
||||||
|
colors: [Color.blue.opacity(0.22), .clear],
|
||||||
|
center: .init(x: 0.5, y: 0.0),
|
||||||
|
startRadius: 20,
|
||||||
|
endRadius: 320
|
||||||
|
)
|
||||||
|
.blendMode(.screen)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Top chrome (custom — replaces nav bar)
|
||||||
|
|
||||||
|
private var topChrome: some View {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
circularGlassButton(systemName: "gearshape", label: "Settings") {
|
||||||
|
showingSettings = true
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("Screens")
|
||||||
|
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Spacer()
|
||||||
|
circularGlassButton(systemName: "plus", label: "Add connection") {
|
||||||
|
addPrefill = nil
|
||||||
|
showingAdd = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchField
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func circularGlassButton(systemName: String,
|
||||||
|
label: String,
|
||||||
|
action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: systemName)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.glassSurface(in: Circle())
|
||||||
|
.accessibilityLabel(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchField: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(.white.opacity(0.65))
|
||||||
|
TextField("", text: $search, prompt: Text("Search connections")
|
||||||
|
.foregroundStyle(.white.opacity(0.45)))
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
#if os(iOS)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
#endif
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.submitLabel(.search)
|
||||||
|
if !search.isEmpty {
|
||||||
|
Button {
|
||||||
|
search = ""
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.white.opacity(0.45))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Clear search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 11)
|
||||||
|
.glassSurface(in: Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Discovered
|
||||||
|
|
||||||
private var discoveredSection: some View {
|
private var discoveredSection: some View {
|
||||||
Section {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
if discovery.hosts.isEmpty {
|
sectionHeader("On this network",
|
||||||
HStack(spacing: 12) {
|
systemImage: "antenna.radiowaves.left.and.right")
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
Text("Looking for computers on this network…")
|
VStack(spacing: 10) {
|
||||||
.font(.callout)
|
if discovery.hosts.isEmpty {
|
||||||
.foregroundStyle(.secondary)
|
HStack(spacing: 12) {
|
||||||
}
|
ProgressView().controlSize(.small).tint(.white)
|
||||||
.padding(.vertical, 6)
|
Text("Looking for computers…")
|
||||||
} else {
|
.font(.callout)
|
||||||
ForEach(discovery.hosts) { host in
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
Button {
|
Spacer()
|
||||||
Task { await prepareDiscoveredHost(host) }
|
}
|
||||||
} label: {
|
.padding(16)
|
||||||
HStack(spacing: 14) {
|
.glassSurface(in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
ZStack {
|
} else {
|
||||||
Circle()
|
ForEach(discovery.hosts) { host in
|
||||||
.fill(.tint.opacity(0.15))
|
Button {
|
||||||
.frame(width: 38, height: 38)
|
Task { await prepareDiscoveredHost(host) }
|
||||||
Image(systemName: "wifi")
|
} label: {
|
||||||
.font(.callout)
|
HStack(spacing: 14) {
|
||||||
.foregroundStyle(.tint)
|
ZStack {
|
||||||
}
|
Circle()
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.fill(Color.blue.opacity(0.25))
|
||||||
Text(host.displayName)
|
.frame(width: 38, height: 38)
|
||||||
.font(.headline)
|
Image(systemName: "wifi")
|
||||||
.foregroundStyle(.primary)
|
.font(.callout)
|
||||||
Text(serviceLabel(host.serviceType))
|
.foregroundStyle(.blue)
|
||||||
.font(.caption)
|
}
|
||||||
.foregroundStyle(.secondary)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
}
|
Text(host.displayName)
|
||||||
Spacer()
|
.font(.headline)
|
||||||
if resolvingHostID == host.id {
|
.foregroundStyle(.white)
|
||||||
ProgressView().controlSize(.small)
|
Text(serviceLabel(host.serviceType))
|
||||||
} else {
|
.font(.caption)
|
||||||
Image(systemName: "plus.circle.fill")
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
.font(.title3)
|
}
|
||||||
.foregroundStyle(.tint)
|
Spacer()
|
||||||
}
|
if resolvingHostID == host.id {
|
||||||
}
|
ProgressView().controlSize(.small).tint(.white)
|
||||||
.padding(.vertical, 4)
|
} else {
|
||||||
.contentShape(Rectangle())
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.glassSurface(in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
.disabled(resolvingHostID != nil)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(resolvingHostID != nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
|
||||||
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
|
||||||
.font(.footnote.weight(.semibold))
|
|
||||||
.textCase(nil)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Saved
|
||||||
|
|
||||||
private var savedSection: some View {
|
private var savedSection: some View {
|
||||||
Section {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
sectionHeader("Saved", systemImage: "bookmark.fill")
|
||||||
|
|
||||||
if filteredConnections.isEmpty {
|
if filteredConnections.isEmpty {
|
||||||
if connections.isEmpty {
|
if connections.isEmpty {
|
||||||
ContentUnavailableView {
|
emptySavedState
|
||||||
Label("No saved connections", systemImage: "display")
|
|
||||||
} description: {
|
|
||||||
Text("Tap the + button above to add a Mac, Linux box, or Raspberry Pi.")
|
|
||||||
} actions: {
|
|
||||||
Button {
|
|
||||||
addPrefill = nil
|
|
||||||
showingAdd = true
|
|
||||||
} label: {
|
|
||||||
Label("Add a computer", systemImage: "plus")
|
|
||||||
}
|
|
||||||
.glassButton(prominent: true)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
} else {
|
} else {
|
||||||
ContentUnavailableView.search(text: search)
|
noSearchResultsState
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ForEach(filteredConnections) { connection in
|
VStack(spacing: 10) {
|
||||||
NavigationLink(value: SessionRoute(connectionID: connection.id)) {
|
ForEach(filteredConnections) { connection in
|
||||||
ConnectionCard(connection: connection)
|
connectionRow(connection)
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
Button {
|
|
||||||
editingConnection = connection
|
|
||||||
} label: {
|
|
||||||
Label("Edit", systemImage: "pencil")
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
Button {
|
|
||||||
openWindow(value: connection.id)
|
|
||||||
} label: {
|
|
||||||
Label("Open in New Window", systemImage: "rectangle.stack.badge.plus")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
Button(role: .destructive) {
|
|
||||||
delete(connection)
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.swipeActions(edge: .leading) {
|
|
||||||
Button {
|
|
||||||
editingConnection = connection
|
|
||||||
} label: {
|
|
||||||
Label("Edit", systemImage: "pencil")
|
|
||||||
}
|
|
||||||
.tint(.blue)
|
|
||||||
}
|
|
||||||
.swipeActions(edge: .trailing) {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
delete(connection)
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
|
||||||
Label("Saved", systemImage: "bookmark")
|
|
||||||
.font(.footnote.weight(.semibold))
|
|
||||||
.textCase(nil)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var emptySavedState: some View {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(LinearGradient(colors: [.blue.opacity(0.4), .purple.opacity(0.3)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing))
|
||||||
|
.frame(width: 64, height: 64)
|
||||||
|
Image(systemName: "display")
|
||||||
|
.font(.title.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
Text("No saved connections")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text("Tap the + button up top to add a Mac, Linux box, or Raspberry Pi.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.white.opacity(0.65))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
Button {
|
||||||
|
addPrefill = nil
|
||||||
|
showingAdd = true
|
||||||
|
} label: {
|
||||||
|
Label("Add a computer", systemImage: "plus")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(LinearGradient(colors: [.blue, .purple],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing))
|
||||||
|
)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(28)
|
||||||
|
.glassSurface(in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noSearchResultsState: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
Text("No matches for \"\(search)\"")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.white.opacity(0.65))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(24)
|
||||||
|
.glassSurface(in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectionRow(_ connection: SavedConnection) -> some View {
|
||||||
|
NavigationLink(value: SessionRoute(connectionID: connection.id)) {
|
||||||
|
ConnectionCard(connection: connection)
|
||||||
|
.padding(14)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.glassSurface(in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
editingConnection = connection
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
Button {
|
||||||
|
openWindow(value: connection.id)
|
||||||
|
} label: {
|
||||||
|
Label("Open in New Window", systemImage: "rectangle.stack.badge.plus")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
Divider()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
delete(connection)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Helpers
|
||||||
|
|
||||||
|
private func sectionHeader(_ title: String, systemImage: String) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.tracking(0.5)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
|
||||||
private var filteredConnections: [SavedConnection] {
|
private var filteredConnections: [SavedConnection] {
|
||||||
let trimmed = search.trimmingCharacters(in: .whitespaces)
|
let trimmed = search.trimmingCharacters(in: .whitespaces)
|
||||||
guard !trimmed.isEmpty else { return connections }
|
guard !trimmed.isEmpty else { return connections }
|
||||||
|
|||||||
Reference in New Issue
Block a user