Reuse mobile host picker for metrics
This commit is contained in:
1 parent
4c68a01c48
commit
14bde784ac
4 files changed
+150
-33
No files matched your search
@@ -28,8 +28,8 @@ struct MobileWorkspaceToolbar: ToolbarContent {
|
||||
}
|
||||
|
||||
struct MobileConnectionPicker: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var model: MobileConnectionPickerModel
|
||||
let library: HostLibraryModel
|
||||
let cancel: () -> Void
|
||||
|
||||
init(
|
||||
@@ -37,12 +37,19 @@ struct MobileConnectionPicker: View {
|
||||
connect: @escaping (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void,
|
||||
cancel: @escaping () -> Void
|
||||
) {
|
||||
_model = State(initialValue: MobileConnectionPickerModel(library: library, connect: connect))
|
||||
self.library = library
|
||||
_model = State(initialValue: MobileConnectionPickerModel(connect: connect))
|
||||
self.cancel = cancel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
MobileHostPicker(
|
||||
library: library,
|
||||
title: "Connect",
|
||||
emptyDescription: "Add a host to connect to it.",
|
||||
cancel: cancel,
|
||||
select: model.select
|
||||
) {
|
||||
Section {
|
||||
Picker("Connection Protocol", selection: $model.kind) {
|
||||
Text("SSH").tag(MobileWorkspaceDescriptor.Kind.ssh)
|
||||
@@ -50,11 +57,43 @@ struct MobileConnectionPicker: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MobileHostPicker<Header: View>: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var model: MobileHostPickerModel
|
||||
let title: LocalizedStringKey
|
||||
let emptyDescription: LocalizedStringKey
|
||||
let cancel: () -> Void
|
||||
let select: (Host) -> Void
|
||||
let header: Header
|
||||
|
||||
init(
|
||||
library: HostLibraryModel,
|
||||
title: LocalizedStringKey,
|
||||
emptyDescription: LocalizedStringKey,
|
||||
cancel: @escaping () -> Void,
|
||||
select: @escaping (Host) -> Void,
|
||||
@ViewBuilder header: () -> Header
|
||||
) {
|
||||
_model = State(initialValue: MobileHostPickerModel(library: library))
|
||||
self.title = title
|
||||
self.emptyDescription = emptyDescription
|
||||
self.cancel = cancel
|
||||
self.select = select
|
||||
self.header = header()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
header
|
||||
|
||||
if model.isLibraryEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Hosts", systemImage: "server.rack",
|
||||
description: Text("Add a host to connect to it.")
|
||||
description: Text(emptyDescription)
|
||||
)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
@@ -63,10 +102,10 @@ struct MobileConnectionPicker: View {
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
} else {
|
||||
Section("Hosts") {
|
||||
Section {
|
||||
ForEach(model.hosts) { host in
|
||||
Button {
|
||||
model.select(host)
|
||||
select(host)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
HostSystemIcon(asset: distributionEntry(for: host)?.asset, size: 32)
|
||||
@@ -90,7 +129,7 @@ struct MobileConnectionPicker: View {
|
||||
}
|
||||
}
|
||||
.searchable(text: $model.query, prompt: "Search hosts")
|
||||
.navigationTitle("Connect")
|
||||
.navigationTitle(Text(title))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@@ -113,28 +152,48 @@ struct MobileConnectionPicker: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension MobileHostPicker where Header == EmptyView {
|
||||
init(
|
||||
library: HostLibraryModel,
|
||||
title: LocalizedStringKey,
|
||||
emptyDescription: LocalizedStringKey,
|
||||
cancel: @escaping () -> Void,
|
||||
select: @escaping (Host) -> Void
|
||||
) {
|
||||
self.init(
|
||||
library: library,
|
||||
title: title,
|
||||
emptyDescription: emptyDescription,
|
||||
cancel: cancel,
|
||||
select: select,
|
||||
header: { EmptyView() })
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MobileConnectionPickerModel {
|
||||
var kind = MobileWorkspaceDescriptor.Kind.ssh
|
||||
final class MobileHostPickerModel {
|
||||
var query = ""
|
||||
|
||||
private let library: HostLibraryModel
|
||||
private let connect: (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void
|
||||
|
||||
init(
|
||||
library: HostLibraryModel,
|
||||
connect: @escaping (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void
|
||||
) {
|
||||
self.library = library
|
||||
self.connect = connect
|
||||
}
|
||||
init(library: HostLibraryModel) { self.library = library }
|
||||
|
||||
var isLibraryEmpty: Bool { library.hosts.isEmpty }
|
||||
|
||||
var hosts: [Host] { library.searchHosts(query) }
|
||||
|
||||
func loginAddress(for host: Host) -> String { library.loginAddress(for: host) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MobileConnectionPickerModel {
|
||||
var kind = MobileWorkspaceDescriptor.Kind.ssh
|
||||
|
||||
private let connect: (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void
|
||||
|
||||
init(connect: @escaping (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void) { self.connect = connect }
|
||||
|
||||
func select(_ host: Host) { connect(host.id, kind) }
|
||||
}
|
||||
@@ -49,16 +49,17 @@ struct MobileMetricsView: View {
|
||||
}
|
||||
.sheet(isPresented: $selecting) {
|
||||
NavigationStack {
|
||||
List(app.library.hosts) { host in
|
||||
Button(host.name, systemImage: "server.rack") {
|
||||
MobileHostPicker(
|
||||
library: app.library,
|
||||
title: "Add Server",
|
||||
emptyDescription: "Add a saved Linux server to start monitoring.",
|
||||
cancel: { selecting = false },
|
||||
select: { host in
|
||||
selecting = false
|
||||
if !app.metrics.add(host.id, pro: app.pro.hasProAccess) {
|
||||
app.pro.presentPaywall(for: .unlimitedMetrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Hosts")
|
||||
.toolbar { Button("Cancel", systemImage: "xmark") { selecting = false }.labelStyle(.iconOnly) }
|
||||
})
|
||||
}.mobileSheetPresentation(compact: true)
|
||||
}
|
||||
.task { app.metrics.start() }
|
||||
|
||||
@@ -12,34 +12,62 @@ struct MobileConnectionPickerTests {
|
||||
@Test func pickerDefaultsToSSHFiltersHostsAndReturnsTheSelectedProtocol() throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
var selections: [(Host.ID, MobileWorkspaceDescriptor.Kind)] = []
|
||||
let model = MobileConnectionPickerModel(library: app.library) { selections.append(($0, $1)) }
|
||||
let hostModel = MobileHostPickerModel(library: app.library)
|
||||
let connectionModel = MobileConnectionPickerModel { selections.append(($0, $1)) }
|
||||
|
||||
#expect(model.kind == .ssh)
|
||||
#expect(model.isLibraryEmpty)
|
||||
#expect(connectionModel.kind == .ssh)
|
||||
#expect(hostModel.isLibraryEmpty)
|
||||
|
||||
let production = Host(name: "Production", host: "production.example.invalid")
|
||||
let staging = Host(name: "Staging", host: "staging.example.invalid")
|
||||
app.library.upsert(production)
|
||||
app.library.upsert(staging)
|
||||
|
||||
#expect(!model.isLibraryEmpty)
|
||||
#expect(Set(model.hosts.map(\.id)) == Set([production.id, staging.id]))
|
||||
#expect(!hostModel.isLibraryEmpty)
|
||||
#expect(Set(hostModel.hosts.map(\.id)) == Set([production.id, staging.id]))
|
||||
|
||||
model.query = "prod"
|
||||
#expect(model.hosts.map(\.id) == [production.id])
|
||||
hostModel.query = "prod"
|
||||
#expect(hostModel.hosts.map(\.id) == [production.id])
|
||||
|
||||
model.select(production)
|
||||
connectionModel.select(production)
|
||||
#expect(selections.count == 1)
|
||||
#expect(selections.first?.0 == production.id)
|
||||
#expect(selections.first?.1 == .ssh)
|
||||
|
||||
model.kind = .sftp
|
||||
model.select(staging)
|
||||
connectionModel.kind = .sftp
|
||||
connectionModel.select(staging)
|
||||
#expect(selections.count == 2)
|
||||
#expect(selections.last?.0 == staging.id)
|
||||
#expect(selections.last?.1 == .sftp)
|
||||
}
|
||||
|
||||
@Test func sharedHostPickerUsesConfiguredTitleWithoutHostsHeaderOrProtocolControl() async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
app.library.upsert(Host(name: "Production", host: "production.example.invalid"))
|
||||
|
||||
try await withNavigation(
|
||||
MobileHostPicker(
|
||||
library: app.library,
|
||||
title: "Add Server",
|
||||
emptyDescription: "Add a saved Linux server to start monitoring.",
|
||||
cancel: {},
|
||||
select: { _ in }),
|
||||
language: "en"
|
||||
) { navigation in
|
||||
#expect(navigation.topViewController?.navigationItem.title == "Add Server")
|
||||
#expect(descendant(of: UISegmentedControl.self, in: navigation) == nil)
|
||||
#expect(!labels(in: navigation.view).contains("Hosts"))
|
||||
}
|
||||
|
||||
try await withNavigation(
|
||||
MobileConnectionPicker(library: app.library, connect: { _, _ in }, cancel: {}),
|
||||
language: "en"
|
||||
) { navigation in
|
||||
#expect(descendant(of: UISegmentedControl.self, in: navigation) != nil)
|
||||
#expect(!labels(in: navigation.view).contains("Hosts"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
("en", "Connect", "More"),
|
||||
@@ -124,6 +152,24 @@ struct MobileConnectionPickerTests {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func descendant<T: UIView>(of type: T.Type, in viewController: UIViewController) -> T? {
|
||||
descendant(of: type, in: viewController.view)
|
||||
}
|
||||
|
||||
private func descendant<T: UIView>(of type: T.Type, in view: UIView) -> T? {
|
||||
if let result = view as? T { return result }
|
||||
for child in view.subviews {
|
||||
if let result = descendant(of: type, in: child) { return result }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func labels(in view: UIView) -> [String] {
|
||||
var result = (view as? UILabel).flatMap(\.text).map { [$0] } ?? []
|
||||
for child in view.subviews { result.append(contentsOf: labels(in: child)) }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolbarHost: View {
|
||||
|
||||
@@ -102,6 +102,17 @@ struct MobileIconRenderingTests {
|
||||
}
|
||||
.environment(\.locale, locale)
|
||||
}
|
||||
try await capture("metrics-host-picker-\(suffix)", language: language, scheme: scheme) {
|
||||
NavigationStack {
|
||||
MobileHostPicker(
|
||||
library: connectionApp.library,
|
||||
title: "Add Server",
|
||||
emptyDescription: "Add a saved Linux server to start monitoring.",
|
||||
cancel: {},
|
||||
select: { _ in })
|
||||
}
|
||||
.environment(\.locale, locale)
|
||||
}
|
||||
try await capture("sftp-settings-\(suffix)", language: language, scheme: scheme) {
|
||||
NavigationStack { MobileSFTPSettingsView(transfers: app.transfers) }.environment(\.locale, locale)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user