From 7a64252ef4705f1f0bade8c88edca176d2b6548e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 02:12:41 +0700 Subject: [PATCH] feat(ios): add iPad two-column layout with table sidebar and data detail --- .../TableProMobile/Views/ConnectedView.swift | 217 +++++++++++++----- .../Views/ConnectionListView.swift | 15 +- .../Views/DataBrowserView.swift | 29 +-- .../TableProMobile/Views/TableListView.swift | 140 +++++++---- 4 files changed, 276 insertions(+), 125 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 58ddfdbc..d6ebd1dd 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -11,6 +11,7 @@ import TableProModels struct ConnectedView: View { @Environment(AppState.self) private var appState @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var sizeClass let connection: DatabaseConnection private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectedView") @@ -29,6 +30,7 @@ struct ConnectedView: View { @State private var activeDatabase: String = "" @State private var schemas: [String] = [] @State private var activeSchema: String = "public" + @State private var selectedTable: TableInfo? @State private var isSwitching = false @State private var isReconnecting = false @State private var hapticSuccess = false @@ -129,16 +131,12 @@ struct ConnectedView: View { } message: { Text(failureAlertMessage ?? "") } - .navigationTitle(supportsDatabaseSwitching && databases.count > 1 ? "" : displayName) + .navigationTitle(sizeClass != .regular ? (supportsDatabaseSwitching && databases.count > 1 ? "" : displayName) : "") .navigationBarTitleDisplayMode(.inline) .safeAreaInset(edge: .top) { - Picker("Tab", selection: selectedTabBinding) { - Text("Tables").tag(ConnectedTab.tables) - Text("Query").tag(ConnectedTab.query) + if sizeClass != .regular { + tabPicker } - .pickerStyle(.segmented) - .padding(.horizontal) - .padding(.vertical, 8) } .background { Button("") { selectedTabRaw = ConnectedTab.tables.rawValue } @@ -149,63 +147,23 @@ struct ConnectedView: View { .hidden() } .toolbar { - if connection.safeModeLevel != .off { - ToolbarItem(placement: .topBarTrailing) { - Image(systemName: connection.safeModeLevel == .readOnly ? "lock.fill" : "shield.fill") - .foregroundStyle(connection.safeModeLevel == .readOnly ? .red : .orange) - .font(.caption) + if sizeClass != .regular { + if connection.safeModeLevel != .off { + ToolbarItem(placement: .topBarTrailing) { + Image(systemName: connection.safeModeLevel == .readOnly ? "lock.fill" : "shield.fill") + .foregroundStyle(connection.safeModeLevel == .readOnly ? .red : .orange) + .font(.caption) + } } - } - if supportsDatabaseSwitching && databases.count > 1 { - ToolbarItem(placement: .topBarLeading) { - Menu { - ForEach(databases, id: \.self) { db in - Button { - Task { await switchDatabase(to: db) } - } label: { - if db == activeDatabase { - Label(db, systemImage: "checkmark") - } else { - Text(db) - } - } - } - } label: { - HStack(spacing: 4) { - Text(activeDatabase) - .font(.subheadline) - if isSwitching { - ProgressView() - .controlSize(.mini) - } else { - Image(systemName: "chevron.down") - .font(.caption2) - .foregroundStyle(.secondary) - } - } + if supportsDatabaseSwitching && databases.count > 1 { + ToolbarItem(placement: .topBarLeading) { + databaseSwitcherMenu } - .disabled(isSwitching) } - } - if supportsSchemas && schemas.count > 1 && selectedTab == .tables { - ToolbarItem(placement: .topBarTrailing) { - Menu { - ForEach(schemas, id: \.self) { schema in - Button { - Task { await switchSchema(to: schema) } - } label: { - if schema == activeSchema { - Label(schema, systemImage: "checkmark") - } else { - Text(schema) - } - } - } - } label: { - Label(activeSchema, systemImage: "square.3.layers.3d") - .font(.subheadline) + if supportsSchemas && schemas.count > 1 && selectedTab == .tables { + ToolbarItem(placement: .topBarTrailing) { + schemaSwitcherMenu } - .disabled(isSwitching) } } } @@ -219,6 +177,9 @@ struct ConnectedView: View { let key = connection.id.uuidString activeDatabase = UserDefaults.standard.string(forKey: "lastDB.\(key)") ?? "" activeSchema = UserDefaults.standard.string(forKey: "lastSchema.\(key)") ?? "public" + if let savedTable = UserDefaults.standard.string(forKey: "lastTable.\(key)") { + selectedTable = tables.first { $0.name == savedTable } + } let hasDriver = appState.connectionManager.session(for: connection.id)?.driver != nil if !hasDriver, !isConnecting, appError == nil { @@ -232,6 +193,9 @@ struct ConnectedView: View { .onChange(of: activeSchema) { _, newValue in UserDefaults.standard.set(newValue, forKey: "lastSchema.\(connection.id.uuidString)") } + .onChange(of: selectedTable) { _, newValue in + UserDefaults.standard.set(newValue?.name, forKey: "lastTable.\(connection.id.uuidString)") + } .onChange(of: scenePhase) { _, phase in if phase == .active, session != nil { Task { await reconnectIfNeeded() } @@ -239,7 +203,80 @@ struct ConnectedView: View { } } + @ViewBuilder private var connectedContent: some View { + if sizeClass == .regular { + iPadContent + } else { + iPhoneContent + } + } + + private var iPadContent: some View { + NavigationSplitView { + TableListView( + connection: connection, + tables: tables, + session: session, + selectedTable: $selectedTable, + onRefresh: { await refreshTables() } + ) + .navigationTitle(displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if connection.safeModeLevel != .off { + ToolbarItem(placement: .topBarTrailing) { + Image(systemName: connection.safeModeLevel == .readOnly ? "lock.fill" : "shield.fill") + .foregroundStyle(connection.safeModeLevel == .readOnly ? .red : .orange) + .font(.caption) + } + } + if supportsDatabaseSwitching && databases.count > 1 { + ToolbarItem(placement: .topBarLeading) { + databaseSwitcherMenu + } + } + if supportsSchemas && schemas.count > 1 && selectedTab == .tables { + ToolbarItem(placement: .topBarTrailing) { + schemaSwitcherMenu + } + } + } + } detail: { + NavigationStack { + switch selectedTab { + case .tables: + if let table = selectedTable { + DataBrowserView(connection: connection, table: table, session: session) + .id(table.id) + } else { + ContentUnavailableView( + "Select a Table", + systemImage: "tablecells", + description: Text("Choose a table from the sidebar.") + ) + } + case .query: + QueryEditorView( + session: session, + tables: tables, + databaseType: connection.type, + safeModeLevel: connection.safeModeLevel, + queryHistory: $queryHistory, + connectionId: connection.id, + historyStorage: historyStorage + ) + } + } + } + .navigationSplitViewStyle(.balanced) + .safeAreaInset(edge: .bottom) { + tabPicker + .background(.bar) + } + } + + private var iPhoneContent: some View { VStack(spacing: 0) { switch selectedTab { case .tables: @@ -263,6 +300,66 @@ struct ConnectedView: View { } } + private var tabPicker: some View { + Picker("Tab", selection: selectedTabBinding) { + Text("Tables").tag(ConnectedTab.tables) + Text("Query").tag(ConnectedTab.query) + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + } + + private var databaseSwitcherMenu: some View { + Menu { + ForEach(databases, id: \.self) { db in + Button { + Task { await switchDatabase(to: db) } + } label: { + if db == activeDatabase { + Label(db, systemImage: "checkmark") + } else { + Text(db) + } + } + } + } label: { + HStack(spacing: 4) { + Text(activeDatabase) + .font(.subheadline) + if isSwitching { + ProgressView() + .controlSize(.mini) + } else { + Image(systemName: "chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .disabled(isSwitching) + } + + private var schemaSwitcherMenu: some View { + Menu { + ForEach(schemas, id: \.self) { schema in + Button { + Task { await switchSchema(to: schema) } + } label: { + if schema == activeSchema { + Label(schema, systemImage: "checkmark") + } else { + Text(schema) + } + } + } + } label: { + Label(activeSchema, systemImage: "square.3.layers.3d") + .font(.subheadline) + } + .disabled(isSwitching) + } + private func connect() async { guard !isConnectInProgress else { return } guard session == nil else { diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 49db3ee2..c2d0dd48 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -115,7 +115,7 @@ struct ConnectionListView: View { navigateToPendingConnection(appState.pendingConnectionId) } } detail: { - NavigationStack { + if sizeClass == .regular { if let connection = selectedConnection { ConnectedView(connection: connection) .id(connection.id) @@ -126,6 +126,19 @@ struct ConnectionListView: View { description: Text("Choose a connection from the sidebar.") ) } + } else { + NavigationStack { + if let connection = selectedConnection { + ConnectedView(connection: connection) + .id(connection.id) + } else { + ContentUnavailableView( + "Select a Connection", + systemImage: "server.rack", + description: Text("Choose a connection from the sidebar.") + ) + } + } } } .sheet(isPresented: $showingAddConnection) { diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 4902ea29..ff6bf6fa 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -105,6 +105,20 @@ struct DataBrowserView: View { var body: some View { searchableContent + .navigationDestination(for: Int.self) { index in + RowDetailView( + columns: columns, + rows: rows, + initialIndex: index, + table: table, + session: session, + columnDetails: columnDetails, + databaseType: connection.type, + safeModeLevel: connection.safeModeLevel, + foreignKeys: foreignKeys, + onSaved: { Task { await loadData() } } + ) + } .userActivity("com.TablePro.viewTable") { activity in activity.title = table.name activity.isEligibleForHandoff = true @@ -248,20 +262,7 @@ struct DataBrowserView: View { private var rowList: some View { List { ForEach(Array(rows.enumerated()), id: \.offset) { index, row in - NavigationLink { - RowDetailView( - columns: columns, - rows: rows, - initialIndex: index, - table: table, - session: session, - columnDetails: columnDetails, - databaseType: connection.type, - safeModeLevel: connection.safeModeLevel, - foreignKeys: foreignKeys, - onSaved: { Task { await loadData() } } - ) - } label: { + NavigationLink(value: index) { RowCard( columns: columns, columnDetails: columnDetails, diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index a020138d..1c833f09 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -11,6 +11,7 @@ struct TableListView: View { let connection: DatabaseConnection let tables: [TableInfo] let session: ConnectionSession? + var selectedTable: Binding? var onRefresh: (() async -> Void)? @State private var searchText = "" @@ -55,63 +56,18 @@ struct TableListView: View { } var body: some View { - List { - ForEach(tableSections, id: \.0) { sectionTitle, items in - Section { - ForEach(items) { table in - NavigationLink(value: table) { - TableRow(table: table) - } - .contextMenu { - Button { - UIPasteboard.general.string = table.name - } label: { - Label("Copy Name", systemImage: "doc.on.doc") - } - - let isView = table.type == .view || table.type == .materializedView - if !isView && !connection.safeModeLevel.blocksWrites { - Divider() - - Button(role: .destructive) { - tableToTruncate = table - } label: { - Label("Truncate Table", systemImage: "trash.slash") - } - - Button(role: .destructive) { - tableToDrop = table - } label: { - Label("Drop Table", systemImage: "trash") - } - } - } - .hoverEffect() - } - } header: { - HStack { - Text(sectionTitle) - Spacer() - Text("\(items.count)") - .font(.caption) - .foregroundStyle(.tertiary) - } - } + Group { + if let selectedTable { + iPadList(selection: selectedTable) + } else { + iPhoneList } } - .listStyle(.insetGrouped) .searchable(text: $searchText, prompt: "Search tables") .textInputAutocapitalization(.never) .refreshable { await onRefresh?() } - .navigationDestination(for: TableInfo.self) { table in - DataBrowserView( - connection: connection, - table: table, - session: session - ) - } .overlay { if tables.isEmpty { ContentUnavailableView( @@ -177,6 +133,90 @@ struct TableListView: View { Text(errorMessage) } } + + private func iPadList(selection: Binding) -> some View { + List(selection: selection) { + ForEach(tableSections, id: \.0) { sectionTitle, items in + Section { + ForEach(items) { table in + TableRow(table: table) + .tag(table) + .contextMenu { + tableContextMenu(for: table) + } + .hoverEffect() + } + } header: { + sectionHeader(title: sectionTitle, count: items.count) + } + } + } + .listStyle(.sidebar) + } + + private var iPhoneList: some View { + List { + ForEach(tableSections, id: \.0) { sectionTitle, items in + Section { + ForEach(items) { table in + NavigationLink(value: table) { + TableRow(table: table) + } + .contextMenu { + tableContextMenu(for: table) + } + .hoverEffect() + } + } header: { + sectionHeader(title: sectionTitle, count: items.count) + } + } + } + .listStyle(.insetGrouped) + .navigationDestination(for: TableInfo.self) { table in + DataBrowserView( + connection: connection, + table: table, + session: session + ) + } + } + + @ViewBuilder + private func tableContextMenu(for table: TableInfo) -> some View { + Button { + UIPasteboard.general.string = table.name + } label: { + Label("Copy Name", systemImage: "doc.on.doc") + } + + let isView = table.type == .view || table.type == .materializedView + if !isView && !connection.safeModeLevel.blocksWrites { + Divider() + + Button(role: .destructive) { + tableToTruncate = table + } label: { + Label("Truncate Table", systemImage: "trash.slash") + } + + Button(role: .destructive) { + tableToDrop = table + } label: { + Label("Drop Table", systemImage: "trash") + } + } + } + + private func sectionHeader(title: String, count: Int) -> some View { + HStack { + Text(title) + Spacer() + Text("\(count)") + .font(.caption) + .foregroundStyle(.tertiary) + } + } } private struct TableRow: View {