diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index b1c36c28..ab268de1 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Frameworks/TermindPresentation/Strings/Settings.swift b/Frameworks/TermindPresentation/Strings/Settings.swift index ba20bea2..4f7d704f 100644 --- a/Frameworks/TermindPresentation/Strings/Settings.swift +++ b/Frameworks/TermindPresentation/Strings/Settings.swift @@ -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" } diff --git a/TFI/Design/MobileDesignTokens.swift b/TFI/Design/MobileDesignTokens.swift index ec68e649..d5537120 100644 --- a/TFI/Design/MobileDesignTokens.swift +++ b/TFI/Design/MobileDesignTokens.swift @@ -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 { diff --git a/TFI/Features/Keychain/MobileCredentialsView.swift b/TFI/Features/Keychain/MobileCredentialsView.swift index cf32ddf5..902f6425 100644 --- a/TFI/Features/Keychain/MobileCredentialsView.swift +++ b/TFI/Features/Keychain/MobileCredentialsView.swift @@ -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( diff --git a/TFI/Features/KnownHosts/MobileKnownHostsView.swift b/TFI/Features/KnownHosts/MobileKnownHostsView.swift index b1ebae78..cbee85c9 100644 --- a/TFI/Features/KnownHosts/MobileKnownHostsView.swift +++ b/TFI/Features/KnownHosts/MobileKnownHostsView.swift @@ -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 { diff --git a/TFI/Features/Ports/MobileForwardingView.swift b/TFI/Features/Ports/MobileForwardingView.swift index 1a547bee..89bc5a4e 100644 --- a/TFI/Features/Ports/MobileForwardingView.swift +++ b/TFI/Features/Ports/MobileForwardingView.swift @@ -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) diff --git a/TFI/Features/Settings/MobileAppSettingsView.swift b/TFI/Features/Settings/MobileAppSettingsView.swift index ab8040d4..d6315a4b 100644 --- a/TFI/Features/Settings/MobileAppSettingsView.swift +++ b/TFI/Features/Settings/MobileAppSettingsView.swift @@ -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( diff --git a/TFI/Features/Snippets/MobileCompactSnippetCardContent.swift b/TFI/Features/Snippets/MobileCompactSnippetCardContent.swift new file mode 100644 index 00000000..f89519c6 --- /dev/null +++ b/TFI/Features/Snippets/MobileCompactSnippetCardContent.swift @@ -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) + } + } + } +} diff --git a/TFI/Features/Snippets/MobileSnippetsView.swift b/TFI/Features/Snippets/MobileSnippetsView.swift index 2e3c4354..1c022838 100644 --- a/TFI/Features/Snippets/MobileSnippetsView.swift +++ b/TFI/Features/Snippets/MobileSnippetsView.swift @@ -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) diff --git a/TFI/Features/Vault/MobileHostCardContent.swift b/TFI/Features/Vault/MobileHostCardContent.swift index 399b430f..ee510f56 100644 --- a/TFI/Features/Vault/MobileHostCardContent.swift +++ b/TFI/Features/Vault/MobileHostCardContent.swift @@ -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 { diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 081f7e11..549ad6e2 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -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( diff --git a/TFI/UI/Managed/MobileCards.swift b/TFI/UI/Managed/MobileCards.swift index c67b8814..24870262 100644 --- a/TFI/UI/Managed/MobileCards.swift +++ b/TFI/UI/Managed/MobileCards.swift @@ -16,7 +16,9 @@ struct MobileCardPage: View { struct MobileCardGrid: 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: 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: View { /// The primary action fills the card. Secondary actions are available from the card's context menu. struct MobileActionCard: 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 { diff --git a/TFI/UI/Managed/MobileCompactCardContent.swift b/TFI/UI/Managed/MobileCompactCardContent.swift new file mode 100644 index 00000000..3bb05b20 --- /dev/null +++ b/TFI/UI/Managed/MobileCompactCardContent.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct MobileCompactCardContent: 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)) + } +} diff --git a/TFITests/MobileCompactCardTests.swift b/TFITests/MobileCompactCardTests.swift new file mode 100644 index 00000000..743c5bc6 --- /dev/null +++ b/TFITests/MobileCompactCardTests.swift @@ -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) 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) + } + } + } + } + } +} diff --git a/TFITests/Screenshots/MobileCardRenderingTests.swift b/TFITests/Screenshots/MobileCardRenderingTests.swift index 4190a5e9..3a741f52 100644 --- a/TFITests/Screenshots/MobileCardRenderingTests.swift +++ b/TFITests/Screenshots/MobileCardRenderingTests.swift @@ -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( - _ 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