From dc44e115ece6ac40d615cdbae81cefdcc8477513 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 15:31:57 +0700 Subject: [PATCH 1/7] feat: add connection groups and tags to iOS app --- .../TableProModels/ConnectionColor.swift | 16 ++ .../TableProModels/ConnectionGroup.swift | 23 +- .../TableProModels/ConnectionTag.swift | 79 +++++- .../TableProModels/DatabaseConnection.swift | 3 + .../TableProSync/SyncRecordMapper.swift | 78 ++++- .../Sources/TableProSync/SyncRecordType.swift | 1 + TableProMobile/TableProMobile/AppState.swift | 140 ++++++++- .../Helpers/GroupPersistence.swift | 31 ++ .../Helpers/TagPersistence.swift | 32 +++ .../Sync/IOSSyncCoordinator.swift | 228 ++++++++++++--- .../TableProMobile/TableProMobileApp.swift | 8 +- .../Components/ConnectionColorPicker.swift | 49 ++++ .../Views/Components/GroupFormSheet.swift | 55 ++++ .../Views/Components/TagFormSheet.swift | 55 ++++ .../Views/ConnectionFormView.swift | 40 ++- .../Views/ConnectionListView.swift | 266 ++++++++++++++---- .../Views/GroupManagementView.swift | 95 +++++++ .../TableProMobile/Views/OnboardingView.swift | 6 +- .../Views/TagManagementView.swift | 84 ++++++ 19 files changed, 1179 insertions(+), 110 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProModels/ConnectionColor.swift create mode 100644 TableProMobile/TableProMobile/Helpers/GroupPersistence.swift create mode 100644 TableProMobile/TableProMobile/Helpers/TagPersistence.swift create mode 100644 TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift create mode 100644 TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift create mode 100644 TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift create mode 100644 TableProMobile/TableProMobile/Views/GroupManagementView.swift create mode 100644 TableProMobile/TableProMobile/Views/TagManagementView.swift diff --git a/Packages/TableProCore/Sources/TableProModels/ConnectionColor.swift b/Packages/TableProCore/Sources/TableProModels/ConnectionColor.swift new file mode 100644 index 000000000..2b90f56bc --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/ConnectionColor.swift @@ -0,0 +1,16 @@ +import Foundation + +public enum ConnectionColor: String, CaseIterable, Identifiable, Codable, Sendable { + case none = "None" + case red = "Red" + case orange = "Orange" + case yellow = "Yellow" + case green = "Green" + case blue = "Blue" + case purple = "Purple" + case pink = "Pink" + case gray = "Gray" + + public var id: String { rawValue } + public var isDefault: Bool { self == .none } +} diff --git a/Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift b/Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift index c538405c2..39aa4e74e 100644 --- a/Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift +++ b/Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift @@ -1,17 +1,36 @@ import Foundation -public struct ConnectionGroup: Identifiable, Codable, Sendable { +public struct ConnectionGroup: Identifiable, Codable, Hashable, Sendable { public var id: UUID public var name: String public var sortOrder: Int + public var color: ConnectionColor + public var parentId: UUID? public init( id: UUID = UUID(), name: String = "", - sortOrder: Int = 0 + sortOrder: Int = 0, + color: ConnectionColor = .none, + parentId: UUID? = nil ) { self.id = id self.name = name self.sortOrder = sortOrder + self.color = color + self.parentId = parentId + } + + private enum CodingKeys: String, CodingKey { + case id, name, sortOrder, color, parentId + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + sortOrder = try container.decode(Int.self, forKey: .sortOrder) + color = try container.decodeIfPresent(ConnectionColor.self, forKey: .color) ?? .none + parentId = try container.decodeIfPresent(UUID.self, forKey: .parentId) } } diff --git a/Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift b/Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift index a0a347640..4b34916f9 100644 --- a/Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift +++ b/Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift @@ -1,17 +1,88 @@ import Foundation -public struct ConnectionTag: Identifiable, Codable, Sendable { +public struct ConnectionTag: Identifiable, Codable, Hashable, Sendable { public var id: UUID public var name: String - public var colorHex: String + public var color: ConnectionColor + public var isPreset: Bool public init( id: UUID = UUID(), name: String = "", - colorHex: String = "#808080" + isPreset: Bool = false, + color: ConnectionColor = .gray ) { self.id = id self.name = name - self.colorHex = colorHex + self.isPreset = isPreset + self.color = color } + + private enum CodingKeys: String, CodingKey { + case id, name, color, colorHex, isPreset + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + isPreset = try container.decodeIfPresent(Bool.self, forKey: .isPreset) ?? false + + if let color = try container.decodeIfPresent(ConnectionColor.self, forKey: .color) { + self.color = color + } else if let hex = try container.decodeIfPresent(String.self, forKey: .colorHex) { + self.color = ConnectionTag.colorFromHex(hex) + } else { + self.color = .gray + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(color, forKey: .color) + try container.encode(isPreset, forKey: .isPreset) + } + + private static func colorFromHex(_ hex: String) -> ConnectionColor { + let normalized = hex.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "#")) + switch normalized { + case "ff0000", "ff3b30", "cc0000": return .red + case "ff9500", "ff8c00", "ffa500": return .orange + case "ffcc00", "ffff00", "ffd700": return .yellow + case "34c759", "28cd41", "00ff00", "008000": return .green + case "007aff", "0000ff", "5856d6": return .blue + case "af52de", "800080", "9b59b6": return .purple + case "ff2d55", "ff69b4", "ffc0cb": return .pink + default: return .gray + } + } + + public static let presets: [ConnectionTag] = [ + ConnectionTag( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + name: "local", + isPreset: true, + color: .green + ), + ConnectionTag( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + name: "development", + isPreset: true, + color: .blue + ), + ConnectionTag( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000003")!, + name: "production", + isPreset: true, + color: .red + ), + ConnectionTag( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000004")!, + name: "testing", + isPreset: true, + color: .orange + ) + ] } diff --git a/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift b/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift index d8ab6a6b1..f76b6ea29 100644 --- a/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift +++ b/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift @@ -20,6 +20,7 @@ public struct DatabaseConnection: Identifiable, Codable, Hashable, Sendable { public var sslConfiguration: SSLConfiguration? public var groupId: UUID? + public var tagId: UUID? public var sortOrder: Int public init( @@ -39,6 +40,7 @@ public struct DatabaseConnection: Identifiable, Codable, Hashable, Sendable { sslEnabled: Bool = false, sslConfiguration: SSLConfiguration? = nil, groupId: UUID? = nil, + tagId: UUID? = nil, sortOrder: Int = 0 ) { self.id = id @@ -57,6 +59,7 @@ public struct DatabaseConnection: Identifiable, Codable, Hashable, Sendable { self.sslEnabled = sslEnabled self.sslConfiguration = sslConfiguration self.groupId = groupId + self.tagId = tagId self.sortOrder = sortOrder } } diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index c24ae8e8a..2d39850f2 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -18,6 +18,7 @@ public enum SyncRecordMapper { switch type { case .connection: recordName = "Connection_\(id)" case .group: recordName = "Group_\(id)" + case .tag: recordName = "Tag_\(id)" } return CKRecord.ID(recordName: recordName, zoneID: zone) } @@ -47,6 +48,9 @@ public enum SyncRecordMapper { if let groupId = connection.groupId { record["groupId"] = groupId.uuidString as CKRecordValue } + if let tagId = connection.tagId { + record["tagId"] = tagId.uuidString as CKRecordValue + } if let queryTimeout = connection.queryTimeoutSeconds { record["queryTimeoutSeconds"] = Int64(queryTimeout) as CKRecordValue } @@ -104,6 +108,7 @@ public enum SyncRecordMapper { let username = record["username"] as? String ?? "" let colorTag = record["color"] as? String ?? record["colorTag"] as? String let groupId = (record["groupId"] as? String).flatMap { UUID(uuidString: $0) } + let tagId = (record["tagId"] as? String).flatMap { UUID(uuidString: $0) } let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0 let isReadOnly = (record["isReadOnly"] as? Int64 ?? 0) != 0 let queryTimeout = (record["queryTimeoutSeconds"] as? Int64).map { Int($0) } @@ -153,6 +158,7 @@ public enum SyncRecordMapper { sslEnabled: sslEnabled, sslConfiguration: sslConfig, groupId: groupId, + tagId: tagId, sortOrder: sortOrder ) } @@ -186,6 +192,12 @@ public enum SyncRecordMapper { record["groupId"] = nil } + if let tagId = connection.tagId { + record["tagId"] = tagId.uuidString as CKRecordValue + } else { + record["tagId"] = nil + } + if let queryTimeout = connection.queryTimeoutSeconds { record["queryTimeoutSeconds"] = Int64(queryTimeout) as CKRecordValue } else { @@ -229,6 +241,10 @@ public enum SyncRecordMapper { record["groupId"] = group.id.uuidString as CKRecordValue record["name"] = group.name as CKRecordValue + record["color"] = group.color.rawValue as CKRecordValue + if let parentId = group.parentId { + record["parentId"] = parentId.uuidString as CKRecordValue + } record["sortOrder"] = Int64(group.sortOrder) as CKRecordValue record["modifiedAtLocal"] = Date() as CKRecordValue record["schemaVersion"] = schemaVersion as CKRecordValue @@ -248,7 +264,67 @@ public enum SyncRecordMapper { } let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0 + let color = (record["color"] as? String).flatMap { ConnectionColor(rawValue: $0) } ?? .none + let parentId = (record["parentId"] as? String).flatMap { UUID(uuidString: $0) } + + return ConnectionGroup(id: id, name: name, sortOrder: sortOrder, color: color, parentId: parentId) + } + + // MARK: - Update Existing CKRecord with Group + + public static func updateRecord(_ record: CKRecord, with group: ConnectionGroup) { + record["groupId"] = group.id.uuidString as CKRecordValue + record["name"] = group.name as CKRecordValue + record["color"] = group.color.rawValue as CKRecordValue + if let parentId = group.parentId { + record["parentId"] = parentId.uuidString as CKRecordValue + } else { + record["parentId"] = nil + } + record["sortOrder"] = Int64(group.sortOrder) as CKRecordValue + record["modifiedAtLocal"] = Date() as CKRecordValue + } + + // MARK: - Tag -> CKRecord + + public static func toRecord(_ tag: ConnectionTag, zoneID: CKRecordZone.ID) -> CKRecord { + let id = recordID(type: .tag, id: tag.id.uuidString, in: zoneID) + let record = CKRecord(recordType: SyncRecordType.tag.rawValue, recordID: id) - return ConnectionGroup(id: id, name: name, sortOrder: sortOrder) + record["tagId"] = tag.id.uuidString as CKRecordValue + record["name"] = tag.name as CKRecordValue + record["isPreset"] = Int64(tag.isPreset ? 1 : 0) as CKRecordValue + record["color"] = tag.color.rawValue as CKRecordValue + record["modifiedAtLocal"] = Date() as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + return record + } + + // MARK: - CKRecord -> Tag + + public static func toTag(_ record: CKRecord) -> ConnectionTag? { + guard let tagIdStr = record["tagId"] as? String, + let tagId = UUID(uuidString: tagIdStr), + let name = record["name"] as? String + else { + logger.warning("Failed to decode tag from CKRecord: missing required fields") + return nil + } + + let isPreset = (record["isPreset"] as? Int64 ?? 0) != 0 + let color = (record["color"] as? String).flatMap { ConnectionColor(rawValue: $0) } ?? .gray + + return ConnectionTag(id: tagId, name: name, isPreset: isPreset, color: color) + } + + // MARK: - Update Existing CKRecord with Tag + + public static func updateRecord(_ record: CKRecord, with tag: ConnectionTag) { + record["tagId"] = tag.id.uuidString as CKRecordValue + record["name"] = tag.name as CKRecordValue + record["isPreset"] = Int64(tag.isPreset ? 1 : 0) as CKRecordValue + record["color"] = tag.color.rawValue as CKRecordValue + record["modifiedAtLocal"] = Date() as CKRecordValue } } diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordType.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordType.swift index 52e22f497..ddbba073d 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncRecordType.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordType.swift @@ -3,4 +3,5 @@ import Foundation public enum SyncRecordType: String, CaseIterable, Sendable { case connection = "Connection" case group = "ConnectionGroup" + case tag = "ConnectionTag" } diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 2dc30f20f..a7a0c4050 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -11,12 +11,16 @@ import TableProModels @MainActor @Observable final class AppState { var connections: [DatabaseConnection] = [] + var groups: [ConnectionGroup] = [] + var tags: [ConnectionTag] = [] let connectionManager: ConnectionManager let syncCoordinator = IOSSyncCoordinator() let sshProvider: IOSSSHProvider let secureStore: KeychainSecureStore private let storage = ConnectionPersistence() + private let groupStorage = GroupPersistence() + private let tagStorage = TagPersistence() init() { let driverFactory = IOSDriverFactory() @@ -30,6 +34,8 @@ final class AppState { sshProvider: sshProvider ) connections = storage.load() + groups = groupStorage.load() + tags = tagStorage.load() secureStore.cleanOrphanedCredentials(validConnectionIds: Set(connections.map(\.id))) syncCoordinator.onConnectionsChanged = { [weak self] merged in @@ -37,13 +43,31 @@ final class AppState { self.connections = merged self.storage.save(merged) } + + syncCoordinator.onGroupsChanged = { [weak self] merged in + guard let self else { return } + self.groups = merged + self.groupStorage.save(merged) + } + + syncCoordinator.onTagsChanged = { [weak self] merged in + guard let self else { return } + self.tags = merged + self.tagStorage.save(merged) + } } + // MARK: - Connections + func addConnection(_ connection: DatabaseConnection) { connections.append(connection) storage.save(connections) syncCoordinator.markDirty(connection.id) - syncCoordinator.scheduleSyncAfterChange(localConnections: connections) + syncCoordinator.scheduleSyncAfterChange( + localConnections: connections, + localGroups: groups, + localTags: tags + ) } func updateConnection(_ connection: DatabaseConnection) { @@ -51,7 +75,11 @@ final class AppState { connections[index] = connection storage.save(connections) syncCoordinator.markDirty(connection.id) - syncCoordinator.scheduleSyncAfterChange(localConnections: connections) + syncCoordinator.scheduleSyncAfterChange( + localConnections: connections, + localGroups: groups, + localTags: tags + ) } } @@ -67,7 +95,113 @@ final class AppState { try? secureStore.delete(forKey: "com.TablePro.sshkeydata.\(connection.id.uuidString)") storage.save(connections) syncCoordinator.markDeleted(connection.id) - syncCoordinator.scheduleSyncAfterChange(localConnections: connections) + syncCoordinator.scheduleSyncAfterChange( + localConnections: connections, + localGroups: groups, + localTags: tags + ) + } + + // MARK: - Groups + + func addGroup(_ group: ConnectionGroup) { + groups.append(group) + groupStorage.save(groups) + syncCoordinator.markDirtyGroup(group.id) + syncCoordinator.scheduleSyncAfterChange( + localConnections: connections, + localGroups: groups, + localTags: tags + ) + } + + func updateGroup(_ group: ConnectionGroup) { + if let index = groups.firstIndex(where: { $0.id == group.id }) { + groups[index] = group + groupStorage.save(groups) + syncCoordinator.markDirtyGroup(group.id) + syncCoordinator.scheduleSyncAfterChange( + localConnections: connections, + localGroups: groups, + localTags: tags + ) + } + } + + func deleteGroup(_ groupId: UUID) { + groups.removeAll { $0.id == groupId } + groupStorage.save(groups) + + for index in connections.indices where connections[index].groupId == groupId { + connections[index].groupId = nil + syncCoordinator.markDirty(connections[index].id) + } + storage.save(connections) + + syncCoordinator.markDeletedGroup(groupId) + syncCoordinator.scheduleSyncAfterChange( + localConnections: connections, + localGroups: groups, + localTags: tags + ) + } + + // MARK: - Tags + + func addTag(_ tag: ConnectionTag) { + tags.append(tag) + tagStorage.save(tags) + syncCoordinator.markDirtyTag(tag.id) + syncCoordinator.scheduleSyncAfterChange( + localConnections: connections, + localGroups: groups, + localTags: tags + ) + } + + func updateTag(_ tag: ConnectionTag) { + if let index = tags.firstIndex(where: { $0.id == tag.id }) { + tags[index] = tag + tagStorage.save(tags) + syncCoordinator.markDirtyTag(tag.id) + syncCoordinator.scheduleSyncAfterChange( + localConnections: connections, + localGroups: groups, + localTags: tags + ) + } + } + + func deleteTag(_ tagId: UUID) { + guard let tag = tags.first(where: { $0.id == tagId }), !tag.isPreset else { return } + + tags.removeAll { $0.id == tagId } + tagStorage.save(tags) + + for index in connections.indices where connections[index].tagId == tagId { + connections[index].tagId = nil + syncCoordinator.markDirty(connections[index].id) + } + storage.save(connections) + + syncCoordinator.markDeletedTag(tagId) + syncCoordinator.scheduleSyncAfterChange( + localConnections: connections, + localGroups: groups, + localTags: tags + ) + } + + // MARK: - Helpers + + func group(for id: UUID?) -> ConnectionGroup? { + guard let id else { return nil } + return groups.first { $0.id == id } + } + + func tag(for id: UUID?) -> ConnectionTag? { + guard let id else { return nil } + return tags.first { $0.id == id } } } diff --git a/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift b/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift new file mode 100644 index 000000000..7b7f82146 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift @@ -0,0 +1,31 @@ +// +// GroupPersistence.swift +// TableProMobile +// + +import Foundation +import TableProModels + +struct GroupPersistence { + private var fileURL: URL? { + guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let appDir = dir.appendingPathComponent("TableProMobile", isDirectory: true) + try? FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true) + return appDir.appendingPathComponent("groups.json") + } + + func save(_ groups: [ConnectionGroup]) { + guard let fileURL, let data = try? JSONEncoder().encode(groups) else { return } + try? data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + } + + func load() -> [ConnectionGroup] { + guard let fileURL, let data = try? Data(contentsOf: fileURL), + let groups = try? JSONDecoder().decode([ConnectionGroup].self, from: data) else { + return [] + } + return groups + } +} diff --git a/TableProMobile/TableProMobile/Helpers/TagPersistence.swift b/TableProMobile/TableProMobile/Helpers/TagPersistence.swift new file mode 100644 index 000000000..0fb2d37aa --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/TagPersistence.swift @@ -0,0 +1,32 @@ +// +// TagPersistence.swift +// TableProMobile +// + +import Foundation +import TableProModels + +struct TagPersistence { + private var fileURL: URL? { + guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let appDir = dir.appendingPathComponent("TableProMobile", isDirectory: true) + try? FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true) + return appDir.appendingPathComponent("tags.json") + } + + func save(_ tags: [ConnectionTag]) { + guard let fileURL, let data = try? JSONEncoder().encode(tags) else { return } + try? data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + } + + func load() -> [ConnectionTag] { + guard let fileURL, let data = try? Data(contentsOf: fileURL), + let tags = try? JSONDecoder().decode([ConnectionTag].self, from: data), + !tags.isEmpty else { + return ConnectionTag.presets + } + return tags + } +} diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index a5702b117..2ecd7105d 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -20,6 +20,8 @@ final class IOSSyncCoordinator { private var engine: CloudKitSyncEngine? private let metadata = SyncMetadataStorage() private var cachedRecords: [UUID: CKRecord] = [:] + private var cachedGroupRecords: [UUID: CKRecord] = [:] + private var cachedTagRecords: [UUID: CKRecord] = [:] private func getEngine() -> CloudKitSyncEngine { if let engine { return engine } @@ -29,12 +31,18 @@ final class IOSSyncCoordinator { } private var debounceTask: Task? - // Callback to update AppState connections var onConnectionsChanged: (([DatabaseConnection]) -> Void)? + var onGroupsChanged: (([ConnectionGroup]) -> Void)? + var onTagsChanged: (([ConnectionTag]) -> Void)? // MARK: - Sync - func sync(localConnections: [DatabaseConnection], isRetry: Bool = false) async { + func sync( + localConnections: [DatabaseConnection], + localGroups: [ConnectionGroup] = [], + localTags: [ConnectionTag] = [], + isRetry: Bool = false + ) async { guard isRetry || status != .syncing else { return } status = .syncing @@ -47,14 +55,24 @@ final class IOSSyncCoordinator { try await getEngine().ensureZoneExists() let remoteChanges = try await pull() - Self.logger.info("Pulled \(remoteChanges.changed.count) changed, \(remoteChanges.deletedIDs.count) deleted") + let connCount = remoteChanges.changedConnections.count + let groupCount = remoteChanges.changedGroups.count + let tagCount = remoteChanges.changedTags.count + Self.logger.info("Pulled \(connCount) connections, \(groupCount) groups, \(tagCount) tags") - // Merge before push: incorporate remote changes into local state first, - // then push the merged result so the remote gets the correct final state. - let merged = merge(local: localConnections, remote: remoteChanges) - Self.logger.info("Merged: local=\(localConnections.count), result=\(merged.count)") - try await push(localConnections: merged) - onConnectionsChanged?(merged) + let mergedConnections = mergeConnections(local: localConnections, remote: remoteChanges) + let mergedGroups = mergeGroups(local: localGroups, remote: remoteChanges) + let mergedTags = mergeTags(local: localTags, remote: remoteChanges) + + try await push( + localConnections: mergedConnections, + localGroups: mergedGroups, + localTags: mergedTags + ) + + onConnectionsChanged?(mergedConnections) + onGroupsChanged?(mergedGroups) + onTagsChanged?(mergedTags) metadata.lastSyncDate = Date() lastSyncDate = metadata.lastSyncDate @@ -65,12 +83,19 @@ final class IOSSyncCoordinator { return } metadata.saveToken(nil) - await sync(localConnections: localConnections, isRetry: true) + await sync( + localConnections: localConnections, + localGroups: localGroups, + localTags: localTags, + isRetry: true + ) } catch { status = .error(error.localizedDescription) } } + // MARK: - Dirty / Tombstone Tracking + func markDirty(_ connectionId: UUID) { metadata.markDirty(connectionId.uuidString, type: .connection) } @@ -79,51 +104,118 @@ final class IOSSyncCoordinator { metadata.addTombstone(connectionId.uuidString, type: .connection) } - func scheduleSyncAfterChange(localConnections: [DatabaseConnection]) { + func markDirtyGroup(_ groupId: UUID) { + metadata.markDirty(groupId.uuidString, type: .group) + } + + func markDeletedGroup(_ groupId: UUID) { + metadata.addTombstone(groupId.uuidString, type: .group) + } + + func markDirtyTag(_ tagId: UUID) { + metadata.markDirty(tagId.uuidString, type: .tag) + } + + func markDeletedTag(_ tagId: UUID) { + metadata.addTombstone(tagId.uuidString, type: .tag) + } + + func scheduleSyncAfterChange( + localConnections: [DatabaseConnection], + localGroups: [ConnectionGroup] = [], + localTags: [ConnectionTag] = [] + ) { debounceTask?.cancel() debounceTask = Task { try? await Task.sleep(nanoseconds: 2_000_000_000) guard !Task.isCancelled else { return } - await sync(localConnections: localConnections) + await sync( + localConnections: localConnections, + localGroups: localGroups, + localTags: localTags + ) } } // MARK: - Push - private func push(localConnections: [DatabaseConnection]) async throws { + private func push( + localConnections: [DatabaseConnection], + localGroups: [ConnectionGroup], + localTags: [ConnectionTag] + ) async throws { let zoneID = await getEngine().currentZoneID + var allRecords: [CKRecord] = [] + var allDeletions: [CKRecord.ID] = [] // Dirty connections - let dirtyIDs = metadata.dirtyIDs(for: .connection) - let dirtyRecords = localConnections - .filter { dirtyIDs.contains($0.id.uuidString) } - .map { connection -> CKRecord in - if let existing = cachedRecords[connection.id] { - SyncRecordMapper.updateRecord(existing, with: connection) - return existing - } else { - return SyncRecordMapper.toRecord(connection, zoneID: zoneID) - } + let dirtyConnIDs = metadata.dirtyIDs(for: .connection) + for connection in localConnections where dirtyConnIDs.contains(connection.id.uuidString) { + if let existing = cachedRecords[connection.id] { + SyncRecordMapper.updateRecord(existing, with: connection) + allRecords.append(existing) + } else { + allRecords.append(SyncRecordMapper.toRecord(connection, zoneID: zoneID)) } + } - // Tombstones - let tombstones = metadata.tombstones(for: .connection) - let deletions = tombstones.map { - CKRecord.ID(recordName: "Connection_\($0.id)", zoneID: zoneID) + // Connection tombstones + for tombstone in metadata.tombstones(for: .connection) { + allDeletions.append(CKRecord.ID(recordName: "Connection_\(tombstone.id)", zoneID: zoneID)) } - guard !dirtyRecords.isEmpty || !deletions.isEmpty else { return } + // Dirty groups + let dirtyGroupIDs = metadata.dirtyIDs(for: .group) + for group in localGroups where dirtyGroupIDs.contains(group.id.uuidString) { + if let existing = cachedGroupRecords[group.id] { + SyncRecordMapper.updateRecord(existing, with: group) + allRecords.append(existing) + } else { + allRecords.append(SyncRecordMapper.toRecord(group, zoneID: zoneID)) + } + } + + // Group tombstones + for tombstone in metadata.tombstones(for: .group) { + allDeletions.append(CKRecord.ID(recordName: "Group_\(tombstone.id)", zoneID: zoneID)) + } + + // Dirty tags + let dirtyTagIDs = metadata.dirtyIDs(for: .tag) + for tag in localTags where dirtyTagIDs.contains(tag.id.uuidString) { + if let existing = cachedTagRecords[tag.id] { + SyncRecordMapper.updateRecord(existing, with: tag) + allRecords.append(existing) + } else { + allRecords.append(SyncRecordMapper.toRecord(tag, zoneID: zoneID)) + } + } + + // Tag tombstones + for tombstone in metadata.tombstones(for: .tag) { + allDeletions.append(CKRecord.ID(recordName: "Tag_\(tombstone.id)", zoneID: zoneID)) + } - try await getEngine().push(records: dirtyRecords, deletions: deletions) + guard !allRecords.isEmpty || !allDeletions.isEmpty else { return } + + try await getEngine().push(records: allRecords, deletions: allDeletions) metadata.clearDirty(type: .connection) metadata.clearTombstones(type: .connection) + metadata.clearDirty(type: .group) + metadata.clearTombstones(type: .group) + metadata.clearDirty(type: .tag) + metadata.clearTombstones(type: .tag) } // MARK: - Pull private struct PullChanges { - var changed: [DatabaseConnection] = [] - var deletedIDs: Set = [] + var changedConnections: [DatabaseConnection] = [] + var deletedConnectionIDs: Set = [] + var changedGroups: [ConnectionGroup] = [] + var deletedGroupIDs: Set = [] + var changedTags: [ConnectionTag] = [] + var deletedTagIDs: Set = [] } private func pull() async throws -> PullChanges { @@ -137,11 +229,24 @@ final class IOSSyncCoordinator { var changes = PullChanges() for record in result.changedRecords { - if record.recordType == SyncRecordType.connection.rawValue { + switch record.recordType { + case SyncRecordType.connection.rawValue: if let connection = SyncRecordMapper.toConnection(record) { cachedRecords[connection.id] = record - changes.changed.append(connection) + changes.changedConnections.append(connection) + } + case SyncRecordType.group.rawValue: + if let group = SyncRecordMapper.toGroup(record) { + cachedGroupRecords[group.id] = record + changes.changedGroups.append(group) + } + case SyncRecordType.tag.rawValue: + if let tag = SyncRecordMapper.toTag(record) { + cachedTagRecords[tag.id] = record + changes.changedTags.append(tag) } + default: + break } } @@ -150,7 +255,17 @@ final class IOSSyncCoordinator { if name.hasPrefix("Connection_") { let uuidStr = String(name.dropFirst("Connection_".count)) if let uuid = UUID(uuidString: uuidStr) { - changes.deletedIDs.insert(uuid) + changes.deletedConnectionIDs.insert(uuid) + } + } else if name.hasPrefix("Group_") { + let uuidStr = String(name.dropFirst("Group_".count)) + if let uuid = UUID(uuidString: uuidStr) { + changes.deletedGroupIDs.insert(uuid) + } + } else if name.hasPrefix("Tag_") { + let uuidStr = String(name.dropFirst("Tag_".count)) + if let uuid = UUID(uuidString: uuidStr) { + changes.deletedTagIDs.insert(uuid) } } } @@ -160,23 +275,54 @@ final class IOSSyncCoordinator { // MARK: - Merge (last-write-wins) - private func merge(local: [DatabaseConnection], remote: PullChanges) -> [DatabaseConnection] { - // Remove deleted connections - var result = local.filter { !remote.deletedIDs.contains($0.id) } - + private func mergeConnections(local: [DatabaseConnection], remote: PullChanges) -> [DatabaseConnection] { + var result = local.filter { !remote.deletedConnectionIDs.contains($0.id) } let localMap = Dictionary(uniqueKeysWithValues: result.map { ($0.id, $0) }) - for remoteConn in remote.changed { + for remoteConn in remote.changedConnections { if localMap[remoteConn.id] != nil { if let index = result.firstIndex(where: { $0.id == remoteConn.id }) { result[index] = remoteConn } - } else if !remote.deletedIDs.contains(remoteConn.id) { + } else if !remote.deletedConnectionIDs.contains(remoteConn.id) { result.append(remoteConn) } } return result } -} + private func mergeGroups(local: [ConnectionGroup], remote: PullChanges) -> [ConnectionGroup] { + var result = local.filter { !remote.deletedGroupIDs.contains($0.id) } + let localMap = Dictionary(uniqueKeysWithValues: result.map { ($0.id, $0) }) + + for remoteGroup in remote.changedGroups { + if localMap[remoteGroup.id] != nil { + if let index = result.firstIndex(where: { $0.id == remoteGroup.id }) { + result[index] = remoteGroup + } + } else if !remote.deletedGroupIDs.contains(remoteGroup.id) { + result.append(remoteGroup) + } + } + + return result + } + + private func mergeTags(local: [ConnectionTag], remote: PullChanges) -> [ConnectionTag] { + var result = local.filter { !remote.deletedTagIDs.contains($0.id) } + let localMap = Dictionary(uniqueKeysWithValues: result.map { ($0.id, $0) }) + + for remoteTag in remote.changedTags { + if localMap[remoteTag.id] != nil { + if let index = result.firstIndex(where: { $0.id == remoteTag.id }) { + result[index] = remoteTag + } + } else if !remote.deletedTagIDs.contains(remoteTag.id) { + result.append(remoteTag) + } + } + + return result + } +} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 7a9e69541..6b3ae016b 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -27,7 +27,13 @@ struct TableProMobileApp: App { switch phase { case .active: syncTask?.cancel() - syncTask = Task { await appState.syncCoordinator.sync(localConnections: appState.connections) } + syncTask = Task { + await appState.syncCoordinator.sync( + localConnections: appState.connections, + localGroups: appState.groups, + localTags: appState.tags + ) + } case .background: Task { await appState.connectionManager.disconnectAll() } default: diff --git a/TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift b/TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift new file mode 100644 index 000000000..1f64d7845 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift @@ -0,0 +1,49 @@ +// +// ConnectionColorPicker.swift +// TableProMobile +// + +import SwiftUI +import TableProModels + +struct ConnectionColorPicker: View { + @Binding var selection: ConnectionColor + + var body: some View { + HStack(spacing: 12) { + ForEach(ConnectionColor.allCases) { color in + Button { + selection = color + } label: { + ZStack { + Circle() + .fill(Self.swiftUIColor(for: color)) + .frame(width: 28, height: 28) + + if selection == color { + Image(systemName: "checkmark") + .font(.caption.bold()) + .foregroundStyle(.white) + } + } + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + + static func swiftUIColor(for color: ConnectionColor) -> Color { + switch color { + case .none: return .gray + case .red: return .red + case .orange: return .orange + case .yellow: return .yellow + case .green: return .green + case .blue: return .blue + case .purple: return .purple + case .pink: return .pink + case .gray: return Color(.systemGray3) + } + } +} diff --git a/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift b/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift new file mode 100644 index 000000000..452059387 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/Components/GroupFormSheet.swift @@ -0,0 +1,55 @@ +// +// GroupFormSheet.swift +// TableProMobile +// + +import SwiftUI +import TableProModels + +struct GroupFormSheet: View { + @Environment(\.dismiss) private var dismiss + + @State private var name: String + @State private var color: ConnectionColor + private let existingGroup: ConnectionGroup? + var onSave: (ConnectionGroup) -> Void + + init(editing group: ConnectionGroup? = nil, onSave: @escaping (ConnectionGroup) -> Void) { + self.existingGroup = group + self.onSave = onSave + _name = State(initialValue: group?.name ?? "") + _color = State(initialValue: group?.color ?? .none) + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Name", text: $name) + .textInputAutocapitalization(.words) + } + + Section("Color") { + ConnectionColorPicker(selection: $color) + } + } + .navigationTitle(existingGroup != nil ? String(localized: "Edit Group") : String(localized: "New Group")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + var group = existingGroup ?? ConnectionGroup() + group.name = name + group.color = color + onSave(group) + dismiss() + } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } +} diff --git a/TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift b/TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift new file mode 100644 index 000000000..7dc765641 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/Components/TagFormSheet.swift @@ -0,0 +1,55 @@ +// +// TagFormSheet.swift +// TableProMobile +// + +import SwiftUI +import TableProModels + +struct TagFormSheet: View { + @Environment(\.dismiss) private var dismiss + + @State private var name: String + @State private var color: ConnectionColor + private let existingTag: ConnectionTag? + var onSave: (ConnectionTag) -> Void + + init(editing tag: ConnectionTag? = nil, onSave: @escaping (ConnectionTag) -> Void) { + self.existingTag = tag + self.onSave = onSave + _name = State(initialValue: tag?.name ?? "") + _color = State(initialValue: tag?.color ?? .gray) + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Name", text: $name) + .textInputAutocapitalization(.words) + } + + Section("Color") { + ConnectionColorPicker(selection: $color) + } + } + .navigationTitle(existingTag != nil ? String(localized: "Edit Tag") : String(localized: "New Tag")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + var tag = existingTag ?? ConnectionTag() + tag.name = name + tag.color = color + onSave(tag) + dismiss() + } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } +} diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 5467cd42f..376bade53 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -28,6 +28,10 @@ struct ConnectionFormView: View { @State private var showNewDatabaseAlert = false @State private var newDatabaseName = "" + // Organization + @State private var groupId: UUID? + @State private var tagId: UUID? + // SSH @State private var sshEnabled = false @State private var sshHost = "" @@ -88,6 +92,8 @@ struct ConnectionFormView: View { _sshKeyInputMode = State(initialValue: .paste) } } + _groupId = State(initialValue: connection.groupId) + _tagId = State(initialValue: connection.tagId) if connection.type == .sqlite { _selectedFileURL = State(initialValue: URL(fileURLWithPath: connection.database)) } @@ -113,6 +119,36 @@ struct ConnectionFormView: View { } } + Section("Organization") { + Picker("Group", selection: $groupId) { + Text("None").tag(UUID?.none) + ForEach(appState.groups) { group in + HStack { + Circle() + .fill(ConnectionColorPicker.swiftUIColor(for: group.color)) + .frame(width: 8, height: 8) + Text(group.name) + } + .tag(Optional(group.id)) + } + } + .pickerStyle(.menu) + + Picker("Tag", selection: $tagId) { + Text("None").tag(UUID?.none) + ForEach(appState.tags) { tag in + HStack { + Circle() + .fill(ConnectionColorPicker.swiftUIColor(for: tag.color)) + .frame(width: 8, height: 8) + Text(tag.name) + } + .tag(Optional(tag.id)) + } + } + .pickerStyle(.menu) + } + if type == .sqlite { sqliteSection } else { @@ -500,7 +536,9 @@ struct ConnectionFormView: View { username: username, database: database, sshEnabled: sshEnabled, - sslEnabled: sslEnabled + sslEnabled: sslEnabled, + groupId: groupId, + tagId: tagId ) if sshEnabled { conn.sshConfiguration = SSHConfiguration( diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 345eed53d..70b151553 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -12,10 +12,24 @@ struct ConnectionListView: View { @State private var showingAddConnection = false @State private var editingConnection: DatabaseConnection? @State private var selectedConnection: DatabaseConnection? + @State private var viewMode: ViewMode = .all + @State private var selectedTagIds: Set = [] + @State private var showingGroupManagement = false + @State private var showingTagManagement = false - private var groupedConnections: [(String, [DatabaseConnection])] { - let grouped = Dictionary(grouping: appState.connections) { $0.type.rawValue } - return grouped.sorted { $0.key < $1.key } + private enum ViewMode: String, CaseIterable { + case all = "All" + case groups = "Groups" + } + + private var filteredConnections: [DatabaseConnection] { + if selectedTagIds.isEmpty { + return appState.connections + } + return appState.connections.filter { conn in + guard let tagId = conn.tagId else { return false } + return selectedTagIds.contains(tagId) + } } private var isSyncing: Bool { @@ -35,20 +49,37 @@ struct ConnectionListView: View { } } ToolbarItem(placement: .topBarLeading) { - Button { - Task { - await appState.syncCoordinator.sync( - localConnections: appState.connections) + Menu { + Button { + showingGroupManagement = true + } label: { + Label("Manage Groups", systemImage: "folder") } - } label: { - if isSyncing { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "arrow.triangle.2.circlepath.icloud") + Button { + showingTagManagement = true + } label: { + Label("Manage Tags", systemImage: "tag") + } + Divider() + Button { + Task { + await appState.syncCoordinator.sync( + localConnections: appState.connections, + localGroups: appState.groups, + localTags: appState.tags + ) + } + } label: { + if isSyncing { + Label("Syncing...", systemImage: "arrow.triangle.2.circlepath.icloud") + } else { + Label("Sync", systemImage: "arrow.triangle.2.circlepath.icloud") + } } + .disabled(isSyncing) + } label: { + Image(systemName: "ellipsis.circle") } - .disabled(isSyncing) } } } detail: { @@ -77,6 +108,12 @@ struct ConnectionListView: View { editingConnection = nil } } + .sheet(isPresented: $showingGroupManagement) { + GroupManagementView() + } + .sheet(isPresented: $showingTagManagement) { + TagManagementView() + } } @ViewBuilder @@ -96,60 +133,165 @@ struct ConnectionListView: View { ProgressView("Syncing from iCloud...") .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - List(selection: $selectedConnection) { - ForEach(groupedConnections, id: \.0) { sectionTitle, connections in - Section(sectionTitle) { - ForEach(connections) { connection in - ConnectionRow(connection: connection) - .tag(connection) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - if selectedConnection?.id == connection.id { - selectedConnection = nil - } - appState.removeConnection(connection) - } label: { - Label("Delete", systemImage: "trash") - } - } - .contextMenu { - Button { - editingConnection = connection - } label: { - Label("Edit", systemImage: "pencil") - } - Button { - var duplicate = connection - duplicate.id = UUID() - duplicate.name = "\(connection.name) Copy" - appState.addConnection(duplicate) - } label: { - Label("Duplicate", systemImage: "doc.on.doc") - } - Divider() - Button(role: .destructive) { - if selectedConnection?.id == connection.id { - selectedConnection = nil - } - appState.removeConnection(connection) - } label: { - Label("Delete", systemImage: "trash") - } + VStack(spacing: 0) { + viewModeAndFilters + connectionList + } + } + } + + private var viewModeAndFilters: some View { + VStack(spacing: 8) { + Picker("View", selection: $viewMode) { + ForEach(ViewMode.allCases, id: \.self) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + + if !appState.tags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(appState.tags) { tag in + let isSelected = selectedTagIds.contains(tag.id) + Button { + if isSelected { + selectedTagIds.remove(tag.id) + } else { + selectedTagIds.insert(tag.id) } + } label: { + Text(tag.name) + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule() + .fill(isSelected + ? ConnectionColorPicker.swiftUIColor(for: tag.color) + : ConnectionColorPicker.swiftUIColor(for: tag.color).opacity(0.15)) + ) + .foregroundStyle(isSelected ? .white : .primary) + } + .buttonStyle(.plain) } } + .padding(.horizontal) + } + } + } + .padding(.vertical, 8) + } + + private var connectionList: some View { + List(selection: $selectedConnection) { + switch viewMode { + case .all: + allConnectionsList + case .groups: + groupedConnectionsList + } + } + .listStyle(.sidebar) + .refreshable { + await appState.syncCoordinator.sync( + localConnections: appState.connections, + localGroups: appState.groups, + localTags: appState.tags + ) + } + } + + @ViewBuilder + private var allConnectionsList: some View { + let sorted = filteredConnections.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + ForEach(sorted) { connection in + connectionRow(connection) + } + } + + @ViewBuilder + private var groupedConnectionsList: some View { + let sortedGroups = appState.groups.sorted { $0.sortOrder < $1.sortOrder } + + ForEach(sortedGroups) { group in + let groupConnections = filteredConnections + .filter { $0.groupId == group.id } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + if !groupConnections.isEmpty { + Section { + ForEach(groupConnections) { connection in + connectionRow(connection) + } + } header: { + HStack(spacing: 6) { + Circle() + .fill(ConnectionColorPicker.swiftUIColor(for: group.color)) + .frame(width: 8, height: 8) + Text(group.name) + } } } - .listStyle(.sidebar) - .refreshable { - await appState.syncCoordinator.sync(localConnections: appState.connections) + } + + let ungrouped = filteredConnections + .filter { $0.groupId == nil } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + if !ungrouped.isEmpty { + Section("Ungrouped") { + ForEach(ungrouped) { connection in + connectionRow(connection) + } } } } + + private func connectionRow(_ connection: DatabaseConnection) -> some View { + ConnectionRow(connection: connection, tag: appState.tag(for: connection.tagId)) + .tag(connection) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + if selectedConnection?.id == connection.id { + selectedConnection = nil + } + appState.removeConnection(connection) + } label: { + Label("Delete", systemImage: "trash") + } + } + .contextMenu { + Button { + editingConnection = connection + } label: { + Label("Edit", systemImage: "pencil") + } + Button { + var duplicate = connection + duplicate.id = UUID() + duplicate.name = "\(connection.name) Copy" + appState.addConnection(duplicate) + } label: { + Label("Duplicate", systemImage: "doc.on.doc") + } + Divider() + Button(role: .destructive) { + if selectedConnection?.id == connection.id { + selectedConnection = nil + } + appState.removeConnection(connection) + } label: { + Label("Delete", systemImage: "trash") + } + } + } } private struct ConnectionRow: View { let connection: DatabaseConnection + let tag: ConnectionTag? var body: some View { HStack(spacing: 12) { @@ -174,6 +316,18 @@ private struct ConnectionRow: View { .font(.caption) .foregroundStyle(.secondary) } + + if let tag { + Text(tag.name) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(ConnectionColorPicker.swiftUIColor(for: tag.color).opacity(0.2)) + ) + .foregroundStyle(ConnectionColorPicker.swiftUIColor(for: tag.color)) + } } Spacer() diff --git a/TableProMobile/TableProMobile/Views/GroupManagementView.swift b/TableProMobile/TableProMobile/Views/GroupManagementView.swift new file mode 100644 index 000000000..8a108faa7 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/GroupManagementView.swift @@ -0,0 +1,95 @@ +// +// GroupManagementView.swift +// TableProMobile +// + +import SwiftUI +import TableProModels + +struct GroupManagementView: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var editingGroup: ConnectionGroup? + @State private var showingAddGroup = false + + var body: some View { + NavigationStack { + List { + ForEach(appState.groups.sorted(by: { $0.sortOrder < $1.sortOrder })) { group in + Button { + editingGroup = group + } label: { + HStack(spacing: 12) { + Circle() + .fill(ConnectionColorPicker.swiftUIColor(for: group.color)) + .frame(width: 12, height: 12) + + Text(group.name) + .foregroundStyle(.primary) + + Spacer() + + let count = appState.connections.filter { $0.groupId == group.id }.count + Text("\(count)") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + appState.deleteGroup(group.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .onMove { source, destination in + var sorted = appState.groups.sorted(by: { $0.sortOrder < $1.sortOrder }) + sorted.move(fromOffsets: source, toOffset: destination) + for (index, group) in sorted.enumerated() { + var updated = group + updated.sortOrder = index + appState.updateGroup(updated) + } + } + } + .overlay { + if appState.groups.isEmpty { + ContentUnavailableView { + Label("No Groups", systemImage: "folder") + } description: { + Text("Create a group to organize your connections.") + } + } + } + .navigationTitle("Groups") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + showingAddGroup = true + } label: { + Image(systemName: "plus") + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + EditButton() + } + } + .sheet(isPresented: $showingAddGroup) { + GroupFormSheet { group in + appState.addGroup(group) + } + } + .sheet(item: $editingGroup) { group in + GroupFormSheet(editing: group) { updated in + appState.updateGroup(updated) + editingGroup = nil + } + } + } + } +} diff --git a/TableProMobile/TableProMobile/Views/OnboardingView.swift b/TableProMobile/TableProMobile/Views/OnboardingView.swift index beb8fc2f6..dbfbd1c66 100644 --- a/TableProMobile/TableProMobile/Views/OnboardingView.swift +++ b/TableProMobile/TableProMobile/Views/OnboardingView.swift @@ -156,7 +156,11 @@ struct OnboardingView: View { private func syncFromiCloud() { completeOnboarding() Task { - await appState.syncCoordinator.sync(localConnections: appState.connections) + await appState.syncCoordinator.sync( + localConnections: appState.connections, + localGroups: appState.groups, + localTags: appState.tags + ) } } diff --git a/TableProMobile/TableProMobile/Views/TagManagementView.swift b/TableProMobile/TableProMobile/Views/TagManagementView.swift new file mode 100644 index 000000000..d9e3c8f9f --- /dev/null +++ b/TableProMobile/TableProMobile/Views/TagManagementView.swift @@ -0,0 +1,84 @@ +// +// TagManagementView.swift +// TableProMobile +// + +import SwiftUI +import TableProModels + +struct TagManagementView: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var editingTag: ConnectionTag? + @State private var showingAddTag = false + + var body: some View { + NavigationStack { + List { + ForEach(appState.tags) { tag in + Button { + if !tag.isPreset { + editingTag = tag + } + } label: { + HStack(spacing: 12) { + Circle() + .fill(ConnectionColorPicker.swiftUIColor(for: tag.color)) + .frame(width: 12, height: 12) + + Text(tag.name) + .foregroundStyle(.primary) + + if tag.isPreset { + Image(systemName: "lock.fill") + .font(.caption2) + .foregroundStyle(.secondary) + } + + Spacer() + + let count = appState.connections.filter { $0.tagId == tag.id }.count + Text("\(count)") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + if !tag.isPreset { + Button(role: .destructive) { + appState.deleteTag(tag.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } + .navigationTitle("Tags") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + showingAddTag = true + } label: { + Image(systemName: "plus") + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + .sheet(isPresented: $showingAddTag) { + TagFormSheet { tag in + appState.addTag(tag) + } + } + .sheet(item: $editingTag) { tag in + TagFormSheet(editing: tag) { updated in + appState.updateTag(updated) + editingTag = nil + } + } + } + } +} From 45a5f1aa5dd9df395781301fd426502031249af2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 15:42:22 +0700 Subject: [PATCH 2/7] fix: 8 iOS HIG and correctness fixes for groups/tags --- TableProMobile/TableProMobile/AppState.swift | 59 +++++-------------- .../Sync/IOSSyncCoordinator.swift | 14 ++--- .../Components/ConnectionColorPicker.swift | 2 + .../Views/ConnectionListView.swift | 15 +++-- .../Views/GroupManagementView.swift | 15 ++--- .../Views/TagManagementView.swift | 12 +++- 6 files changed, 48 insertions(+), 69 deletions(-) diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index a7a0c4050..b81587f2f 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -55,6 +55,11 @@ final class AppState { self.tags = merged self.tagStorage.save(merged) } + + syncCoordinator.getCurrentState = { [weak self] in + guard let self else { return ([], [], []) } + return (self.connections, self.groups, self.tags) + } } // MARK: - Connections @@ -63,11 +68,7 @@ final class AppState { connections.append(connection) storage.save(connections) syncCoordinator.markDirty(connection.id) - syncCoordinator.scheduleSyncAfterChange( - localConnections: connections, - localGroups: groups, - localTags: tags - ) + syncCoordinator.scheduleSyncAfterChange() } func updateConnection(_ connection: DatabaseConnection) { @@ -75,11 +76,7 @@ final class AppState { connections[index] = connection storage.save(connections) syncCoordinator.markDirty(connection.id) - syncCoordinator.scheduleSyncAfterChange( - localConnections: connections, - localGroups: groups, - localTags: tags - ) + syncCoordinator.scheduleSyncAfterChange() } } @@ -95,11 +92,7 @@ final class AppState { try? secureStore.delete(forKey: "com.TablePro.sshkeydata.\(connection.id.uuidString)") storage.save(connections) syncCoordinator.markDeleted(connection.id) - syncCoordinator.scheduleSyncAfterChange( - localConnections: connections, - localGroups: groups, - localTags: tags - ) + syncCoordinator.scheduleSyncAfterChange() } // MARK: - Groups @@ -108,11 +101,7 @@ final class AppState { groups.append(group) groupStorage.save(groups) syncCoordinator.markDirtyGroup(group.id) - syncCoordinator.scheduleSyncAfterChange( - localConnections: connections, - localGroups: groups, - localTags: tags - ) + syncCoordinator.scheduleSyncAfterChange() } func updateGroup(_ group: ConnectionGroup) { @@ -120,11 +109,7 @@ final class AppState { groups[index] = group groupStorage.save(groups) syncCoordinator.markDirtyGroup(group.id) - syncCoordinator.scheduleSyncAfterChange( - localConnections: connections, - localGroups: groups, - localTags: tags - ) + syncCoordinator.scheduleSyncAfterChange() } } @@ -139,11 +124,7 @@ final class AppState { storage.save(connections) syncCoordinator.markDeletedGroup(groupId) - syncCoordinator.scheduleSyncAfterChange( - localConnections: connections, - localGroups: groups, - localTags: tags - ) + syncCoordinator.scheduleSyncAfterChange() } // MARK: - Tags @@ -152,11 +133,7 @@ final class AppState { tags.append(tag) tagStorage.save(tags) syncCoordinator.markDirtyTag(tag.id) - syncCoordinator.scheduleSyncAfterChange( - localConnections: connections, - localGroups: groups, - localTags: tags - ) + syncCoordinator.scheduleSyncAfterChange() } func updateTag(_ tag: ConnectionTag) { @@ -164,11 +141,7 @@ final class AppState { tags[index] = tag tagStorage.save(tags) syncCoordinator.markDirtyTag(tag.id) - syncCoordinator.scheduleSyncAfterChange( - localConnections: connections, - localGroups: groups, - localTags: tags - ) + syncCoordinator.scheduleSyncAfterChange() } } @@ -185,11 +158,7 @@ final class AppState { storage.save(connections) syncCoordinator.markDeletedTag(tagId) - syncCoordinator.scheduleSyncAfterChange( - localConnections: connections, - localGroups: groups, - localTags: tags - ) + syncCoordinator.scheduleSyncAfterChange() } // MARK: - Helpers diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index 2ecd7105d..80c75b718 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -34,6 +34,7 @@ final class IOSSyncCoordinator { var onConnectionsChanged: (([DatabaseConnection]) -> Void)? var onGroupsChanged: (([ConnectionGroup]) -> Void)? var onTagsChanged: (([ConnectionTag]) -> Void)? + var getCurrentState: (() -> (connections: [DatabaseConnection], groups: [ConnectionGroup], tags: [ConnectionTag]))? // MARK: - Sync @@ -120,19 +121,16 @@ final class IOSSyncCoordinator { metadata.addTombstone(tagId.uuidString, type: .tag) } - func scheduleSyncAfterChange( - localConnections: [DatabaseConnection], - localGroups: [ConnectionGroup] = [], - localTags: [ConnectionTag] = [] - ) { + func scheduleSyncAfterChange() { debounceTask?.cancel() debounceTask = Task { try? await Task.sleep(nanoseconds: 2_000_000_000) guard !Task.isCancelled else { return } + guard let state = getCurrentState?() else { return } await sync( - localConnections: localConnections, - localGroups: localGroups, - localTags: localTags + localConnections: state.connections, + localGroups: state.groups, + localTags: state.tags ) } } diff --git a/TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift b/TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift index 1f64d7845..d7b1501ab 100644 --- a/TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift +++ b/TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift @@ -26,6 +26,8 @@ struct ConnectionColorPicker: View { .foregroundStyle(.white) } } + .frame(minWidth: 44, minHeight: 44) + .contentShape(Circle()) } .buttonStyle(.plain) } diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 70b151553..34ee17c13 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -40,6 +40,9 @@ struct ConnectionListView: View { NavigationSplitView { sidebar .navigationTitle("Connections") + .navigationDestination(for: DatabaseConnection.self) { connection in + ConnectedView(connection: connection) + } .toolbar { ToolbarItem(placement: .primaryAction) { Button { @@ -165,7 +168,7 @@ struct ConnectionListView: View { Text(tag.name) .font(.caption) .padding(.horizontal, 10) - .padding(.vertical, 5) + .padding(.vertical, 10) .background( Capsule() .fill(isSelected @@ -173,6 +176,7 @@ struct ConnectionListView: View { : ConnectionColorPicker.swiftUIColor(for: tag.color).opacity(0.15)) ) .foregroundStyle(isSelected ? .white : .primary) + .contentShape(Capsule()) } .buttonStyle(.plain) } @@ -185,7 +189,7 @@ struct ConnectionListView: View { } private var connectionList: some View { - List(selection: $selectedConnection) { + List { switch viewMode { case .all: allConnectionsList @@ -250,9 +254,10 @@ struct ConnectionListView: View { } private func connectionRow(_ connection: DatabaseConnection) -> some View { - ConnectionRow(connection: connection, tag: appState.tag(for: connection.tagId)) - .tag(connection) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { + NavigationLink(value: connection) { + ConnectionRow(connection: connection, tag: appState.tag(for: connection.tagId)) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { if selectedConnection?.id == connection.id { selectedConnection = nil diff --git a/TableProMobile/TableProMobile/Views/GroupManagementView.swift b/TableProMobile/TableProMobile/Views/GroupManagementView.swift index 8a108faa7..d11d86147 100644 --- a/TableProMobile/TableProMobile/Views/GroupManagementView.swift +++ b/TableProMobile/TableProMobile/Views/GroupManagementView.swift @@ -35,7 +35,7 @@ struct GroupManagementView: View { .font(.subheadline) } } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { + .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { appState.deleteGroup(group.id) } label: { @@ -65,19 +65,17 @@ struct GroupManagementView: View { .navigationTitle("Groups") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .confirmationAction) { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItemGroup(placement: .topBarTrailing) { + EditButton() Button { showingAddGroup = true } label: { Image(systemName: "plus") } } - ToolbarItem(placement: .cancellationAction) { - Button("Done") { dismiss() } - } - ToolbarItem(placement: .primaryAction) { - EditButton() - } } .sheet(isPresented: $showingAddGroup) { GroupFormSheet { group in @@ -87,7 +85,6 @@ struct GroupManagementView: View { .sheet(item: $editingGroup) { group in GroupFormSheet(editing: group) { updated in appState.updateGroup(updated) - editingGroup = nil } } } diff --git a/TableProMobile/TableProMobile/Views/TagManagementView.swift b/TableProMobile/TableProMobile/Views/TagManagementView.swift index d9e3c8f9f..7c2774893 100644 --- a/TableProMobile/TableProMobile/Views/TagManagementView.swift +++ b/TableProMobile/TableProMobile/Views/TagManagementView.swift @@ -43,7 +43,7 @@ struct TagManagementView: View { .font(.subheadline) } } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { + .swipeActions(edge: .trailing, allowsFullSwipe: false) { if !tag.isPreset { Button(role: .destructive) { appState.deleteTag(tag.id) @@ -54,6 +54,15 @@ struct TagManagementView: View { } } } + .overlay { + if appState.tags.isEmpty { + ContentUnavailableView { + Label("No Tags", systemImage: "tag") + } description: { + Text("Create a tag to organize your connections.") + } + } + } .navigationTitle("Tags") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -76,7 +85,6 @@ struct TagManagementView: View { .sheet(item: $editingTag) { tag in TagFormSheet(editing: tag) { updated in appState.updateTag(updated) - editingTag = nil } } } From 3dd9f7f78fbeeabf59e70215e242a072fa508713 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 15:44:47 +0700 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20native=20iOS=20list=20style=20?= =?UTF-8?q?=E2=80=94=20insetGrouped,=20toolbar=20filter=20menu,=20remove?= =?UTF-8?q?=20custom=20chips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/ConnectionListView.swift | 306 ++++++++---------- 1 file changed, 139 insertions(+), 167 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 34ee17c13..b11717d25 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -12,24 +12,17 @@ struct ConnectionListView: View { @State private var showingAddConnection = false @State private var editingConnection: DatabaseConnection? @State private var selectedConnection: DatabaseConnection? - @State private var viewMode: ViewMode = .all - @State private var selectedTagIds: Set = [] @State private var showingGroupManagement = false @State private var showingTagManagement = false + @State private var filterTagId: UUID? + @State private var groupByGroup = false - private enum ViewMode: String, CaseIterable { - case all = "All" - case groups = "Groups" - } - - private var filteredConnections: [DatabaseConnection] { - if selectedTagIds.isEmpty { - return appState.connections - } - return appState.connections.filter { conn in - guard let tagId = conn.tagId else { return false } - return selectedTagIds.contains(tagId) + private var displayedConnections: [DatabaseConnection] { + var result = appState.connections + if let filterTagId { + result = result.filter { $0.tagId == filterTagId } } + return result.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } private var isSyncing: Bool { @@ -52,37 +45,23 @@ struct ConnectionListView: View { } } ToolbarItem(placement: .topBarLeading) { - Menu { - Button { - showingGroupManagement = true - } label: { - Label("Manage Groups", systemImage: "folder") - } - Button { - showingTagManagement = true - } label: { - Label("Manage Tags", systemImage: "tag") - } - Divider() - Button { - Task { - await appState.syncCoordinator.sync( - localConnections: appState.connections, - localGroups: appState.groups, - localTags: appState.tags - ) - } - } label: { - if isSyncing { - Label("Syncing...", systemImage: "arrow.triangle.2.circlepath.icloud") - } else { - Label("Sync", systemImage: "arrow.triangle.2.circlepath.icloud") - } + Button { + Task { + await appState.syncCoordinator.sync( + localConnections: appState.connections, + localGroups: appState.groups, + localTags: appState.tags + ) } - .disabled(isSyncing) } label: { - Image(systemName: "ellipsis.circle") + if isSyncing { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.triangle.2.circlepath.icloud") + } } + .disabled(isSyncing) } } } detail: { @@ -136,93 +115,86 @@ struct ConnectionListView: View { ProgressView("Syncing from iCloud...") .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - VStack(spacing: 0) { - viewModeAndFilters - connectionList - } - } - } - - private var viewModeAndFilters: some View { - VStack(spacing: 8) { - Picker("View", selection: $viewMode) { - ForEach(ViewMode.allCases, id: \.self) { mode in - Text(mode.rawValue).tag(mode) + List { + if groupByGroup { + groupedContent + } else { + ForEach(displayedConnections) { connection in + connectionRow(connection) + } } } - .pickerStyle(.segmented) - .padding(.horizontal) + .listStyle(.insetGrouped) + .refreshable { + await appState.syncCoordinator.sync( + localConnections: appState.connections, + localGroups: appState.groups, + localTags: appState.tags + ) + } + .toolbar { + ToolbarItem(placement: .secondaryAction) { + Menu { + Section { + Toggle("Group by Folder", isOn: $groupByGroup) + } - if !appState.tags.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(appState.tags) { tag in - let isSelected = selectedTagIds.contains(tag.id) - Button { - if isSelected { - selectedTagIds.remove(tag.id) - } else { - selectedTagIds.insert(tag.id) + if !appState.tags.isEmpty { + Section("Filter by Tag") { + Button { + filterTagId = nil + } label: { + HStack { + Text("All") + if filterTagId == nil { + Image(systemName: "checkmark") + } + } } + ForEach(appState.tags) { tag in + Button { + filterTagId = tag.id + } label: { + HStack { + Image(systemName: "circle.fill") + .font(.caption2) + .foregroundStyle(ConnectionColorPicker.swiftUIColor(for: tag.color)) + Text(tag.name) + if filterTagId == tag.id { + Image(systemName: "checkmark") + } + } + } + } + } + } + + Section { + Button { + showingGroupManagement = true + } label: { + Label("Manage Groups", systemImage: "folder") + } + Button { + showingTagManagement = true } label: { - Text(tag.name) - .font(.caption) - .padding(.horizontal, 10) - .padding(.vertical, 10) - .background( - Capsule() - .fill(isSelected - ? ConnectionColorPicker.swiftUIColor(for: tag.color) - : ConnectionColorPicker.swiftUIColor(for: tag.color).opacity(0.15)) - ) - .foregroundStyle(isSelected ? .white : .primary) - .contentShape(Capsule()) + Label("Manage Tags", systemImage: "tag") } - .buttonStyle(.plain) } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") } - .padding(.horizontal) } } } - .padding(.vertical, 8) - } - - private var connectionList: some View { - List { - switch viewMode { - case .all: - allConnectionsList - case .groups: - groupedConnectionsList - } - } - .listStyle(.sidebar) - .refreshable { - await appState.syncCoordinator.sync( - localConnections: appState.connections, - localGroups: appState.groups, - localTags: appState.tags - ) - } - } - - @ViewBuilder - private var allConnectionsList: some View { - let sorted = filteredConnections.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - ForEach(sorted) { connection in - connectionRow(connection) - } } @ViewBuilder - private var groupedConnectionsList: some View { + private var groupedContent: some View { let sortedGroups = appState.groups.sorted { $0.sortOrder < $1.sortOrder } ForEach(sortedGroups) { group in - let groupConnections = filteredConnections - .filter { $0.groupId == group.id } - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + let groupConnections = displayedConnections.filter { $0.groupId == group.id } if !groupConnections.isEmpty { Section { @@ -231,18 +203,20 @@ struct ConnectionListView: View { } } header: { HStack(spacing: 6) { - Circle() - .fill(ConnectionColorPicker.swiftUIColor(for: group.color)) - .frame(width: 8, height: 8) + if group.color != .none { + Circle() + .fill(ConnectionColorPicker.swiftUIColor(for: group.color)) + .frame(width: 8, height: 8) + } Text(group.name) } } } } - let ungrouped = filteredConnections - .filter { $0.groupId == nil } - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + let ungrouped = displayedConnections.filter { conn in + conn.groupId == nil || !appState.groups.contains { $0.id == conn.groupId } + } if !ungrouped.isEmpty { Section("Ungrouped") { @@ -257,40 +231,40 @@ struct ConnectionListView: View { NavigationLink(value: connection) { ConnectionRow(connection: connection, tag: appState.tag(for: connection.tagId)) } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - if selectedConnection?.id == connection.id { - selectedConnection = nil - } - appState.removeConnection(connection) - } label: { - Label("Delete", systemImage: "trash") + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + if selectedConnection?.id == connection.id { + selectedConnection = nil } + appState.removeConnection(connection) + } label: { + Label("Delete", systemImage: "trash") } - .contextMenu { - Button { - editingConnection = connection - } label: { - Label("Edit", systemImage: "pencil") - } - Button { - var duplicate = connection - duplicate.id = UUID() - duplicate.name = "\(connection.name) Copy" - appState.addConnection(duplicate) - } label: { - Label("Duplicate", systemImage: "doc.on.doc") - } - Divider() - Button(role: .destructive) { - if selectedConnection?.id == connection.id { - selectedConnection = nil - } - appState.removeConnection(connection) - } label: { - Label("Delete", systemImage: "trash") + } + .contextMenu { + Button { + editingConnection = connection + } label: { + Label("Edit", systemImage: "pencil") + } + Button { + var duplicate = connection + duplicate.id = UUID() + duplicate.name = "\(connection.name) Copy" + appState.addConnection(duplicate) + } label: { + Label("Duplicate", systemImage: "doc.on.doc") + } + Divider() + Button(role: .destructive) { + if selectedConnection?.id == connection.id { + selectedConnection = nil } + appState.removeConnection(connection) + } label: { + Label("Delete", systemImage: "trash") } + } } } @@ -301,43 +275,41 @@ private struct ConnectionRow: View { var body: some View { HStack(spacing: 12) { Image(systemName: iconName(for: connection.type)) - .font(.title2) + .font(.title3) .foregroundStyle(iconColor(for: connection.type)) - .frame(width: 36, height: 36) + .frame(width: 32, height: 32) .background(iconColor(for: connection.type).opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: 7)) VStack(alignment: .leading, spacing: 2) { Text(connection.name.isEmpty ? connection.host : connection.name) .font(.body) - .fontWeight(.medium) if connection.type != .sqlite { Text(verbatim: "\(connection.host):\(connection.port)") - .font(.caption) + .font(.subheadline) .foregroundStyle(.secondary) } else { Text(connection.database.components(separatedBy: "/").last ?? "database") - .font(.caption) + .font(.subheadline) .foregroundStyle(.secondary) } - - if let tag { - Text(tag.name) - .font(.caption2) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(ConnectionColorPicker.swiftUIColor(for: tag.color).opacity(0.2)) - ) - .foregroundStyle(ConnectionColorPicker.swiftUIColor(for: tag.color)) - } } Spacer() + + if let tag { + Text(tag.name) + .font(.caption) + .foregroundStyle(ConnectionColorPicker.swiftUIColor(for: tag.color)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + Capsule() + .fill(ConnectionColorPicker.swiftUIColor(for: tag.color).opacity(0.15)) + ) + } } - .padding(.vertical, 4) } private func iconName(for type: DatabaseType) -> String { From b27dc77f1478a44cb5b8f284c01c8ba4c39755c6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 15:47:57 +0700 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20move=20filter=20menu=20to=20regular?= =?UTF-8?q?=20toolbar=20item=20=E2=80=94=20remove=20secondaryAction=20pill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/ConnectionListView.swift | 94 +++++++++---------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index b11717d25..eaed46c6b 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -37,7 +37,8 @@ struct ConnectionListView: View { ConnectedView(connection: connection) } .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .topBarTrailing) { + filterMenu Button { showingAddConnection = true } label: { @@ -132,60 +133,59 @@ struct ConnectionListView: View { localTags: appState.tags ) } - .toolbar { - ToolbarItem(placement: .secondaryAction) { - Menu { - Section { - Toggle("Group by Folder", isOn: $groupByGroup) - } + } + } - if !appState.tags.isEmpty { - Section("Filter by Tag") { - Button { - filterTagId = nil - } label: { - HStack { - Text("All") - if filterTagId == nil { - Image(systemName: "checkmark") - } - } - } - ForEach(appState.tags) { tag in - Button { - filterTagId = tag.id - } label: { - HStack { - Image(systemName: "circle.fill") - .font(.caption2) - .foregroundStyle(ConnectionColorPicker.swiftUIColor(for: tag.color)) - Text(tag.name) - if filterTagId == tag.id { - Image(systemName: "checkmark") - } - } - } - } - } - } + private var filterMenu: some View { + Menu { + Section { + Toggle("Group by Folder", isOn: $groupByGroup) + } - Section { - Button { - showingGroupManagement = true - } label: { - Label("Manage Groups", systemImage: "folder") + if !appState.tags.isEmpty { + Section("Filter by Tag") { + Button { + filterTagId = nil + } label: { + HStack { + Text("All") + if filterTagId == nil { + Image(systemName: "checkmark") } - Button { - showingTagManagement = true - } label: { - Label("Manage Tags", systemImage: "tag") + } + } + ForEach(appState.tags) { tag in + Button { + filterTagId = tag.id + } label: { + HStack { + Image(systemName: "circle.fill") + .font(.caption2) + .foregroundStyle(ConnectionColorPicker.swiftUIColor(for: tag.color)) + Text(tag.name) + if filterTagId == tag.id { + Image(systemName: "checkmark") + } } } - } label: { - Image(systemName: "line.3.horizontal.decrease.circle") } } } + + Section { + Button { + showingGroupManagement = true + } label: { + Label("Manage Groups", systemImage: "folder") + } + Button { + showingTagManagement = true + } label: { + Label("Manage Tags", systemImage: "tag") + } + } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") } } From f8f8373758d927f9ade565a3ebba222626d70229 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 15:49:14 +0700 Subject: [PATCH 5/7] fix: show empty state when tag filter matches no connections --- .../TableProMobile/Views/ConnectionListView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index eaed46c6b..83fe6740f 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -126,6 +126,15 @@ struct ConnectionListView: View { } } .listStyle(.insetGrouped) + .overlay { + if !appState.connections.isEmpty && displayedConnections.isEmpty { + ContentUnavailableView( + "No Matching Connections", + systemImage: "line.3.horizontal.decrease.circle", + description: Text("No connections match the selected filter.") + ) + } + } .refreshable { await appState.syncCoordinator.sync( localConnections: appState.connections, From e8321dbd02843512d69453855b84e5ea76f2a9f0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 15:53:48 +0700 Subject: [PATCH 6/7] fix: remove force unwraps, safe sortOrder decode, add CHANGELOG entry --- CHANGELOG.md | 4 ++++ .../Sources/TableProModels/ConnectionGroup.swift | 2 +- .../Sources/TableProModels/ConnectionTag.swift | 8 ++++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a3b239ba..867f8f164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- iOS: connection groups and tags — organize connections with colored groups and tags, synced via iCloud + ## [0.27.4] - 2026-04-05 ### Added diff --git a/Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift b/Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift index 39aa4e74e..44065b1fa 100644 --- a/Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift +++ b/Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift @@ -29,7 +29,7 @@ public struct ConnectionGroup: Identifiable, Codable, Hashable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) name = try container.decode(String.self, forKey: .name) - sortOrder = try container.decode(Int.self, forKey: .sortOrder) + sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 color = try container.decodeIfPresent(ConnectionColor.self, forKey: .color) ?? .none parentId = try container.decodeIfPresent(UUID.self, forKey: .parentId) } diff --git a/Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift b/Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift index 4b34916f9..19efb083d 100644 --- a/Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift +++ b/Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift @@ -61,25 +61,25 @@ public struct ConnectionTag: Identifiable, Codable, Hashable, Sendable { public static let presets: [ConnectionTag] = [ ConnectionTag( - id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID(), name: "local", isPreset: true, color: .green ), ConnectionTag( - id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + id: UUID(uuidString: "00000000-0000-0000-0000-000000000002") ?? UUID(), name: "development", isPreset: true, color: .blue ), ConnectionTag( - id: UUID(uuidString: "00000000-0000-0000-0000-000000000003")!, + id: UUID(uuidString: "00000000-0000-0000-0000-000000000003") ?? UUID(), name: "production", isPreset: true, color: .red ), ConnectionTag( - id: UUID(uuidString: "00000000-0000-0000-0000-000000000004")!, + id: UUID(uuidString: "00000000-0000-0000-0000-000000000004") ?? UUID(), name: "testing", isPreset: true, color: .orange From 3b6133b93d5ac53e557c6d5fc657d6c33d837ba3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 5 Apr 2026 15:56:55 +0700 Subject: [PATCH 7/7] docs: simplify CHANGELOG entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 867f8f164..238933a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- iOS: connection groups and tags — organize connections with colored groups and tags, synced via iCloud +- iOS: connection groups and tags ## [0.27.4] - 2026-04-05