diff --git a/CHANGELOG.md b/CHANGELOG.md index 238933a47..214b20c9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - iOS: connection groups and tags +- iOS: Quick Connect Home Screen widget ## [0.27.4] - 2026-04-05 diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index b5a9cb223..abb4c75c9 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXBuildFile section */ 5A87EEED2F7F893000D028D0 /* TableProSync in Frameworks */ = {isa = PBXBuildFile; productRef = 5A87EEEC2F7F893000D028D0 /* TableProSync */; }; + 5AA136062F82610F00ADCD58 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA136052F82610F00ADCD58 /* WidgetKit.framework */; }; + 5AA136082F82610F00ADCD58 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA136072F82610F00ADCD58 /* SwiftUI.framework */; }; + 5AA136132F82611000ADCD58 /* TableProWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5AA136042F82610F00ADCD58 /* TableProWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5AA3133A2F7EA5B4008EBA97 /* LibPQ.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA313342F7EA5B4008EBA97 /* LibPQ.xcframework */; }; 5AA3133C2F7EA5B4008EBA97 /* Hiredis.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA313352F7EA5B4008EBA97 /* Hiredis.xcframework */; }; 5AA3133E2F7EA5B4008EBA97 /* OpenSSL-Crypto.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA313362F7EA5B4008EBA97 /* OpenSSL-Crypto.xcframework */; }; @@ -21,6 +24,30 @@ 5AB9F3EF2F7C1D03001F3337 /* TableProQuery in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EE2F7C1D03001F3337 /* TableProQuery */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 5AA136112F82611000ADCD58 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5AB9F3D12F7C1C12001F3337 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5AA136032F82610F00ADCD58; + remoteInfo = TableProWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 5AA136142F82611000ADCD58 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 5AA136132F82611000ADCD58 /* TableProWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 5A87ECDD2F7F88F200D028D0 /* AIPromptTemplates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIPromptTemplates.swift; sourceTree = ""; }; 5A87ECDE2F7F88F200D028D0 /* AIPromptTemplates+InlineSuggest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AIPromptTemplates+InlineSuggest.swift"; sourceTree = ""; }; @@ -480,6 +507,10 @@ 5A87EEE82F7F891F00D028D0 /* SyncMetadataStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMetadataStorage.swift; sourceTree = ""; }; 5A87EEE92F7F891F00D028D0 /* SyncRecordMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncRecordMapper.swift; sourceTree = ""; }; 5A87EEEA2F7F891F00D028D0 /* SyncRecordType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncRecordType.swift; sourceTree = ""; }; + 5AA136042F82610F00ADCD58 /* TableProWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TableProWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 5AA136052F82610F00ADCD58 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 5AA136072F82610F00ADCD58 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 5AA136322F82675600ADCD58 /* TableProWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TableProWidgetExtension.entitlements; sourceTree = ""; }; 5AA313342F7EA5B4008EBA97 /* LibPQ.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = LibPQ.xcframework; path = ../Libs/ios/LibPQ.xcframework; sourceTree = ""; }; 5AA313352F7EA5B4008EBA97 /* Hiredis.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Hiredis.xcframework; path = ../Libs/ios/Hiredis.xcframework; sourceTree = ""; }; 5AA313362F7EA5B4008EBA97 /* OpenSSL-Crypto.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "OpenSSL-Crypto.xcframework"; path = "../Libs/ios/OpenSSL-Crypto.xcframework"; sourceTree = ""; }; @@ -490,7 +521,34 @@ 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TableProMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 5AA136172F82611000ADCD58 /* Exceptions for "TableProWidget" folder in "TableProWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5AA136032F82610F00ADCD58 /* TableProWidgetExtension */; + }; + 5AA136302F82660900ADCD58 /* Exceptions for "TableProWidget" folder in "TableProMobile" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Shared/SharedConnectionStore.swift, + Shared/WidgetConnectionItem.swift, + ); + target = 5AB9F3D82F7C1C12001F3337 /* TableProMobile */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ + 5AA136092F82610F00ADCD58 /* TableProWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5AA136302F82660900ADCD58 /* Exceptions for "TableProWidget" folder in "TableProMobile" target */, + 5AA136172F82611000ADCD58 /* Exceptions for "TableProWidget" folder in "TableProWidgetExtension" target */, + ); + path = TableProWidget; + sourceTree = ""; + }; 5AB9F3DB2F7C1C12001F3337 /* TableProMobile */ = { isa = PBXFileSystemSynchronizedRootGroup; path = TableProMobile; @@ -499,6 +557,15 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 5AA136012F82610F00ADCD58 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5AA136082F82610F00ADCD58 /* SwiftUI.framework in Frameworks */, + 5AA136062F82610F00ADCD58 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5AB9F3D62F7C1C12001F3337 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1543,6 +1610,8 @@ 5AA313372F7EA5B4008EBA97 /* MariaDB.xcframework */, 5AA313362F7EA5B4008EBA97 /* OpenSSL-Crypto.xcframework */, 5AA313392F7EA5B4008EBA97 /* OpenSSL-SSL.xcframework */, + 5AA136052F82610F00ADCD58 /* WidgetKit.framework */, + 5AA136072F82610F00ADCD58 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -1550,7 +1619,9 @@ 5AB9F3D02F7C1C12001F3337 = { isa = PBXGroup; children = ( + 5AA136322F82675600ADCD58 /* TableProWidgetExtension.entitlements */, 5AB9F3DB2F7C1C12001F3337 /* TableProMobile */, + 5AA136092F82610F00ADCD58 /* TableProWidget */, 5AA313332F7EA5B4008EBA97 /* Frameworks */, 5AB9F3DA2F7C1C12001F3337 /* Products */, ); @@ -1560,6 +1631,7 @@ isa = PBXGroup; children = ( 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */, + 5AA136042F82610F00ADCD58 /* TableProWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -1567,6 +1639,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 5AA136032F82610F00ADCD58 /* TableProWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5AA136182F82611000ADCD58 /* Build configuration list for PBXNativeTarget "TableProWidgetExtension" */; + buildPhases = ( + 5AA136002F82610F00ADCD58 /* Sources */, + 5AA136012F82610F00ADCD58 /* Frameworks */, + 5AA136022F82610F00ADCD58 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5AA136092F82610F00ADCD58 /* TableProWidget */, + ); + name = TableProWidgetExtension; + packageProductDependencies = ( + ); + productName = TableProWidgetExtension; + productReference = 5AA136042F82610F00ADCD58 /* TableProWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 5AB9F3D82F7C1C12001F3337 /* TableProMobile */ = { isa = PBXNativeTarget; buildConfigurationList = 5AB9F3E42F7C1C13001F3337 /* Build configuration list for PBXNativeTarget "TableProMobile" */; @@ -1574,10 +1668,12 @@ 5AB9F3D52F7C1C12001F3337 /* Sources */, 5AB9F3D62F7C1C12001F3337 /* Frameworks */, 5AB9F3D72F7C1C12001F3337 /* Resources */, + 5AA136142F82611000ADCD58 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 5AA136122F82611000ADCD58 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 5AB9F3DB2F7C1C12001F3337 /* TableProMobile */, @@ -1601,9 +1697,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2640; + LastSwiftUpdateCheck = 2650; LastUpgradeCheck = 2650; TargetAttributes = { + 5AA136032F82610F00ADCD58 = { + CreatedOnToolsVersion = 26.5; + }; 5AB9F3D82F7C1C12001F3337 = { CreatedOnToolsVersion = 26.4; }; @@ -1627,11 +1726,19 @@ projectRoot = ""; targets = ( 5AB9F3D82F7C1C12001F3337 /* TableProMobile */, + 5AA136032F82610F00ADCD58 /* TableProWidgetExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 5AA136022F82610F00ADCD58 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5AB9F3D72F7C1C12001F3337 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1642,6 +1749,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 5AA136002F82610F00ADCD58 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5AB9F3D52F7C1C12001F3337 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1651,7 +1765,79 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 5AA136122F82611000ADCD58 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5AA136032F82610F00ADCD58 /* TableProWidgetExtension */; + targetProxy = 5AA136112F82611000ADCD58 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 5AA136152F82611000ADCD58 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = TableProWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TableProWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TableProWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProMobile.Widget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 5AA136162F82611000ADCD58 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = TableProWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TableProWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TableProWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProMobile.Widget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 5AB9F3E22F7C1C13001F3337 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1862,6 +2048,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 5AA136182F82611000ADCD58 /* Build configuration list for PBXNativeTarget "TableProWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5AA136152F82611000ADCD58 /* Debug */, + 5AA136162F82611000ADCD58 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5AB9F3D42F7C1C12001F3337 /* Build configuration list for PBXProject "TableProMobile" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index b81587f2f..4ecadd311 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -7,12 +7,14 @@ import Foundation import Observation import TableProDatabase import TableProModels +import WidgetKit @MainActor @Observable final class AppState { var connections: [DatabaseConnection] = [] var groups: [ConnectionGroup] = [] var tags: [ConnectionTag] = [] + var pendingConnectionId: UUID? let connectionManager: ConnectionManager let syncCoordinator = IOSSyncCoordinator() let sshProvider: IOSSSHProvider @@ -37,11 +39,13 @@ final class AppState { groups = groupStorage.load() tags = tagStorage.load() secureStore.cleanOrphanedCredentials(validConnectionIds: Set(connections.map(\.id))) + updateWidgetData() syncCoordinator.onConnectionsChanged = { [weak self] merged in guard let self else { return } self.connections = merged self.storage.save(merged) + self.updateWidgetData() } syncCoordinator.onGroupsChanged = { [weak self] merged in @@ -67,6 +71,7 @@ final class AppState { func addConnection(_ connection: DatabaseConnection) { connections.append(connection) storage.save(connections) + updateWidgetData() syncCoordinator.markDirty(connection.id) syncCoordinator.scheduleSyncAfterChange() } @@ -75,6 +80,7 @@ final class AppState { if let index = connections.firstIndex(where: { $0.id == connection.id }) { connections[index] = connection storage.save(connections) + updateWidgetData() syncCoordinator.markDirty(connection.id) syncCoordinator.scheduleSyncAfterChange() } @@ -91,6 +97,7 @@ final class AppState { try? secureStore.delete(forKey: "com.TablePro.keypassphrase.\(connection.id.uuidString)") try? secureStore.delete(forKey: "com.TablePro.sshkeydata.\(connection.id.uuidString)") storage.save(connections) + updateWidgetData() syncCoordinator.markDeleted(connection.id) syncCoordinator.scheduleSyncAfterChange() } @@ -161,6 +168,25 @@ final class AppState { syncCoordinator.scheduleSyncAfterChange() } + // MARK: - Widget + + private func updateWidgetData() { + let items = connections + .sorted { ($0.sortOrder, $0.name) < ($1.sortOrder, $1.name) } + .map { conn in + WidgetConnectionItem( + id: conn.id, + name: conn.name.isEmpty ? conn.host : conn.name, + type: conn.type.rawValue, + host: conn.host, + port: conn.port, + sortOrder: conn.sortOrder + ) + } + SharedConnectionStore.write(items) + WidgetCenter.shared.reloadAllTimelines() + } + // MARK: - Helpers func group(for id: UUID?) -> ConnectionGroup? { diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 6b3ae016b..b6ba4070e 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -15,12 +15,21 @@ struct TableProMobileApp: App { var body: some Scene { WindowGroup { - if appState.hasCompletedOnboarding { - ConnectionListView() - .environment(appState) - } else { - OnboardingView() - .environment(appState) + Group { + if appState.hasCompletedOnboarding { + ConnectionListView() + .environment(appState) + } else { + OnboardingView() + .environment(appState) + } + } + .onOpenURL { url in + guard url.scheme == "tablepro", + url.host(percentEncoded: false) == "connect", + let uuidString = url.pathComponents.dropFirst().first, + let uuid = UUID(uuidString: uuidString) else { return } + appState.pendingConnectionId = uuid } } .onChange(of: scenePhase) { _, phase in diff --git a/TableProMobile/TableProMobile/TableProMobileRelease.entitlements b/TableProMobile/TableProMobile/TableProMobileRelease.entitlements index cacbeecd7..f8880a803 100644 --- a/TableProMobile/TableProMobile/TableProMobileRelease.entitlements +++ b/TableProMobile/TableProMobile/TableProMobileRelease.entitlements @@ -10,6 +10,10 @@ CloudKit + com.apple.security.application-groups + + group.com.TablePro.TableProMobile + keychain-access-groups $(AppIdentifierPrefix)com.TablePro.shared diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 83fe6740f..a8d804512 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -12,6 +12,7 @@ struct ConnectionListView: View { @State private var showingAddConnection = false @State private var editingConnection: DatabaseConnection? @State private var selectedConnection: DatabaseConnection? + @State private var navigationPath = NavigationPath() @State private var showingGroupManagement = false @State private var showingTagManagement = false @State private var filterTagId: UUID? @@ -31,11 +32,19 @@ struct ConnectionListView: View { var body: some View { NavigationSplitView { - sidebar - .navigationTitle("Connections") - .navigationDestination(for: DatabaseConnection.self) { connection in - ConnectedView(connection: connection) - } + NavigationStack(path: $navigationPath) { + sidebar + .navigationTitle("Connections") + .navigationDestination(for: DatabaseConnection.self) { connection in + ConnectedView(connection: connection) + } + } + .onChange(of: appState.pendingConnectionId) { _, newId in + navigateToPendingConnection(newId) + } + .onAppear { + navigateToPendingConnection(appState.pendingConnectionId) + } .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { filterMenu @@ -236,6 +245,14 @@ struct ConnectionListView: View { } } + private func navigateToPendingConnection(_ id: UUID?) { + guard let id, + let connection = appState.connections.first(where: { $0.id == id }) else { return } + navigationPath.append(connection) + selectedConnection = connection + appState.pendingConnectionId = nil + } + private func connectionRow(_ connection: DatabaseConnection) -> some View { NavigationLink(value: connection) { ConnectionRow(connection: connection, tag: appState.tag(for: connection.tagId)) diff --git a/TableProMobile/TableProWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TableProMobile/TableProWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TableProMobile/TableProWidget/Assets.xcassets/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TableProMobile/TableProWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/TableProMobile/TableProWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift b/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift new file mode 100644 index 000000000..d083e2955 --- /dev/null +++ b/TableProMobile/TableProWidget/Helpers/DatabaseTypeStyle.swift @@ -0,0 +1,34 @@ +// +// DatabaseTypeStyle.swift +// TableProWidget +// + +import SwiftUI + +enum DatabaseTypeStyle { + static func iconName(for type: String) -> String { + switch type.lowercased() { + case "mysql", "mariadb": return "cylinder" + case "postgresql", "redshift": return "cylinder.split.1x2" + case "sqlite": return "doc" + case "redis": return "key" + case "mongodb": return "leaf" + case "clickhouse": return "bolt" + case "mssql": return "server.rack" + default: return "externaldrive" + } + } + + static func iconColor(for type: String) -> Color { + switch type.lowercased() { + case "mysql", "mariadb": return .orange + case "postgresql", "redshift": return .blue + case "sqlite": return .green + case "redis": return .red + case "mongodb": return .green + case "clickhouse": return .yellow + case "mssql": return .indigo + default: return .gray + } + } +} diff --git a/TableProMobile/TableProWidget/Info.plist b/TableProMobile/TableProWidget/Info.plist new file mode 100644 index 000000000..0f118fb75 --- /dev/null +++ b/TableProMobile/TableProWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/TableProMobile/TableProWidget/QuickConnectEntry.swift b/TableProMobile/TableProWidget/QuickConnectEntry.swift new file mode 100644 index 000000000..bc83bc221 --- /dev/null +++ b/TableProMobile/TableProWidget/QuickConnectEntry.swift @@ -0,0 +1,23 @@ +// +// QuickConnectEntry.swift +// TableProWidget +// + +import WidgetKit + +struct QuickConnectEntry: TimelineEntry { + let date: Date + let connections: [WidgetConnectionItem] + + static var placeholder: QuickConnectEntry { + QuickConnectEntry( + date: .now, + connections: [ + WidgetConnectionItem(id: UUID(), name: "Production", type: "postgresql", host: "db.example.com", port: 5432, sortOrder: 0), + WidgetConnectionItem(id: UUID(), name: "Local MySQL", type: "mysql", host: "localhost", port: 3306, sortOrder: 1), + WidgetConnectionItem(id: UUID(), name: "Redis Cache", type: "redis", host: "cache.local", port: 6379, sortOrder: 2), + WidgetConnectionItem(id: UUID(), name: "Analytics", type: "clickhouse", host: "ch.example.com", port: 8123, sortOrder: 3) + ] + ) + } +} diff --git a/TableProMobile/TableProWidget/QuickConnectProvider.swift b/TableProMobile/TableProWidget/QuickConnectProvider.swift new file mode 100644 index 000000000..9d900f900 --- /dev/null +++ b/TableProMobile/TableProWidget/QuickConnectProvider.swift @@ -0,0 +1,29 @@ +// +// QuickConnectProvider.swift +// TableProWidget +// + +import WidgetKit + +struct QuickConnectProvider: TimelineProvider { + func placeholder(in context: Context) -> QuickConnectEntry { + .placeholder + } + + func getSnapshot(in context: Context, completion: @escaping (QuickConnectEntry) -> Void) { + if context.isPreview { + completion(.placeholder) + return + } + let connections = SharedConnectionStore.read() + .sorted { $0.sortOrder < $1.sortOrder } + completion(QuickConnectEntry(date: .now, connections: connections)) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let connections = SharedConnectionStore.read() + .sorted { $0.sortOrder < $1.sortOrder } + let entry = QuickConnectEntry(date: .now, connections: connections) + completion(Timeline(entries: [entry], policy: .never)) + } +} diff --git a/TableProMobile/TableProWidget/QuickConnectWidget.swift b/TableProMobile/TableProWidget/QuickConnectWidget.swift new file mode 100644 index 000000000..0189dec75 --- /dev/null +++ b/TableProMobile/TableProWidget/QuickConnectWidget.swift @@ -0,0 +1,22 @@ +// +// QuickConnectWidget.swift +// TableProWidget +// + +import SwiftUI +import WidgetKit + +@main +struct QuickConnectWidget: Widget { + let kind = "com.TablePro.QuickConnect" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: QuickConnectProvider()) { entry in + QuickConnectEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Quick Connect") + .description("Quickly connect to your databases.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} diff --git a/TableProMobile/TableProWidget/Shared/SharedConnectionStore.swift b/TableProMobile/TableProWidget/Shared/SharedConnectionStore.swift new file mode 100644 index 000000000..2b5e7837e --- /dev/null +++ b/TableProMobile/TableProWidget/Shared/SharedConnectionStore.swift @@ -0,0 +1,32 @@ +// +// SharedConnectionStore.swift +// TableProWidget +// + +import Foundation + +enum SharedConnectionStore { + private static let appGroupId = "group.com.TablePro.TableProMobile" + private static let fileName = "widget-connections.json" + + private static var fileURL: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupId)? + .appendingPathComponent(fileName) + } + + static func write(_ items: [WidgetConnectionItem]) { + guard let url = fileURL, + let data = try? JSONEncoder().encode(items) else { return } + try? data.write(to: url, options: .atomic) + } + + static func read() -> [WidgetConnectionItem] { + guard let url = fileURL, + let data = try? Data(contentsOf: url), + let items = try? JSONDecoder().decode([WidgetConnectionItem].self, from: data) else { + return [] + } + return items + } +} diff --git a/TableProMobile/TableProWidget/Shared/WidgetConnectionItem.swift b/TableProMobile/TableProWidget/Shared/WidgetConnectionItem.swift new file mode 100644 index 000000000..92ec23d02 --- /dev/null +++ b/TableProMobile/TableProWidget/Shared/WidgetConnectionItem.swift @@ -0,0 +1,15 @@ +// +// WidgetConnectionItem.swift +// TableProWidget +// + +import Foundation + +struct WidgetConnectionItem: Codable, Identifiable, Hashable { + let id: UUID + let name: String + let type: String + let host: String + let port: Int + let sortOrder: Int +} diff --git a/TableProMobile/TableProWidget/TableProWidget.entitlements b/TableProMobile/TableProWidget/TableProWidget.entitlements new file mode 100644 index 000000000..930e4ce9b --- /dev/null +++ b/TableProMobile/TableProWidget/TableProWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.TablePro.TableProMobile + + + diff --git a/TableProMobile/TableProWidget/Views/MediumWidgetView.swift b/TableProMobile/TableProWidget/Views/MediumWidgetView.swift new file mode 100644 index 000000000..ad37538d1 --- /dev/null +++ b/TableProMobile/TableProWidget/Views/MediumWidgetView.swift @@ -0,0 +1,59 @@ +// +// MediumWidgetView.swift +// TableProWidget +// + +import SwiftUI + +struct MediumWidgetView: View { + let connections: [WidgetConnectionItem] + + private var displayedConnections: [WidgetConnectionItem] { + Array(connections.prefix(4)) + } + + private let columns = [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8) + ] + + var body: some View { + if connections.isEmpty { + VStack(spacing: 8) { + Image(systemName: "server.rack") + .font(.title2) + .foregroundStyle(.secondary) + Text("Add a connection in TablePro") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(displayedConnections) { connection in + Link(destination: URL(string: "tablepro://connect/\(connection.id.uuidString)")!) { + HStack(spacing: 8) { + Image(systemName: DatabaseTypeStyle.iconName(for: connection.type)) + .font(.callout) + .foregroundStyle(DatabaseTypeStyle.iconColor(for: connection.type)) + .frame(width: 28, height: 28) + .background(DatabaseTypeStyle.iconColor(for: connection.type).opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + Text(connection.name) + .font(.caption) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(.fill.quaternary) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + } +} diff --git a/TableProMobile/TableProWidget/Views/QuickConnectEntryView.swift b/TableProMobile/TableProWidget/Views/QuickConnectEntryView.swift new file mode 100644 index 000000000..3f70db162 --- /dev/null +++ b/TableProMobile/TableProWidget/Views/QuickConnectEntryView.swift @@ -0,0 +1,23 @@ +// +// QuickConnectEntryView.swift +// TableProWidget +// + +import SwiftUI +import WidgetKit + +struct QuickConnectEntryView: View { + @Environment(\.widgetFamily) private var family + let entry: QuickConnectEntry + + var body: some View { + switch family { + case .systemSmall: + SmallWidgetView(connections: entry.connections) + case .systemMedium: + MediumWidgetView(connections: entry.connections) + default: + SmallWidgetView(connections: entry.connections) + } + } +} diff --git a/TableProMobile/TableProWidget/Views/SmallWidgetView.swift b/TableProMobile/TableProWidget/Views/SmallWidgetView.swift new file mode 100644 index 000000000..a30d23d33 --- /dev/null +++ b/TableProMobile/TableProWidget/Views/SmallWidgetView.swift @@ -0,0 +1,50 @@ +// +// SmallWidgetView.swift +// TableProWidget +// + +import SwiftUI +import WidgetKit + +struct SmallWidgetView: View { + let connections: [WidgetConnectionItem] + + var body: some View { + if let connection = connections.first { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: DatabaseTypeStyle.iconName(for: connection.type)) + .font(.title2) + .foregroundStyle(DatabaseTypeStyle.iconColor(for: connection.type)) + .frame(width: 36, height: 36) + .background(DatabaseTypeStyle.iconColor(for: connection.type).opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Spacer() + + VStack(alignment: .leading, spacing: 2) { + Text(connection.name) + .font(.headline) + .lineLimit(1) + + Text(verbatim: "\(connection.host):\(connection.port)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .widgetURL(URL(string: "tablepro://connect/\(connection.id.uuidString)")) + } else { + VStack(spacing: 8) { + Image(systemName: "server.rack") + .font(.title2) + .foregroundStyle(.secondary) + Text("Add a connection in TablePro") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/TableProMobile/TableProWidgetExtension.entitlements b/TableProMobile/TableProWidgetExtension.entitlements new file mode 100644 index 000000000..2eb7e333a --- /dev/null +++ b/TableProMobile/TableProWidgetExtension.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.application-groups + + +