diff --git a/CHANGELOG.md b/CHANGELOG.md index 5411b1759..0e9ebbcec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- iOS: Face ID, Touch ID, or Optic ID lock with cold-launch protection and idle timeout (1, 5, 15, or 60 minutes), opt-in from Settings +- iOS: Connection Info tab replaces the per-connection Settings tab, showing host, SSL, SSH tunnel, active database, and live connection status - MCP Setup sheet adds Zed alongside Claude Desktop, Claude Code, and Cursor with a one-paste `context_servers` snippet ### Changed +- iOS: metadata badges (column types, primary key markers, row counts) cap at the first accessibility size so they stay readable without breaking layouts at the largest Dynamic Type sizes +- iOS: SQL editor keyboard accessory uses the system keyboard input view, dropping the deprecated screen-width measurement +- iOS: Edit Connection moves to the navigation bar trailing pencil icon so the floating tab bar never covers it - PostgreSQL SQL export emits foreign key constraints via `ALTER TABLE ... ADD CONSTRAINT` after data load and resyncs sequences via `setval` so a re-imported dump round-trips cleanly even when child tables sort before parents (#1114) - SQL import parser uses bounded streaming and run-length string append, reducing memory and CPU on large files (#1114) - AI inline suggestions: debounce now uses structured Swift concurrency, and the delay is configurable via the `inlineSuggestionDebounceMs` setting (default 500ms) diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index d4b4df8a5..b09503474 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -34,6 +34,7 @@ final class ConnectionCoordinator { } var pendingQuery: String? var navigationPath = NavigationPath() + var showingEditSheet = false private(set) var queryHistory: [QueryHistoryItem] = [] private let historyStorage = QueryHistoryStorage() diff --git a/TableProMobile/TableProMobile/Info.plist b/TableProMobile/TableProMobile/Info.plist index e1dc13944..20d4d811b 100644 --- a/TableProMobile/TableProMobile/Info.plist +++ b/TableProMobile/TableProMobile/Info.plist @@ -4,6 +4,8 @@ AnalyticsHMACSecret $(ANALYTICS_HMAC_SECRET) + NSFaceIDUsageDescription + TablePro uses Face ID to protect your saved database connections and credentials. NSLocalNetworkUsageDescription TablePro connects to database servers and SSH tunnels running on your local network, including Bonjour (.local) hostnames. NSBonjourServices diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 607ebd7a0..9807493d2 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -190,6 +190,9 @@ } } } + }, + "Active DB" : { + }, "Add a database connection to get started." : { "localizations" : { @@ -238,6 +241,18 @@ } } } + }, + "After 1 hour" : { + + }, + "After 1 minute" : { + + }, + "After 5 minutes" : { + + }, + "After 15 minutes" : { + }, "All" : { "localizations" : { @@ -383,6 +398,9 @@ } } } + }, + "Auth" : { + }, "Auth Method" : { "localizations" : { @@ -399,6 +417,9 @@ } } } + }, + "Authenticate to access your database connections." : { + }, "Authentication Failed" : { "localizations" : { @@ -431,6 +452,9 @@ } } } + }, + "Auto-Lock" : { + }, "Build" : { "localizations" : { @@ -769,6 +793,9 @@ } } } + }, + "Connected" : { + }, "Connecting to %@..." : { "localizations" : { @@ -785,6 +812,9 @@ } } } + }, + "Connecting…" : { + }, "Connection" : { "localizations" : { @@ -1153,6 +1183,9 @@ } } } + }, + "Databases" : { + }, "Default" : { "localizations" : { @@ -1169,6 +1202,9 @@ } } } + }, + "Default DB" : { + }, "Default: %@" : { "localizations" : { @@ -1265,6 +1301,9 @@ } } } + }, + "Disconnected" : { + }, "Done" : { "localizations" : { @@ -1393,6 +1432,9 @@ } } } + }, + "Enabled" : { + }, "Enter a name for the new SQLite database." : { "localizations" : { @@ -1619,6 +1661,9 @@ } } } + }, + "File" : { + }, "Filter" : { "localizations" : { @@ -1860,6 +1905,9 @@ } } } + }, + "Immediately" : { + }, "Import connections from your Mac" : { "localizations" : { @@ -1893,6 +1941,9 @@ } } } + }, + "Info" : { + }, "Input Method" : { "localizations" : { @@ -1941,6 +1992,12 @@ } } } + }, + "Load Failed" : { + + }, + "Load full value" : { + }, "Load More" : { "extractionState" : "stale", @@ -2011,9 +2068,15 @@ }, "Local Network access is required. Open Settings > Privacy & Security > Local Network and turn TablePro on." : { + }, + "Local Network access may be blocked. Open Settings > Privacy & Security > Local Network and turn TablePro on." : { + }, "Local Network Access Required" : { + }, + "Locks TablePro when reopened after the selected idle time. Cold launches always require authentication." : { + }, "Logic" : { "localizations" : { @@ -2447,6 +2510,9 @@ } } } + }, + "Off" : { + }, "OK" : { "localizations" : { @@ -2674,6 +2740,9 @@ } } } + }, + "Path" : { + }, "Port" : { "localizations" : { @@ -2692,6 +2761,7 @@ } }, "Primary" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2883,6 +2953,15 @@ } } } + }, + "Require Face ID" : { + + }, + "Require Optic ID" : { + + }, + "Require Touch ID" : { + }, "Results Cleared" : { "localizations" : { @@ -2901,6 +2980,7 @@ } }, "Results cleared due to memory pressure." : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2915,6 +2995,9 @@ } } } + }, + "Results trimmed due to memory pressure." : { + }, "Retry" : { "localizations" : { @@ -3030,6 +3113,12 @@ } } } + }, + "Schema" : { + + }, + "Schemas" : { + }, "Search all columns" : { "localizations" : { @@ -3094,6 +3183,9 @@ } } } + }, + "Security" : { + }, "SELECT * FROM ..." : { "localizations" : { @@ -3447,6 +3539,12 @@ } } } + }, + "Stats" : { + + }, + "Status" : { + }, "Stop" : { @@ -3532,6 +3630,9 @@ } } } + }, + "TablePro is Locked" : { + }, "Tables" : { "localizations" : { @@ -3853,6 +3954,15 @@ } } } + }, + "Try Again" : { + + }, + "Try again or check your connection." : { + + }, + "Type" : { + }, "Ungrouped" : { "localizations" : { @@ -3871,6 +3981,7 @@ } }, "Unique" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -3885,6 +3996,15 @@ } } } + }, + "Unlock" : { + + }, + "Unlock TablePro to access your database connections." : { + + }, + "Use Passcode" : { + }, "Username" : { "localizations" : { diff --git a/TableProMobile/TableProMobile/Models/ConnectedTab.swift b/TableProMobile/TableProMobile/Models/ConnectedTab.swift index 235894bd5..839a0ee47 100644 --- a/TableProMobile/TableProMobile/Models/ConnectedTab.swift +++ b/TableProMobile/TableProMobile/Models/ConnectedTab.swift @@ -7,5 +7,5 @@ enum ConnectedTab: String, CaseIterable, Sendable { case tables case query case history - case settings + case info } diff --git a/TableProMobile/TableProMobile/Platform/AppLockState.swift b/TableProMobile/TableProMobile/Platform/AppLockState.swift new file mode 100644 index 000000000..2a7932871 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/AppLockState.swift @@ -0,0 +1,107 @@ +// +// AppLockState.swift +// TableProMobile +// + +import Foundation +import Observation +import os +import SwiftUI + +@MainActor @Observable +final class AppLockState { + enum AutoLockTimeout: Int, CaseIterable, Identifiable, Sendable { + case immediately = 0 + case oneMinute = 60 + case fiveMinutes = 300 + case fifteenMinutes = 900 + case oneHour = 3600 + + var id: Int { rawValue } + + var displayName: String { + switch self { + case .immediately: String(localized: "Immediately") + case .oneMinute: String(localized: "After 1 minute") + case .fiveMinutes: String(localized: "After 5 minutes") + case .fifteenMinutes: String(localized: "After 15 minutes") + case .oneHour: String(localized: "After 1 hour") + } + } + } + + private(set) var isLocked: Bool + private var lastBackgroundedAt: Date? + private let auth: BiometricAuthService + + static let lockEnabledKey = "com.TablePro.settings.lockEnabled" + static let lockTimeoutKey = "com.TablePro.settings.lockTimeoutSeconds" + + private static let logger = Logger(subsystem: "com.TablePro", category: "AppLockState") + + init() { + let auth = BiometricAuthService() + self.auth = auth + self.isLocked = Self.shouldLockOnColdLaunch(auth: auth) + } + + static var isLockEnabled: Bool { + UserDefaults.standard.bool(forKey: lockEnabledKey) + } + + static var autoLockTimeout: AutoLockTimeout { + let stored = UserDefaults.standard.object(forKey: lockTimeoutKey) as? Int ?? AutoLockTimeout.fiveMinutes.rawValue + return AutoLockTimeout(rawValue: stored) ?? .fiveMinutes + } + + private static func shouldLockOnColdLaunch(auth: BiometricAuthService) -> Bool { + guard isLockEnabled else { return false } + return auth.availability != .unavailable + } + + func handleScenePhase(_ phase: ScenePhase) { + guard Self.isLockEnabled, auth.availability != .unavailable else { + isLocked = false + return + } + + switch phase { + case .background: + if lastBackgroundedAt == nil { + lastBackgroundedAt = Date() + } + case .active: + evaluateIdleLock() + case .inactive: + break + @unknown default: + break + } + } + + private func evaluateIdleLock() { + guard let backgrounded = lastBackgroundedAt else { return } + let elapsed = Date().timeIntervalSince(backgrounded) + let timeout = TimeInterval(Self.autoLockTimeout.rawValue) + if elapsed >= timeout { + Self.logger.info("Idle timeout exceeded (\(elapsed, format: .fixed(precision: 0))s >= \(timeout, format: .fixed(precision: 0))s), locking") + isLocked = true + } + lastBackgroundedAt = nil + } + + func unlock() async -> Bool { + let reason = String(localized: "Unlock TablePro to access your database connections.") + let success = await auth.authenticate(reason: reason) + if success { + isLocked = false + lastBackgroundedAt = nil + } + return success + } + + func lockNow() { + guard Self.isLockEnabled, auth.availability != .unavailable else { return } + isLocked = true + } +} diff --git a/TableProMobile/TableProMobile/Platform/BiometricAuthService.swift b/TableProMobile/TableProMobile/Platform/BiometricAuthService.swift new file mode 100644 index 000000000..082e17ae9 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/BiometricAuthService.swift @@ -0,0 +1,51 @@ +// +// BiometricAuthService.swift +// TableProMobile +// + +import Foundation +import LocalAuthentication +import os + +@MainActor +final class BiometricAuthService { + enum Availability: Sendable { + case unavailable + case faceID + case touchID + case opticID + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "BiometricAuth") + + var availability: Availability { + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { + return .unavailable + } + switch context.biometryType { + case .faceID: return .faceID + case .touchID: return .touchID + case .opticID: return .opticID + case .none: return .unavailable + @unknown default: return .unavailable + } + } + + func authenticate(reason: String) async -> Bool { + let context = LAContext() + context.localizedFallbackTitle = String(localized: "Use Passcode") + do { + return try await context.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: reason + ) + } catch let error as LAError where error.code == .userCancel || error.code == .appCancel || error.code == .systemCancel { + return false + } catch { + Self.logger.warning("Biometric auth failed: \(error.localizedDescription, privacy: .public)") + return false + } + } +} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 14c775512..7d90b3cef 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -12,6 +12,7 @@ import TableProModels @main struct TableProMobileApp: App { @State private var appState = AppState() + @State private var lockState = AppLockState() @State private var syncTask: Task? @State private var heartbeatService: AnalyticsHeartbeatService? @State private var heartbeatTask: Task? @@ -19,15 +20,26 @@ struct TableProMobileApp: App { var body: some Scene { WindowGroup { - Group { - if appState.hasCompletedOnboarding { - ConnectionListView() - .environment(appState) - } else { - OnboardingView() - .environment(appState) + ZStack { + Group { + if appState.hasCompletedOnboarding { + ConnectionListView() + .environment(appState) + } else { + OnboardingView() + .environment(appState) + } + } + .blur(radius: lockState.isLocked ? 20 : 0) + .allowsHitTesting(!lockState.isLocked) + + if lockState.isLocked { + LockScreenView() + .environment(lockState) + .transition(.opacity) } } + .animation(.default, value: lockState.isLocked) .onOpenURL { url in guard url.scheme == "tablepro", url.host(percentEncoded: false) == "connect", @@ -53,6 +65,7 @@ struct TableProMobileApp: App { } } .onChange(of: scenePhase) { _, phase in + lockState.handleScenePhase(phase) switch phase { case .active: MemoryPressureMonitor.shared.start() diff --git a/TableProMobile/TableProMobile/Views/Components/MetadataBadge.swift b/TableProMobile/TableProMobile/Views/Components/MetadataBadge.swift new file mode 100644 index 000000000..fdfa6ada3 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/Components/MetadataBadge.swift @@ -0,0 +1,30 @@ +// +// MetadataBadge.swift +// TableProMobile +// + +import SwiftUI + +struct MetadataBadge: View { + let text: String + var foreground: Color = .secondary + var background: Background + + var body: some View { + Text(text) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(foreground) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(background, in: Capsule()) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + } +} + +extension MetadataBadge where Background == HierarchicalShapeStyle { + init(_ text: String, foreground: Color = .secondary) { + self.init(text: text, foreground: foreground, background: .tertiary) + } +} diff --git a/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift b/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift index bce0ea2b8..f08f70153 100644 --- a/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift +++ b/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift @@ -73,8 +73,9 @@ struct SQLHighlightTextView: UIViewRepresentable { // MARK: - Keyboard Accessory Toolbar func makeAccessoryToolbar() -> UIView { - let toolbar = UIView(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 44)) - toolbar.backgroundColor = .secondarySystemBackground + let toolbar = UIInputView(frame: CGRect(x: 0, y: 0, width: 0, height: 44), inputViewStyle: .keyboard) + toolbar.autoresizingMask = .flexibleWidth + toolbar.allowsSelfSizing = true let separator = UIView() separator.backgroundColor = .separator diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 1e3b617f3..1133eb50a 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -102,8 +102,8 @@ struct ConnectedView: View { QueryHistoryView() .environment(coordinator) } - Tab("Settings", systemImage: "gear", value: .settings) { - SettingsView() + Tab("Info", systemImage: "info.circle", value: .info) { + ConnectionInfoView() .environment(coordinator) } } @@ -128,9 +128,9 @@ struct ConnectedView: View { .keyboardShortcut("3", modifiers: .command) .accessibilityLabel(Text("History")) .hidden() - Button("") { coordinator.selectedTab = .settings } + Button("") { coordinator.selectedTab = .info } .keyboardShortcut("4", modifiers: .command) - .accessibilityLabel(Text("Settings")) + .accessibilityLabel(Text("Info")) .hidden() } .overlay(alignment: .top) { @@ -179,7 +179,7 @@ struct ConnectedView: View { switch coordinator.selectedTab { case .tables, .query: coordinator.displayName case .history: String(localized: "History") - case .settings: String(localized: "Settings") + case .info: String(localized: "Info") } } @@ -187,6 +187,16 @@ struct ConnectedView: View { @ToolbarContentBuilder private func connectionToolbar(_ coordinator: ConnectionCoordinator) -> some ToolbarContent { + if coordinator.selectedTab == .info { + ToolbarItem(placement: .topBarTrailing) { + Button { + coordinator.showingEditSheet = true + } label: { + Image(systemName: "pencil") + .accessibilityLabel(Text("Edit Connection")) + } + } + } if connection.safeModeLevel != .off { ToolbarItem(placement: .topBarTrailing) { Image(systemName: connection.safeModeLevel == .readOnly ? "lock.fill" : "shield.fill") diff --git a/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift b/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift new file mode 100644 index 000000000..0f17d79a3 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/ConnectionInfoView.swift @@ -0,0 +1,191 @@ +// +// ConnectionInfoView.swift +// TableProMobile +// + +import SwiftUI +import TableProDatabase +import TableProModels + +struct ConnectionInfoView: View { + @Environment(ConnectionCoordinator.self) private var coordinator + @Environment(AppState.self) private var appState + + private var connection: DatabaseConnection { coordinator.connection } + + var body: some View { + Form { + Section { + LabeledContent { + Text(connection.name.isEmpty ? connection.host : connection.name) + } label: { + HStack(spacing: 8) { + DatabaseIconView(type: connection.type, size: 18) + .frame(width: 28, height: 28) + .background(DatabaseIconView.color(for: connection.type).opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + Text("Name") + } + } + LabeledContent("Type", value: typeDisplayName) + if connection.safeModeLevel != .off { + LabeledContent("Safe Mode") { + Label { + Text(connection.safeModeLevel.displayName) + } icon: { + Image(systemName: connection.safeModeLevel == .readOnly ? "lock.fill" : "shield.fill") + .foregroundStyle(connection.safeModeLevel == .readOnly ? .red : .orange) + } + } + } + } + + if connection.type != .sqlite { + serverSection + if connection.sshEnabled, let ssh = connection.sshConfiguration { + sshSection(ssh) + } + } else { + sqliteFileSection + } + + statsSection + } + .navigationTitle("Info") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: Binding( + get: { coordinator.showingEditSheet }, + set: { coordinator.showingEditSheet = $0 } + )) { + ConnectionFormView(editing: connection) { updated in + appState.updateConnection(updated) + coordinator.showingEditSheet = false + } + } + } + + private var typeDisplayName: String { + switch connection.type { + case .mysql: "MySQL" + case .mariadb: "MariaDB" + case .postgresql: "PostgreSQL" + case .redshift: "Redshift" + case .sqlite: "SQLite" + case .redis: "Redis" + default: connection.type.rawValue.uppercased() + } + } + + @ViewBuilder + private var serverSection: some View { + Section("Server") { + LabeledContent("Host") { + Text(verbatim: "\(connection.host):\(connection.port)") + .textSelection(.enabled) + } + if !connection.username.isEmpty { + LabeledContent("Username") { + Text(connection.username).textSelection(.enabled) + } + } + HStack { + Text("SSL") + Spacer() + if connection.sslEnabled { + Image(systemName: "checkmark") + .foregroundStyle(.green) + .font(.subheadline.weight(.semibold)) + .accessibilityLabel(Text("Enabled")) + } else { + Text("Off") + .foregroundStyle(.secondary) + } + } + if !activeDatabaseLabel.isEmpty { + LabeledContent(coordinator.activeDatabase.isEmpty ? "Default DB" : "Active DB", value: activeDatabaseLabel) + } + if coordinator.supportsSchemas, !coordinator.activeSchema.isEmpty { + LabeledContent("Schema", value: coordinator.activeSchema) + } + } + } + + private var activeDatabaseLabel: String { + if !coordinator.activeDatabase.isEmpty { return coordinator.activeDatabase } + return connection.database + } + + @ViewBuilder + private func sshSection(_ ssh: SSHConfiguration) -> some View { + Section("SSH Tunnel") { + LabeledContent("SSH Host") { + Text(verbatim: "\(ssh.host):\(ssh.port)") + .textSelection(.enabled) + } + LabeledContent("SSH Username", value: ssh.username) + LabeledContent("Auth", value: ssh.authMethod == .password ? "Password" : "Private Key") + } + } + + @ViewBuilder + private var sqliteFileSection: some View { + Section("File") { + let url = URL(fileURLWithPath: connection.database) + LabeledContent("Name", value: url.lastPathComponent) + LabeledContent("Path") { + Text(connection.database) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(2) + .truncationMode(.middle) + } + } + } + + @ViewBuilder + private var statsSection: some View { + Section("Stats") { + LabeledContent("Tables", value: "\(coordinator.tables.count)") + if coordinator.supportsDatabaseSwitching, !coordinator.databases.isEmpty { + LabeledContent("Databases", value: "\(coordinator.databases.count)") + } + if coordinator.supportsSchemas, !coordinator.schemas.isEmpty { + LabeledContent("Schemas", value: "\(coordinator.schemas.count)") + } + HStack { + Text("Status") + Spacer() + Image(systemName: statusIcon) + .foregroundStyle(statusColor) + .font(.caption) + Text(statusText) + .foregroundStyle(statusColor) + } + } + } + + private var statusIcon: String { + switch coordinator.phase { + case .connecting: "arrow.triangle.2.circlepath" + case .connected: "circle.fill" + case .error: "exclamationmark.circle.fill" + } + } + + private var statusColor: Color { + switch coordinator.phase { + case .connecting: .secondary + case .connected: .green + case .error: .red + } + } + + private var statusText: String { + switch coordinator.phase { + case .connecting: String(localized: "Connecting…") + case .connected: String(localized: "Connected") + case .error: String(localized: "Disconnected") + } + } +} diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index 43aa51637..f7dbe2917 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -97,13 +97,7 @@ struct InsertRowView: View { Spacer() - Text(column.typeName) - .font(.caption2) - .fontWeight(.medium) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.fill.tertiary) - .clipShape(Capsule()) + MetadataBadge(column.typeName) } } footer: { if let defaultValue = column.defaultValue { diff --git a/TableProMobile/TableProMobile/Views/LockScreenView.swift b/TableProMobile/TableProMobile/Views/LockScreenView.swift new file mode 100644 index 000000000..1a194e851 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/LockScreenView.swift @@ -0,0 +1,61 @@ +// +// LockScreenView.swift +// TableProMobile +// + +import SwiftUI + +struct LockScreenView: View { + @Environment(AppLockState.self) private var lockState + @State private var isAuthenticating = false + @State private var didFail = false + + var body: some View { + ZStack { + Rectangle() + .fill(.regularMaterial) + .ignoresSafeArea() + + VStack(spacing: 24) { + Image(systemName: "lock.fill") + .font(.system(size: 56)) + .foregroundStyle(.tint) + .symbolRenderingMode(.hierarchical) + + VStack(spacing: 6) { + Text("TablePro is Locked") + .font(.title2.weight(.semibold)) + Text("Authenticate to access your database connections.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 32) + + Button { + Task { await unlock() } + } label: { + Label( + didFail ? String(localized: "Try Again") : String(localized: "Unlock"), + systemImage: "faceid" + ) + .frame(minWidth: 220) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isAuthenticating) + } + } + .task { await unlock() } + } + + private func unlock() async { + guard !isAuthenticating, lockState.isLocked else { return } + isAuthenticating = true + defer { isAuthenticating = false } + let success = await lockState.unlock() + if !success { + didFail = true + } + } +} diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index b785c4154..3e50b91b8 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -94,9 +94,9 @@ struct RowDetailView: View { rowContent(at: currentIndex) } else { TabView(selection: $currentIndex) { - ForEach(IndexedRow.wrap(rows)) { item in - rowContent(at: item.id) - .tag(item.id) + ForEach(rows.indices, id: \.self) { index in + rowContent(at: index) + .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) @@ -124,32 +124,7 @@ struct RowDetailView: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { Menu { - Section("Share") { - ForEach(ExportFormat.allCases) { format in - Button { - shareText = ClipboardExporter.exportRow( - columns: columns, row: currentRow, - format: format, tableName: table?.name - ) - showShareSheet = true - } label: { - Label(format.rawValue, systemImage: "square.and.arrow.up") - } - } - } - Section("Copy to Clipboard") { - ForEach(ExportFormat.allCases) { format in - Button { - let text = ClipboardExporter.exportRow( - columns: columns, row: currentRow, - format: format, tableName: table?.name - ) - ClipboardExporter.copyToClipboard(text) - } label: { - Label(format.rawValue, systemImage: "doc.on.clipboard") - } - } - } + shareMenuContent } label: { Image(systemName: "square.and.arrow.up") } @@ -233,6 +208,36 @@ struct RowDetailView: View { } } + @ViewBuilder + private var shareMenuContent: some View { + Section("Share") { + ForEach(ExportFormat.allCases) { format in + Button { + shareText = ClipboardExporter.exportRow( + columns: columns, row: currentRow, + format: format, tableName: table?.name + ) + showShareSheet = true + } label: { + Label(format.rawValue, systemImage: "square.and.arrow.up") + } + } + } + Section("Copy to Clipboard") { + ForEach(ExportFormat.allCases) { format in + Button { + let text = ClipboardExporter.exportRow( + columns: columns, row: currentRow, + format: format, tableName: table?.name + ) + ClipboardExporter.copyToClipboard(text) + } label: { + Label(format.rawValue, systemImage: "doc.on.clipboard") + } + } + } + } + @ViewBuilder private func rowContent(at rowIndex: Int) -> some View { let row: [String?] = { @@ -303,13 +308,7 @@ struct RowDetailView: View { Spacer() - Text(column.typeName) - .font(.caption2) - .fontWeight(.medium) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.fill.tertiary) - .clipShape(Capsule()) + MetadataBadge(column.typeName) } } } diff --git a/TableProMobile/TableProMobile/Views/SettingsView.swift b/TableProMobile/TableProMobile/Views/SettingsView.swift index 7f0f211b0..e8b0109f8 100644 --- a/TableProMobile/TableProMobile/Views/SettingsView.swift +++ b/TableProMobile/TableProMobile/Views/SettingsView.swift @@ -6,10 +6,16 @@ import SwiftUI struct SettingsView: View { - @AppStorage("com.TablePro.settings.shareAnalytics") private var shareAnalytics: Bool = true + @AppStorage("com.TablePro.settings.shareAnalytics") private var shareAnalytics = true + @AppStorage(AppLockState.lockEnabledKey) private var lockEnabled = false + @AppStorage(AppLockState.lockTimeoutKey) private var lockTimeoutSeconds = AppLockState.AutoLockTimeout.fiveMinutes.rawValue + + private let auth = BiometricAuthService() var body: some View { Form { + biometricSection + Section("Privacy") { Toggle(String(localized: "Share anonymous usage data"), isOn: $shareAnalytics) @@ -20,13 +26,44 @@ struct SettingsView: View { Section("About") { LabeledContent(String(localized: "Version")) { - Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—") + Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "-") } LabeledContent(String(localized: "Build")) { - Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—") + Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "-") } } } .navigationTitle(String(localized: "Settings")) } + + @ViewBuilder + private var biometricSection: some View { + let availability = auth.availability + if availability != .unavailable { + Section { + Toggle(toggleLabel(for: availability), isOn: $lockEnabled) + + if lockEnabled { + Picker(String(localized: "Auto-Lock"), selection: $lockTimeoutSeconds) { + ForEach(AppLockState.AutoLockTimeout.allCases) { option in + Text(option.displayName).tag(option.rawValue) + } + } + } + } header: { + Text("Security") + } footer: { + Text("Locks TablePro when reopened after the selected idle time. Cold launches always require authentication.") + } + } + } + + private func toggleLabel(for availability: BiometricAuthService.Availability) -> String { + switch availability { + case .faceID: String(localized: "Require Face ID") + case .touchID: String(localized: "Require Touch ID") + case .opticID: String(localized: "Require Optic ID") + case .unavailable: "" + } + } } diff --git a/TableProMobile/TableProMobile/Views/StructureView.swift b/TableProMobile/TableProMobile/Views/StructureView.swift index c88ebf78b..a3e8fa6c5 100644 --- a/TableProMobile/TableProMobile/Views/StructureView.swift +++ b/TableProMobile/TableProMobile/Views/StructureView.swift @@ -78,13 +78,7 @@ struct StructureView: View { Spacer() - Text(column.typeName) - .font(.caption2) - .fontWeight(.medium) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.fill.tertiary) - .clipShape(Capsule()) + MetadataBadge(column.typeName) } HStack(spacing: 8) { @@ -140,35 +134,15 @@ struct StructureView: View { Spacer() if index.isPrimary { - Text("Primary") - .font(.caption2) - .fontWeight(.medium) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.orange.opacity(0.15)) - .foregroundStyle(.orange) - .clipShape(Capsule()) + MetadataBadge(text: "Primary", foreground: .orange, background: Color.orange.opacity(0.15)) } if index.isUnique && !index.isPrimary { - Text("Unique") - .font(.caption2) - .fontWeight(.medium) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.blue.opacity(0.15)) - .foregroundStyle(.blue) - .clipShape(Capsule()) + MetadataBadge(text: "Unique", foreground: .blue, background: Color.blue.opacity(0.15)) } if !index.type.isEmpty { - Text(index.type) - .font(.caption2) - .fontWeight(.medium) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.fill.tertiary) - .clipShape(Capsule()) + MetadataBadge(index.type) } } diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index f537a4cd3..e33ca2b59 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -192,13 +192,7 @@ private struct TableRow: View { Spacer() if let rowCount = table.rowCount { - Text(formatRowCount(rowCount)) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(.fill.tertiary) - .clipShape(Capsule()) + MetadataBadge(formatRowCount(rowCount)) } } }