feat(ios): add optional compact card layout
This commit is contained in:
1 parent
42b0ecfecf
commit
babb6711b5
15 files changed
+629
-59
No files matched your search
@@ -15638,6 +15638,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Compact Cards" : {
|
||||
"comment" : "App appearance switch for compact resource cards, not AI context compression.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Kompakt"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Compact"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Compact"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "コンパクト"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "컴팩트"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "紧凑"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "緊湊"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Compacted — %@ summarized to %@" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -2,6 +2,9 @@ import Foundation
|
||||
|
||||
extension AppStrings {
|
||||
public nonisolated enum Settings {
|
||||
public static var compactCards: LocalizedStringResource {
|
||||
"Compact Cards"
|
||||
}
|
||||
public static var lightTheme: LocalizedStringResource { "Light Theme" }
|
||||
public static var darkTheme: LocalizedStringResource { "Dark Theme" }
|
||||
public static var customTheme: LocalizedStringResource { "Custom" }
|
||||
|
||||
@@ -3,11 +3,18 @@ import CoreGraphics
|
||||
/// Mobile layout values shared by managed collection surfaces.
|
||||
nonisolated enum MobileCardLayout {
|
||||
static let minimumHeight: CGFloat = 106
|
||||
static let compactMinimumWidth: CGFloat = 76
|
||||
static let compactMaximumColumns = 4
|
||||
static let maximumVisibleTags = 2
|
||||
|
||||
static func columns(width: CGFloat, accessibilitySize: Bool) -> Int {
|
||||
static func columns(
|
||||
width: CGFloat, accessibilitySize: Bool, compact: Bool = false,
|
||||
compactWidth: CGFloat = compactMinimumWidth
|
||||
) -> Int {
|
||||
guard !accessibilitySize else { return 1 }
|
||||
return max(1, Int((max(0, width) + 12) / 332))
|
||||
let minimumWidth = compact ? max(compactMinimumWidth, compactWidth) : 320
|
||||
let count = max(1, Int((max(0, width) + 12) / (minimumWidth + 12)))
|
||||
return compact ? min(compactMaximumColumns, count) : count
|
||||
}
|
||||
|
||||
static func visibleTags(_ tags: [String]) -> ArraySlice<String> {
|
||||
|
||||
@@ -4,6 +4,7 @@ import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
struct MobileCredentialsView: View {
|
||||
@AppStorage("mobile.compactCards") private var compactCards = false
|
||||
let app: MobileAppModel
|
||||
@State private var editingSecurityKey: SSHKeyRef?
|
||||
@State private var editing: Identity?
|
||||
@@ -32,12 +33,12 @@ struct MobileCredentialsView: View {
|
||||
if app.credentials.keys.isEmpty {
|
||||
ContentUnavailableView(AppStrings.Keychain.noKeys.swiftUIKey, systemImage: "key")
|
||||
}
|
||||
MobileCardGrid {
|
||||
MobileCardGrid(compact: compactCards) {
|
||||
ForEach(keys) { key in
|
||||
MobileActionCard {
|
||||
MobileActionCard(compact: compactCards) {
|
||||
exportingKey = key
|
||||
} content: {
|
||||
MobileKeyCredentialCard(key: key, credentials: app.credentials)
|
||||
MobileKeyCredentialCard(key: key, credentials: app.credentials, compact: compactCards)
|
||||
} actions: {
|
||||
Button(AppStrings.Keychain.exportToHost.swiftUIKey, systemImage: "square.and.arrow.up")
|
||||
{
|
||||
@@ -93,12 +94,13 @@ struct MobileCredentialsView: View {
|
||||
ContentUnavailableView(
|
||||
AppStrings.Keychain.noIdentities.swiftUIKey, systemImage: "person.crop.square")
|
||||
}
|
||||
MobileCardGrid {
|
||||
MobileCardGrid(compact: compactCards) {
|
||||
ForEach(identities) { identity in
|
||||
MobileActionCard {
|
||||
MobileActionCard(compact: compactCards) {
|
||||
editing = identity
|
||||
} content: {
|
||||
MobileIdentityCredentialCard(identity: identity, credentials: app.credentials)
|
||||
MobileIdentityCredentialCard(
|
||||
identity: identity, credentials: app.credentials, compact: compactCards)
|
||||
} actions: {
|
||||
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { editing = identity }
|
||||
Divider()
|
||||
@@ -170,13 +172,49 @@ struct MobileCredentialsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MobileKeyCredentialCard: View {
|
||||
struct MobileKeyCredentialCard: View {
|
||||
let key: SSHKeyRef
|
||||
let credentials: CredentialLibraryModel
|
||||
var compact = false
|
||||
@Environment(\.locale) private var locale
|
||||
|
||||
private var dependents: Int { credentials.identities(using: key.id).count }
|
||||
|
||||
var body: some View {
|
||||
if compact {
|
||||
let warnings = compactWarnings
|
||||
MobileCompactCardContent(
|
||||
title: key.label,
|
||||
status: Text(
|
||||
verbatim: warnings.map { AppLocalization.resolve($0, locale: locale) }
|
||||
.formatted(.list(type: .and).locale(locale)))
|
||||
) {
|
||||
MobileCardIcon(symbol: "key.fill", tint: key.colorTag.color ?? .accentColor)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !warnings.isEmpty {
|
||||
MobileCompactCardBadge(symbol: "exclamationmark.triangle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
standardContent
|
||||
}
|
||||
}
|
||||
|
||||
var compactWarnings: [LocalizedStringResource] {
|
||||
var warnings: [LocalizedStringResource] = []
|
||||
switch credentials.keyAvailability(for: key) {
|
||||
case .missing: warnings.append(AppStrings.Keychain.privateKeyMissing)
|
||||
case .failed: warnings.append(AppStrings.Keychain.keychainUnavailable)
|
||||
case .available, .notConfigured: break
|
||||
}
|
||||
if key.hasPassphrase && !credentials.hasSavedPassphrase(for: key) {
|
||||
warnings.append(AppStrings.Keychain.passphraseRequired)
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
private var standardContent: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
MobileCardIcon(symbol: "key.fill", tint: key.colorTag.color ?? .accentColor)
|
||||
@@ -245,13 +283,50 @@ private struct MobileKeyCredentialCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MobileIdentityCredentialCard: View {
|
||||
struct MobileIdentityCredentialCard: View {
|
||||
let identity: Identity
|
||||
let credentials: CredentialLibraryModel
|
||||
var compact = false
|
||||
@Environment(\.locale) private var locale
|
||||
|
||||
private var key: SSHKeyRef? { identity.keyID.flatMap(credentials.key) }
|
||||
|
||||
var body: some View {
|
||||
if compact {
|
||||
let warnings = compactWarnings
|
||||
MobileCompactCardContent(
|
||||
title: identity.name.isEmpty ? identity.username : identity.name,
|
||||
status: Text(
|
||||
verbatim: warnings.map { AppLocalization.resolve($0, locale: locale) }
|
||||
.formatted(.list(type: .and).locale(locale)))
|
||||
) {
|
||||
MobileCardIcon(symbol: "person.badge.key.fill", tint: identity.colorTag.color ?? .accentColor)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !warnings.isEmpty {
|
||||
MobileCompactCardBadge(symbol: "exclamationmark.triangle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
standardContent
|
||||
}
|
||||
}
|
||||
|
||||
var compactWarnings: [LocalizedStringResource] {
|
||||
var warnings: [LocalizedStringResource] = []
|
||||
if identity.usesPassword {
|
||||
switch credentials.passwordAvailability(for: identity) {
|
||||
case .missing: warnings.append(AppStrings.Keychain.passwordMissing)
|
||||
case .failed: warnings.append(AppStrings.Keychain.passwordUnavailable)
|
||||
case .available, .notConfigured: break
|
||||
}
|
||||
}
|
||||
if identity.usesKey && key == nil { warnings.append(AppStrings.Keychain.keyMissing) }
|
||||
if !identity.hasSecret { warnings.append(AppStrings.Keychain.noSecret) }
|
||||
return warnings
|
||||
}
|
||||
|
||||
private var standardContent: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
MobileCardIcon(
|
||||
|
||||
@@ -4,6 +4,7 @@ import TermindPresentation
|
||||
import UIKit
|
||||
|
||||
struct MobileKnownHostsView: View {
|
||||
@AppStorage("mobile.compactCards") private var compactCards = false
|
||||
@Environment(\.locale) private var locale
|
||||
|
||||
let app: MobileAppModel
|
||||
@@ -25,32 +26,18 @@ struct MobileKnownHostsView: View {
|
||||
} else if entries.isEmpty {
|
||||
ContentUnavailableView.search(text: query)
|
||||
}
|
||||
MobileCardGrid {
|
||||
MobileCardGrid(compact: compactCards) {
|
||||
ForEach(entries) { entry in
|
||||
MobileActionCard {
|
||||
MobileActionCard(compact: compactCards) {
|
||||
editing = entry
|
||||
} content: {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if compactCards {
|
||||
MobileCompactCardContent(title: entry.presentationTitle) {
|
||||
MobileCardIcon(symbol: "touchid", tint: .accentColor)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.presentationTitle).font(.headline).lineLimit(1)
|
||||
Text(entry.algorithm.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Spacer(minLength: 6)
|
||||
Text(entry.addedAt.formatted(.relative(presentation: .numeric).locale(locale)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
} else {
|
||||
standardCardContent(entry)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
} actions: {
|
||||
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { editing = entry }
|
||||
Button(AppStrings.KnownHosts.copyFingerprint.swiftUIKey, systemImage: "doc.on.doc") {
|
||||
@@ -88,6 +75,30 @@ struct MobileKnownHostsView: View {
|
||||
MobileKnownHostEditor(app: app, entry: entry, isNew: app.knownHosts.entry(entry.id) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func standardCardContent(_ entry: KnownHostEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
MobileCardIcon(symbol: "touchid", tint: .accentColor)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.presentationTitle).font(.headline).lineLimit(1)
|
||||
Text(entry.algorithm.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Spacer(minLength: 6)
|
||||
Text(entry.addedAt.formatted(.relative(presentation: .numeric).locale(locale)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
struct MobileKnownHostEditor: View {
|
||||
|
||||
@@ -6,6 +6,7 @@ import TermindRemoteInfrastructure
|
||||
import UIKit
|
||||
|
||||
struct MobileForwardingView: View {
|
||||
@AppStorage("mobile.compactCards") private var compactCards = false
|
||||
let app: MobileAppModel
|
||||
@State private var query: String
|
||||
@State private var editing: PortForwardingRule?
|
||||
@@ -29,7 +30,7 @@ struct MobileForwardingView: View {
|
||||
} else if rules.isEmpty {
|
||||
ContentUnavailableView.search(text: query)
|
||||
}
|
||||
MobileCardGrid {
|
||||
MobileCardGrid(compact: compactCards) {
|
||||
ForEach(rules) { rule in
|
||||
card(rule)
|
||||
}
|
||||
@@ -62,14 +63,14 @@ struct MobileForwardingView: View {
|
||||
|
||||
private func card(_ rule: PortForwardingRule) -> some View {
|
||||
let runtime = app.forwardings.state(for: rule.id)
|
||||
return MobileActionCard {
|
||||
return MobileActionCard(compact: compactCards) {
|
||||
showing = rule.id
|
||||
} content: {
|
||||
MobileForwardingCardContent(
|
||||
rule: rule,
|
||||
hostName: app.library.host(rule.hostID)?.name
|
||||
?? String(localized: AppStrings.Forwarding.hostUnavailable),
|
||||
runtime: runtime)
|
||||
runtime: runtime, compact: compactCards)
|
||||
} actions: {
|
||||
if runtime.isActive {
|
||||
Button(AppStrings.Common.stop.swiftUIKey, systemImage: "stop") { app.forwardings.stop(rule.id) }
|
||||
@@ -133,8 +134,35 @@ struct MobileForwardingCardContent: View {
|
||||
let rule: PortForwardingRule
|
||||
let hostName: String
|
||||
let runtime: PortForwardingRuntimeState
|
||||
var compact = false
|
||||
|
||||
var body: some View {
|
||||
if compact {
|
||||
MobileCompactCardContent(
|
||||
title: rule.displayName(hostName: hostName), status: Text(verbatim: runtime.label)
|
||||
) {
|
||||
MobileCardIcon(symbol: rule.kind.symbol, tint: rule.colorTag.color ?? .accentColor)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if let badge = compactBadge {
|
||||
badge
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
standardContent
|
||||
}
|
||||
}
|
||||
|
||||
var compactBadge: MobileCompactCardBadge? {
|
||||
switch runtime {
|
||||
case .stopped: nil
|
||||
case .running: MobileCompactCardBadge(symbol: "checkmark.circle.fill", tint: .green)
|
||||
case .failed: MobileCompactCardBadge(symbol: "exclamationmark.triangle.fill", tint: .red)
|
||||
default: MobileCompactCardBadge(symbol: "clock.fill")
|
||||
}
|
||||
}
|
||||
|
||||
private var standardContent: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
MobileCardIcon(symbol: rule.kind.symbol, tint: rule.colorTag.color ?? .accentColor)
|
||||
|
||||
@@ -7,6 +7,7 @@ struct MobileAppSettingsView: View {
|
||||
var appReview: AppReviewController?
|
||||
var diagnosis = DiagnosisController()
|
||||
@AppStorage("mobile.appearance") private var appearance = "system"
|
||||
@AppStorage("mobile.compactCards") private var compactCards = false
|
||||
@AppStorage(SettingsKey.debugSectionEnabled) private var debugSectionEnabled = false
|
||||
|
||||
var body: some View {
|
||||
@@ -17,6 +18,7 @@ struct MobileAppSettingsView: View {
|
||||
Text(AppStrings.Settings.light).tag("light")
|
||||
Text(AppStrings.Settings.dark).tag("dark")
|
||||
}
|
||||
Toggle(AppStrings.Settings.compactCards.swiftUIKey, isOn: $compactCards)
|
||||
}
|
||||
Section {
|
||||
Toggle(
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
struct MobileCompactSnippetCardContent: View {
|
||||
let snippet: Snippet
|
||||
|
||||
private var risk: CommandRisk { CommandRiskClassifier.assess(snippet.body).risk }
|
||||
|
||||
var riskTitle: LocalizedStringResource {
|
||||
switch risk {
|
||||
case .safe: AppStrings.Snippets.readOnlyRisk
|
||||
case .caution: AppStrings.Snippets.cautionRisk
|
||||
case .destructive: AppStrings.Snippets.destructiveRisk
|
||||
}
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch risk {
|
||||
case .safe: .green
|
||||
case .caution: .orange
|
||||
case .destructive: .red
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MobileCompactCardContent(title: snippet.title, status: Text(riskTitle)) {
|
||||
MobileCardIcon(symbol: "curlybraces", tint: snippet.colorTag.color ?? .accentColor)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
MobileCompactCardBadge(symbol: risk.symbol, tint: tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import TermindPresentation
|
||||
import struct TermindDomain.Host
|
||||
|
||||
struct MobileSnippetsView: View {
|
||||
@AppStorage("mobile.compactCards") private var compactCards = false
|
||||
@Environment(\.locale) private var locale
|
||||
|
||||
private struct SnippetEditorRequest: Identifiable {
|
||||
@@ -67,7 +68,7 @@ struct MobileSnippetsView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader(section)
|
||||
if section.isExpanded != false {
|
||||
MobileCardGrid {
|
||||
MobileCardGrid(compact: compactCards) {
|
||||
ForEach(section.snippets) { snippet in
|
||||
snippetCard(snippet, showsPackage: !query.isEmpty)
|
||||
}
|
||||
@@ -248,14 +249,18 @@ struct MobileSnippetsView: View {
|
||||
}
|
||||
|
||||
private func snippetCard(_ snippet: Snippet, showsPackage: Bool) -> some View {
|
||||
MobileActionCard {
|
||||
MobileActionCard(compact: compactCards) {
|
||||
selectedSnippetID = snippet.id
|
||||
} content: {
|
||||
SnippetCardContent(
|
||||
snippet: snippet,
|
||||
validTargetCount: app.snippets.validTargetIDs(for: snippet).count,
|
||||
packagePath: showsPackage ? app.snippets.snippetPackagePath(snippet.packageID) : nil,
|
||||
modifiedAt: app.snippetModifiedAt(for: snippet.id))
|
||||
if compactCards {
|
||||
MobileCompactSnippetCardContent(snippet: snippet)
|
||||
} else {
|
||||
SnippetCardContent(
|
||||
snippet: snippet,
|
||||
validTargetCount: app.snippets.validTargetIDs(for: snippet).count,
|
||||
packagePath: showsPackage ? app.snippets.snippetPackagePath(snippet.packageID) : nil,
|
||||
modifiedAt: app.snippetModifiedAt(for: snippet.id))
|
||||
}
|
||||
} actions: {
|
||||
Button(AppStrings.Common.run.swiftUIKey, systemImage: "play.fill") {
|
||||
requestExecution(snippet.id, overrideTargets: false)
|
||||
|
||||
@@ -7,16 +7,33 @@ struct MobileHostCardContent: View {
|
||||
let address: String
|
||||
let status: ConnectionStatus?
|
||||
let lastConnectedAt: Date?
|
||||
var compact = false
|
||||
|
||||
@Environment(\.locale) private var locale
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if compact {
|
||||
MobileCompactCardContent(title: host.name, status: Text(verbatim: status?.label ?? "")) {
|
||||
icon
|
||||
}
|
||||
} else {
|
||||
standardContent
|
||||
}
|
||||
}
|
||||
.accessibilityHint(AppStrings.Vault.openWorkspace.swiftUIKey)
|
||||
}
|
||||
|
||||
private var icon: some View {
|
||||
HostConnectionIcon(
|
||||
host: host, status: status,
|
||||
badgeStyle: .card(background: Color(uiColor: .systemBackground), pulses: false))
|
||||
}
|
||||
|
||||
private var standardContent: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
HostConnectionIcon(
|
||||
host: host, status: status,
|
||||
badgeStyle: .card(background: Color(uiColor: .systemBackground), pulses: false)
|
||||
)
|
||||
icon
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(host.name).font(.headline).foregroundStyle(.primary).lineLimit(1)
|
||||
Text(address).font(.caption.monospaced())
|
||||
@@ -27,7 +44,6 @@ struct MobileHostCardContent: View {
|
||||
footer
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityHint(AppStrings.Vault.openWorkspace.swiftUIKey)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
|
||||
@@ -261,6 +261,7 @@ private struct MobileVaultLinkDivider: View {
|
||||
}
|
||||
|
||||
struct MobileVaultView: View {
|
||||
@AppStorage("mobile.compactCards") private var compactCards = false
|
||||
private struct GroupEditorRequest: Identifiable {
|
||||
let id = UUID()
|
||||
let groupID: HostGroup.ID?
|
||||
@@ -475,7 +476,7 @@ struct MobileVaultView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
hostSectionHeader(section)
|
||||
if section.isExpanded != false {
|
||||
MobileCardGrid {
|
||||
MobileCardGrid(compact: compactCards) {
|
||||
ForEach(section.hosts) { row($0) }
|
||||
}
|
||||
}
|
||||
@@ -523,7 +524,7 @@ struct MobileVaultView: View {
|
||||
}
|
||||
if !matchingHosts.isEmpty {
|
||||
MobileCardSection(title: AppStrings.Vault.hosts, count: matchingHosts.count) {
|
||||
MobileCardGrid { ForEach(matchingHosts) { row($0) } }
|
||||
MobileCardGrid(compact: compactCards) { ForEach(matchingHosts) { row($0) } }
|
||||
}
|
||||
}
|
||||
if !keychainSearchLinks.isEmpty {
|
||||
@@ -763,7 +764,7 @@ struct MobileVaultView: View {
|
||||
}
|
||||
|
||||
private func row(_ host: Host) -> some View {
|
||||
MobileActionCard {
|
||||
MobileActionCard(compact: compactCards) {
|
||||
activateHost(host.id)
|
||||
} content: {
|
||||
MobileHostCardContent(
|
||||
@@ -774,7 +775,8 @@ struct MobileVaultView: View {
|
||||
connected: $0.connected, busy: $0.busy, reconnecting: $0.reconnectState != nil
|
||||
)?.status
|
||||
},
|
||||
lastConnectedAt: app.lastConnectedAt(for: host.id)
|
||||
lastConnectedAt: app.lastConnectedAt(for: host.id),
|
||||
compact: compactCards
|
||||
)
|
||||
} actions: {
|
||||
let sections = MobileHostMenuPolicy.sections(
|
||||
|
||||
@@ -16,7 +16,9 @@ struct MobileCardPage<Content: View>: View {
|
||||
|
||||
struct MobileCardGrid<Content: View>: View {
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
@ScaledMetric(relativeTo: .subheadline) private var compactWidth = MobileCardLayout.compactMinimumWidth
|
||||
@State private var width: CGFloat = 0
|
||||
var compact = false
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
@@ -25,7 +27,7 @@ struct MobileCardGrid<Content: View>: View {
|
||||
repeating: GridItem(.flexible(), spacing: 12, alignment: .top),
|
||||
count: MobileCardLayout.columns(
|
||||
width: width,
|
||||
accessibilitySize: typeSize.isAccessibilitySize)),
|
||||
accessibilitySize: typeSize.isAccessibilitySize, compact: compact, compactWidth: compactWidth)),
|
||||
alignment: .leading, spacing: 12
|
||||
) { content }
|
||||
.onGeometryChange(for: CGFloat.self) {
|
||||
@@ -123,30 +125,40 @@ struct MobileCard<Content: View>: View {
|
||||
/// The primary action fills the card. Secondary actions are available from the card's context menu.
|
||||
struct MobileActionCard<Content: View, Actions: View>: View {
|
||||
let alignment: Alignment
|
||||
let compact: Bool
|
||||
let action: () -> Void
|
||||
@ViewBuilder var content: Content
|
||||
@ViewBuilder var actions: Actions
|
||||
|
||||
init(
|
||||
alignment: Alignment = .leading,
|
||||
compact: Bool = false,
|
||||
action: @escaping () -> Void,
|
||||
@ViewBuilder content: () -> Content,
|
||||
@ViewBuilder actions: () -> Actions
|
||||
) {
|
||||
self.alignment = alignment
|
||||
self.compact = compact
|
||||
self.action = action
|
||||
self.content = content()
|
||||
self.actions = actions()
|
||||
}
|
||||
|
||||
private var cardSurface: some View {
|
||||
content.frame(maxWidth: .infinity, alignment: .leading).multilineTextAlignment(.leading)
|
||||
.padding(16)
|
||||
.frame(
|
||||
maxWidth: .infinity, minHeight: MobileCardLayout.minimumHeight,
|
||||
alignment: alignment
|
||||
)
|
||||
@ViewBuilder private var cardSurface: some View {
|
||||
if compact {
|
||||
MobileCompactCardLayout {
|
||||
content.frame(maxWidth: .infinity).padding(8)
|
||||
}
|
||||
.mobileCardSurface()
|
||||
} else {
|
||||
content.frame(maxWidth: .infinity, alignment: .leading).multilineTextAlignment(.leading)
|
||||
.padding(16)
|
||||
.frame(
|
||||
maxWidth: .infinity, minHeight: MobileCardLayout.minimumHeight,
|
||||
alignment: alignment
|
||||
)
|
||||
.mobileCardSurface()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MobileCompactCardContent<Icon: View>: View {
|
||||
let title: String
|
||||
var status: Text = Text(verbatim: "")
|
||||
@ViewBuilder var icon: Icon
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
icon.accessibilityHidden(true)
|
||||
Group {
|
||||
if typeSize.isAccessibilitySize {
|
||||
Text(verbatim: title)
|
||||
} else {
|
||||
Text(verbatim: title).lineLimit(1).truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Text(verbatim: title))
|
||||
.accessibilityValue(status)
|
||||
}
|
||||
}
|
||||
|
||||
struct MobileCompactCardBadge: View {
|
||||
let symbol: String
|
||||
var tint: Color = .orange
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 16, height: 16)
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground), in: .circle)
|
||||
.accessibilityHidden(true)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// A square minimum, not a fixed aspect ratio: large text can make the card taller.
|
||||
struct MobileCompactCardLayout: Layout {
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
guard let content = subviews.first else { return .zero }
|
||||
let proposedWidth = proposal.width ?? content.sizeThatFits(.unspecified).width
|
||||
let width = proposedWidth.isFinite ? max(0, proposedWidth) : content.sizeThatFits(.unspecified).width
|
||||
let height = content.sizeThatFits(ProposedViewSize(width: width, height: nil)).height
|
||||
return CGSize(width: width, height: max(width, height))
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
subviews.first?.place(
|
||||
at: CGPoint(x: bounds.midX, y: bounds.midY), anchor: .center,
|
||||
proposal: ProposedViewSize(width: bounds.width, height: nil))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import TermindRemoteInfrastructure
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobileCompactCardTests {
|
||||
@Test(arguments: [(0.0, 1), (288.0, 3), (343.0, 4), (361.0, 4), (736.0, 4), (992.0, 4)])
|
||||
func compactColumnsFitUpToFourCards(width: Double, expected: Int) {
|
||||
#expect(MobileCardLayout.columns(width: width, accessibilitySize: false, compact: true) == expected)
|
||||
}
|
||||
|
||||
@Test func standardColumnsAndAccessibilityKeepTheirExistingBehavior() {
|
||||
#expect(MobileCardLayout.columns(width: 361, accessibilitySize: false) == 1)
|
||||
#expect(MobileCardLayout.columns(width: 736, accessibilitySize: false) == 2)
|
||||
#expect(MobileCardLayout.columns(width: 992, accessibilitySize: false) == 3)
|
||||
for compact in [false, true] {
|
||||
#expect(MobileCardLayout.columns(width: 992, accessibilitySize: true, compact: compact) == 1)
|
||||
}
|
||||
#expect(
|
||||
MobileCardLayout.columns(
|
||||
width: 361, accessibilitySize: false, compact: true, compactWidth: 104) == 3)
|
||||
#expect(
|
||||
MobileCardLayout.columns(
|
||||
width: 288, accessibilitySize: false, compact: true, compactWidth: 104) == 2)
|
||||
}
|
||||
|
||||
@Test(arguments: [76.0, 81.25, 104.0, 180.0])
|
||||
func compactCardsAreSquareOrTallerRegardlessOfTitleLength(width: Double) {
|
||||
let short = measure(compactCard(title: "Gateway"), width: width)
|
||||
let long = measure(
|
||||
compactCard(title: "Production gateway with a very long name that must be truncated"), width: width)
|
||||
#expect(abs(short.width - width) < 0.5)
|
||||
#expect(short.height >= width)
|
||||
#expect(abs(long.height - short.height) < 0.5)
|
||||
#expect(long.height < width + 24)
|
||||
}
|
||||
|
||||
@Test(arguments: ["Gateway", "网关"])
|
||||
func regularNamesStayOnOneLineWithoutReservingExtraSpace(title: String) {
|
||||
func content(_ title: String) -> some View {
|
||||
MobileCompactCardContent(title: title) {
|
||||
MobileCardIcon(symbol: "server.rack")
|
||||
}
|
||||
.environment(\.dynamicTypeSize, .large)
|
||||
}
|
||||
let singleLine = measure(content(title), width: 180)
|
||||
let long = measure(content(String(repeating: "\(title) ", count: 12)), width: 180)
|
||||
let newline = measure(content("\(title)\n\(title)"), width: 180)
|
||||
#expect(abs(long.height - singleLine.height) < 0.5)
|
||||
#expect(abs(newline.height - singleLine.height) < 0.5)
|
||||
}
|
||||
|
||||
@Test func squareMinimumDoesNotClipTallContent() {
|
||||
let size = measure(
|
||||
MobileCompactCardLayout { Color.clear.frame(height: 280) }, width: 120)
|
||||
#expect(abs(size.width - 120) < 0.5)
|
||||
#expect(abs(size.height - 280) < 0.5)
|
||||
}
|
||||
|
||||
@Test func accessibilityTextCanGrowPastTwoLinesWithoutShrinking() {
|
||||
let title = "Production gateway with an unusually long name for accessible navigation"
|
||||
let regular = measure(compactCard(title: title), width: 200)
|
||||
let accessible = measure(
|
||||
compactCard(title: title).environment(\.dynamicTypeSize, .accessibility3), width: 200)
|
||||
#expect(accessible.height > regular.height)
|
||||
#expect(accessible.height > accessible.width)
|
||||
}
|
||||
|
||||
@Test(.requiresKeyWindow) func compactPreferenceUpdatesMountedCardsAndSurvivesRecreation() async throws {
|
||||
let suiteName = "MobileCompactCardTests.\(UUID())"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
var widths: [CGFloat] = []
|
||||
var standardWidths: [CGFloat] = []
|
||||
let controller = UIHostingController(
|
||||
rootView: PreferenceFixture(
|
||||
widthChanged: { widths.append($0) }, standardWidthChanged: { standardWidths.append($0) }
|
||||
)
|
||||
.defaultAppStorage(defaults)
|
||||
.environment(\.dynamicTypeSize, .large)
|
||||
.frame(width: 361))
|
||||
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
let previous = scene.keyWindow
|
||||
let window = UIWindow(windowScene: scene)
|
||||
window.rootViewController = controller
|
||||
window.makeKeyAndVisible()
|
||||
defer {
|
||||
window.isHidden = true
|
||||
window.rootViewController = nil
|
||||
previous?.makeKey()
|
||||
}
|
||||
await settle(controller)
|
||||
#expect(try #require(widths.last) > 300)
|
||||
|
||||
defaults.set(true, forKey: "mobile.compactCards")
|
||||
await settle(controller)
|
||||
let compactWidth = try #require(widths.last)
|
||||
#expect(abs(compactWidth - (361 - 3 * 12) / 4) < 0.5)
|
||||
#expect(try #require(standardWidths.last) > 300)
|
||||
|
||||
var restoredWidths: [CGFloat] = []
|
||||
let restoredDefaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
let restored = UIHostingController(
|
||||
rootView: PreferenceFixture(widthChanged: { restoredWidths.append($0) })
|
||||
.defaultAppStorage(restoredDefaults)
|
||||
.environment(\.dynamicTypeSize, .large)
|
||||
.frame(width: 361))
|
||||
window.rootViewController = restored
|
||||
await settle(restored)
|
||||
#expect(abs(try #require(restoredWidths.last) - compactWidth) < 0.5)
|
||||
|
||||
restoredDefaults.set(false, forKey: "mobile.compactCards")
|
||||
await settle(restored)
|
||||
#expect(try #require(restoredWidths.last) > 300)
|
||||
}
|
||||
|
||||
@Test func missingCredentialsRemainVisibleAsCompactWarnings() throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let missingKey = SSHKeyRef(
|
||||
label: "Unavailable key", algorithm: .ed25519, credentialRef: "missing.\(UUID())")
|
||||
try app.credentials.upsert(missingKey)
|
||||
let keyCard = MobileKeyCredentialCard(key: missingKey, credentials: app.credentials, compact: true)
|
||||
#expect(keyCard.compactWarnings.contains(AppStrings.Keychain.privateKeyMissing))
|
||||
|
||||
let identity = Identity(name: "", username: "deploy", keyID: UUID())
|
||||
let identityCard = MobileIdentityCredentialCard(
|
||||
identity: identity, credentials: app.credentials, compact: true)
|
||||
#expect(identityCard.compactWarnings.contains(AppStrings.Keychain.keyMissing))
|
||||
let empty = MobileIdentityCredentialCard(
|
||||
identity: Identity(name: "Draft", username: "deploy"), credentials: app.credentials, compact: true)
|
||||
#expect(empty.compactWarnings == [AppStrings.Keychain.noSecret])
|
||||
}
|
||||
|
||||
@Test func forwardingBadgesDistinguishIdleProgressSuccessAndFailure() {
|
||||
let rule = PortForwardingRule(hostID: UUID())
|
||||
func badge(_ runtime: PortForwardingRuntimeState) -> MobileCompactCardBadge? {
|
||||
MobileForwardingCardContent(rule: rule, hostName: "Gateway", runtime: runtime, compact: true).compactBadge
|
||||
}
|
||||
#expect(badge(.stopped) == nil)
|
||||
#expect(badge(.connecting)?.symbol == "clock.fill")
|
||||
#expect(badge(.awaitingAuthentication)?.symbol == "clock.fill")
|
||||
#expect(badge(.running(activeConnections: 1))?.symbol == "checkmark.circle.fill")
|
||||
#expect(badge(.failed("Unavailable"))?.symbol == "exclamationmark.triangle.fill")
|
||||
}
|
||||
|
||||
@Test func compactSnippetsRetainTheExistingRiskClassification() {
|
||||
let safe = MobileCompactSnippetCardContent(snippet: Snippet(title: "Inspect", body: "pwd"))
|
||||
let destructive = MobileCompactSnippetCardContent(snippet: Snippet(title: "Delete", body: "rm -rf /"))
|
||||
#expect(safe.riskTitle == AppStrings.Snippets.readOnlyRisk)
|
||||
#expect(destructive.riskTitle == AppStrings.Snippets.destructiveRisk)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
("en", "Compact"), ("zh-Hans", "紧凑"), ("zh-Hant", "緊湊"),
|
||||
("ja", "コンパクト"), ("ko", "컴팩트"), ("de", "Kompakt"), ("fr", "Compact"),
|
||||
])
|
||||
func appearanceSwitchDoesNotUseTheContextCompressionTranslation(language: String, expected: String) {
|
||||
#expect(
|
||||
AppLocalization.resolve(AppStrings.Settings.compactCards, locale: Locale(identifier: language)) == expected)
|
||||
}
|
||||
|
||||
private func compactCard(title: String) -> some View {
|
||||
MobileActionCard(compact: true) {
|
||||
} content: {
|
||||
MobileCompactCardContent(title: title) {
|
||||
MobileCardIcon(symbol: "server.rack")
|
||||
}
|
||||
} actions: {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func measure(_ view: some View, width: CGFloat) -> CGSize {
|
||||
UIHostingController(rootView: view).sizeThatFits(in: CGSize(width: width, height: 10_000))
|
||||
}
|
||||
|
||||
private func settle(_ controller: UIHostingController<some View>) async {
|
||||
for _ in 0..<8 {
|
||||
controller.view.setNeedsLayout()
|
||||
controller.view.layoutIfNeeded()
|
||||
try? await Task.sleep(for: .milliseconds(30))
|
||||
}
|
||||
}
|
||||
|
||||
private struct PreferenceFixture: View {
|
||||
@AppStorage("mobile.compactCards") private var compact = false
|
||||
let widthChanged: (CGFloat) -> Void
|
||||
var standardWidthChanged: (CGFloat) -> Void = { _ in }
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
MobileCardGrid(compact: compact) {
|
||||
ForEach(0..<4) { index in
|
||||
MobileActionCard(compact: compact) {
|
||||
} content: {
|
||||
MobileCompactCardContent(title: "Host \(index)") {
|
||||
MobileCardIcon(symbol: "server.rack")
|
||||
}
|
||||
} actions: {
|
||||
EmptyView()
|
||||
}
|
||||
.onGeometryChange(for: CGFloat.self) {
|
||||
$0.size.width
|
||||
} action: {
|
||||
if index == 0 { widthChanged($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
MobileCardGrid {
|
||||
MobileCard { Text(verbatim: "Unaffected") }
|
||||
.onGeometryChange(for: CGFloat.self) {
|
||||
$0.size.width
|
||||
} action: {
|
||||
standardWidthChanged($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,15 @@ struct MobileCardRenderingTests {
|
||||
Snippet(
|
||||
title: "Inspect disk usage / 检查磁盘", body: "df -h\ndu -sh /var/log",
|
||||
packageID: snippetPackage.id))
|
||||
app.snippets.upsert(
|
||||
Snippet(title: "Remove cache / 清理缓存", body: "rm -rf /tmp/cache", packageID: snippetPackage.id))
|
||||
let forwardingHost = try #require(app.library.hosts.first)
|
||||
for (index, kind) in [PortForwardingRule.Kind.local, .remote, .dynamic].enumerated() {
|
||||
_ = try app.forwardingLibrary.save(
|
||||
PortForwardingRule(
|
||||
hostID: forwardingHost.id, kind: kind, bindPort: 8080 + index,
|
||||
label: index == 0 ? "Database / 数据库" : "", colorTag: .teal))
|
||||
}
|
||||
let server = ServerMetric(selection: MetricsSelection(id: UUID()), name: "Production Gateway")
|
||||
server.isConnecting = false
|
||||
server.status = ""
|
||||
@@ -164,6 +173,27 @@ struct MobileCardRenderingTests {
|
||||
try await capture("known-host-editor-hashed", language: language, scheme: scheme) {
|
||||
MobileKnownHostEditor(app: app, entry: hashedKnownHost)
|
||||
}
|
||||
try await capture("vault-compact", language: language, scheme: scheme, compact: true) {
|
||||
NavigationStack {
|
||||
MobileVaultView(
|
||||
app: app, editingHost: .constant(nil), activateHost: { _ in }, open: { _, _ in })
|
||||
}
|
||||
}
|
||||
try await capture("keychain-compact", language: language, scheme: scheme, compact: true) {
|
||||
NavigationStack { MobileCredentialsView(app: app) }
|
||||
}
|
||||
try await capture("snippets-compact", language: language, scheme: scheme, compact: true) {
|
||||
NavigationStack { MobileSnippetsView(app: app) }
|
||||
}
|
||||
try await capture("forwarding-compact", language: language, scheme: scheme, compact: true) {
|
||||
NavigationStack { MobileForwardingView(app: app) }
|
||||
}
|
||||
try await capture("known-hosts-compact", language: language, scheme: scheme, compact: true) {
|
||||
NavigationStack { MobileKnownHostsView(app: app) }
|
||||
}
|
||||
try await capture("app-settings-compact", language: language, scheme: scheme, compact: true) {
|
||||
NavigationStack { MobileAppSettingsView(workspaceSettings: app.workspaceSettings) }
|
||||
}
|
||||
}
|
||||
try await capture("large-type", language: language, scheme: .light) {
|
||||
NavigationStack {
|
||||
@@ -173,6 +203,10 @@ struct MobileCardRenderingTests {
|
||||
}
|
||||
.environment(\.dynamicTypeSize, .accessibility3)
|
||||
}
|
||||
try await capture("compact-large-type", language: language, scheme: .light, compact: true) {
|
||||
NavigationStack { MobileCredentialsView(app: app) }
|
||||
.environment(\.dynamicTypeSize, .accessibility3)
|
||||
}
|
||||
try await capture("metrics-large-type", language: language, scheme: .light) {
|
||||
MobileCardPage {
|
||||
MobileCard { MetricsInstrumentBody(server: server, sleeping: false, layout: .adaptive) }
|
||||
@@ -199,13 +233,18 @@ struct MobileCardRenderingTests {
|
||||
}
|
||||
|
||||
private func capture<V: View>(
|
||||
_ name: String, language: String, scheme: ColorScheme, @ViewBuilder view: () -> V
|
||||
_ name: String, language: String, scheme: ColorScheme, compact: Bool = false, @ViewBuilder view: () -> V
|
||||
) async throws {
|
||||
let suiteName = "MobileCardRenderingTests.\(UUID())"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defaults.set(compact, forKey: "mobile.compactCards")
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
let previous = scene.keyWindow
|
||||
let window = UIWindow(windowScene: scene)
|
||||
let controller = UIHostingController(
|
||||
rootView: view().environment(\.locale, Locale(identifier: language)).preferredColorScheme(scheme))
|
||||
rootView: view().defaultAppStorage(defaults)
|
||||
.environment(\.locale, Locale(identifier: language)).preferredColorScheme(scheme))
|
||||
window.rootViewController = controller
|
||||
if name == "opaque" { controller.traitOverrides.accessibilityContrast = .high }
|
||||
window.windowLevel = .alert + 1
|
||||
|
||||
Reference in new issue
Block a user