diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a3b239ba..238933a47 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 + ## [0.27.4] - 2026-04-05 ### Added 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..44065b1fa 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.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 a0a347640..19efb083d 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") ?? UUID(), + name: "local", + isPreset: true, + color: .green + ), + ConnectionTag( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000002") ?? UUID(), + name: "development", + isPreset: true, + color: .blue + ), + ConnectionTag( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000003") ?? UUID(), + name: "production", + isPreset: true, + color: .red + ), + ConnectionTag( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000004") ?? UUID(), + 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..b81587f2f 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,32 @@ 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) + } + + syncCoordinator.getCurrentState = { [weak self] in + guard let self else { return ([], [], []) } + return (self.connections, self.groups, self.tags) + } } + // MARK: - Connections + func addConnection(_ connection: DatabaseConnection) { connections.append(connection) storage.save(connections) syncCoordinator.markDirty(connection.id) - syncCoordinator.scheduleSyncAfterChange(localConnections: connections) + syncCoordinator.scheduleSyncAfterChange() } func updateConnection(_ connection: DatabaseConnection) { @@ -51,7 +76,7 @@ final class AppState { connections[index] = connection storage.save(connections) syncCoordinator.markDirty(connection.id) - syncCoordinator.scheduleSyncAfterChange(localConnections: connections) + syncCoordinator.scheduleSyncAfterChange() } } @@ -67,7 +92,85 @@ 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() + } + + // MARK: - Groups + + func addGroup(_ group: ConnectionGroup) { + groups.append(group) + groupStorage.save(groups) + syncCoordinator.markDirtyGroup(group.id) + syncCoordinator.scheduleSyncAfterChange() + } + + 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() + } + } + + 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() + } + + // MARK: - Tags + + func addTag(_ tag: ConnectionTag) { + tags.append(tag) + tagStorage.save(tags) + syncCoordinator.markDirtyTag(tag.id) + syncCoordinator.scheduleSyncAfterChange() + } + + 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() + } + } + + 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() + } + + // 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..80c75b718 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,19 @@ final class IOSSyncCoordinator { } private var debounceTask: Task? - // Callback to update AppState connections var onConnectionsChanged: (([DatabaseConnection]) -> Void)? + var onGroupsChanged: (([ConnectionGroup]) -> Void)? + var onTagsChanged: (([ConnectionTag]) -> Void)? + var getCurrentState: (() -> (connections: [DatabaseConnection], groups: [ConnectionGroup], tags: [ConnectionTag]))? // 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 +56,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 +84,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 +105,115 @@ 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() { debounceTask?.cancel() debounceTask = Task { try? await Task.sleep(nanoseconds: 2_000_000_000) guard !Task.isCancelled else { return } - await sync(localConnections: localConnections) + guard let state = getCurrentState?() else { return } + await sync( + localConnections: state.connections, + localGroups: state.groups, + localTags: state.tags + ) } } // 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 +227,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 +253,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 +273,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..d7b1501ab --- /dev/null +++ b/TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift @@ -0,0 +1,51 @@ +// +// 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) + } + } + .frame(minWidth: 44, minHeight: 44) + .contentShape(Circle()) + } + .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..83fe6740f 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -12,10 +12,17 @@ struct ConnectionListView: View { @State private var showingAddConnection = false @State private var editingConnection: DatabaseConnection? @State private var selectedConnection: DatabaseConnection? + @State private var showingGroupManagement = false + @State private var showingTagManagement = false + @State private var filterTagId: UUID? + @State private var groupByGroup = false - private var groupedConnections: [(String, [DatabaseConnection])] { - let grouped = Dictionary(grouping: appState.connections) { $0.type.rawValue } - return grouped.sorted { $0.key < $1.key } + 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 { @@ -26,8 +33,12 @@ struct ConnectionListView: View { NavigationSplitView { sidebar .navigationTitle("Connections") + .navigationDestination(for: DatabaseConnection.self) { connection in + ConnectedView(connection: connection) + } .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .topBarTrailing) { + filterMenu Button { showingAddConnection = true } label: { @@ -38,7 +49,10 @@ struct ConnectionListView: View { Button { Task { await appState.syncCoordinator.sync( - localConnections: appState.connections) + localConnections: appState.connections, + localGroups: appState.groups, + localTags: appState.tags + ) } } label: { if isSyncing { @@ -77,6 +91,12 @@ struct ConnectionListView: View { editingConnection = nil } } + .sheet(isPresented: $showingGroupManagement) { + GroupManagementView() + } + .sheet(isPresented: $showingTagManagement) { + TagManagementView() + } } @ViewBuilder @@ -96,53 +116,162 @@ 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") - } + List { + if groupByGroup { + groupedContent + } else { + ForEach(displayedConnections) { connection in + connectionRow(connection) + } + } + } + .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, + localGroups: appState.groups, + localTags: appState.tags + ) + } + } + } + + private var filterMenu: some View { + 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") } + } } } } } - .listStyle(.sidebar) - .refreshable { - await appState.syncCoordinator.sync(localConnections: appState.connections) + + 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") + } + } + + @ViewBuilder + private var groupedContent: some View { + let sortedGroups = appState.groups.sorted { $0.sortOrder < $1.sortOrder } + + ForEach(sortedGroups) { group in + let groupConnections = displayedConnections.filter { $0.groupId == group.id } + + if !groupConnections.isEmpty { + Section { + ForEach(groupConnections) { connection in + connectionRow(connection) + } + } header: { + HStack(spacing: 6) { + if group.color != .none { + Circle() + .fill(ConnectionColorPicker.swiftUIColor(for: group.color)) + .frame(width: 8, height: 8) + } + Text(group.name) + } + } + } + } + + let ungrouped = displayedConnections.filter { conn in + conn.groupId == nil || !appState.groups.contains { $0.id == conn.groupId } + } + + if !ungrouped.isEmpty { + Section("Ungrouped") { + ForEach(ungrouped) { connection in + connectionRow(connection) + } + } + } + } + + private func connectionRow(_ connection: DatabaseConnection) -> some 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") + } + } + .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") } } } @@ -150,35 +279,46 @@ struct ConnectionListView: View { private struct ConnectionRow: View { let connection: DatabaseConnection + let tag: ConnectionTag? 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) } } 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 { diff --git a/TableProMobile/TableProMobile/Views/GroupManagementView.swift b/TableProMobile/TableProMobile/Views/GroupManagementView.swift new file mode 100644 index 000000000..d11d86147 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/GroupManagementView.swift @@ -0,0 +1,92 @@ +// +// 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: false) { + 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: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItemGroup(placement: .topBarTrailing) { + EditButton() + Button { + showingAddGroup = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showingAddGroup) { + GroupFormSheet { group in + appState.addGroup(group) + } + } + .sheet(item: $editingGroup) { group in + GroupFormSheet(editing: group) { updated in + appState.updateGroup(updated) + } + } + } + } +} 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..7c2774893 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/TagManagementView.swift @@ -0,0 +1,92 @@ +// +// 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: false) { + if !tag.isPreset { + Button(role: .destructive) { + appState.deleteTag(tag.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } + .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 { + 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) + } + } + } + } +}