From 12798410f330cfc8ee91bbdbd5931f1cb55f8f07 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 10 May 2026 14:50:06 +0700 Subject: [PATCH 1/8] feat(ios): Live Activity for running queries and iPad multi-window support --- CHANGELOG.md | 2 + .../TableProMobile.xcodeproj/project.pbxproj | 1 + TableProMobile/TableProMobile/Info.plist | 7 ++ .../TableProMobile/Localizable.xcstrings | 20 +++- .../Views/ConnectionListView.swift | 2 +- .../Views/QueryEditorView.swift | 32 ++++++- .../QueryLiveActivityWidget.swift | 96 +++++++++++++++++++ .../TableProWidget/QuickConnectWidget.swift | 9 +- .../Shared/QueryActivityAttributes.swift | 12 +++ 9 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 TableProMobile/TableProWidget/QueryLiveActivityWidget.swift create mode 100644 TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 287e056d3..324e84acf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- iOS: Live Activity for running queries shows query preview, elapsed time, and row count on the lock screen and Dynamic Island +- iOS: multi-window support on iPad - drag a tab off to open a second window, each window remembers its own selected connection across launches - iOS: VoiceOver "Delete row" / "Delete group" / "Delete tag" custom actions on rows whose only deletion path was a swipe gesture - iOS: empty Groups and Tags screens show a Create button so the action is reachable without opening the toolbar - iOS: "No Results" empty state in Query Editor explains the query returned no rows diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index b43df3502..ad26d856b 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -541,6 +541,7 @@ membershipExceptions = ( Shared/SharedConnectionStore.swift, Shared/WidgetConnectionItem.swift, + Shared/QueryActivityAttributes.swift, ); target = 5AB9F3D82F7C1C12001F3337 /* TableProMobile */; }; diff --git a/TableProMobile/TableProMobile/Info.plist b/TableProMobile/TableProMobile/Info.plist index 78b125521..460574592 100644 --- a/TableProMobile/TableProMobile/Info.plist +++ b/TableProMobile/TableProMobile/Info.plist @@ -15,11 +15,18 @@ _postgresql._tcp _redis._tcp + NSSupportsLiveActivities + NSUserActivityTypes com.TablePro.viewConnection com.TablePro.viewTable + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UIBackgroundModes fetch diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index dd1549a00..4dcb09b72 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -1261,6 +1261,9 @@ } } } + }, + "Create Group" : { + }, "Create New Database" : { "localizations" : { @@ -1277,6 +1280,9 @@ } } } + }, + "Create Tag" : { + }, "Database" : { "localizations" : { @@ -1461,6 +1467,9 @@ } } } + }, + "Delete group" : { + }, "Delete Group" : { "localizations" : { @@ -1477,6 +1486,9 @@ } } } + }, + "Delete row" : { + }, "Delete Row" : { "localizations" : { @@ -1493,6 +1505,9 @@ } } } + }, + "Delete tag" : { + }, "Descending" : { "localizations" : { @@ -4209,6 +4224,9 @@ } } } + }, + "The query returned no rows." : { + }, "The server is not responding. Check the host and port." : { "localizations" : { @@ -4672,4 +4690,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 7549779e0..ac0b1faa4 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -7,7 +7,7 @@ struct ConnectionListView: View { @Environment(\.horizontalSizeClass) private var sizeClass @State private var showingAddConnection = false @State private var editingConnection: DatabaseConnection? - @AppStorage("lastConnectionId") private var selectedConnectionIdString: String? + @SceneStorage("lastConnectionId") private var selectedConnectionIdString: String? @State private var columnVisibility: NavigationSplitViewVisibility = .automatic @State private var showingGroupManagement = false @State private var showingTagManagement = false diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 384ce4672..4fb08bead 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -1,3 +1,4 @@ +import ActivityKit import os import SwiftUI import TableProDatabase @@ -394,10 +395,13 @@ struct QueryEditorView: View { editorFocused = false isExecuting = true - executionStartTime = Date() + let startedAt = Date() + executionStartTime = startedAt + let activity = startQueryActivity(trimmed: trimmed, startedAt: startedAt) defer { isExecuting = false executionStartTime = nil + endQueryActivity(activity) } appError = nil @@ -417,4 +421,30 @@ struct QueryEditorView: View { let item = QueryHistoryItem(query: trimmed, connectionId: connectionId) coordinator.addHistoryItem(item) } + + // MARK: - Live Activity + + private func startQueryActivity(trimmed: String, startedAt: Date) -> Activity? { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return nil } + let attributes = QueryActivityAttributes( + connectionName: coordinator.displayName, + queryPreview: String(trimmed.prefix(60)) + ) + let initialState = QueryActivityAttributes.ContentState(elapsed: 0, rowsStreamed: 0) + return try? Activity.request( + attributes: attributes, + content: .init(state: initialState, staleDate: startedAt.addingTimeInterval(60 * 60)) + ) + } + + private func endQueryActivity(_ activity: Activity?) { + guard let activity else { return } + let final = QueryActivityAttributes.ContentState( + elapsed: viewModel.executionTime, + rowsStreamed: viewModel.legacyRows.count + ) + Task { + await activity.end(.init(state: final, staleDate: nil), dismissalPolicy: .immediate) + } + } } diff --git a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift new file mode 100644 index 000000000..664483880 --- /dev/null +++ b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift @@ -0,0 +1,96 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +struct QueryLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: QueryActivityAttributes.self) { context in + lockScreenView(context: context) + .activityBackgroundTint(Color.black.opacity(0.7)) + .activitySystemActionForegroundColor(.white) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Image(systemName: "terminal.fill") + .foregroundStyle(.tint) + } + DynamicIslandExpandedRegion(.trailing) { + Text(formatElapsed(context.state.elapsed)) + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + } + DynamicIslandExpandedRegion(.center) { + Text(context.attributes.connectionName) + .font(.caption) + .foregroundStyle(.secondary) + } + DynamicIslandExpandedRegion(.bottom) { + HStack { + Text(context.attributes.queryPreview) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + if context.state.rowsStreamed > 0 { + Label("\(context.state.rowsStreamed)", systemImage: "list.bullet") + .font(.caption) + .labelStyle(.titleAndIcon) + .foregroundStyle(.secondary) + } + } + } + } compactLeading: { + Image(systemName: "terminal.fill") + } compactTrailing: { + Text(formatElapsed(context.state.elapsed)) + .monospacedDigit() + } minimal: { + Image(systemName: "terminal.fill") + } + } + } + + @ViewBuilder + private func lockScreenView(context: ActivityViewContext) -> some View { + HStack(spacing: 12) { + Image(systemName: "terminal.fill") + .font(.title2) + .foregroundStyle(.tint) + .frame(width: 36, height: 36) + .background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 2) { + Text(context.attributes.connectionName) + .font(.caption) + .foregroundStyle(.secondary) + Text(context.attributes.queryPreview) + .font(.system(.subheadline, design: .monospaced)) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(formatElapsed(context.state.elapsed)) + .font(.body.monospacedDigit()) + if context.state.rowsStreamed > 0 { + Text("\(context.state.rowsStreamed) rows") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + private func formatElapsed(_ seconds: TimeInterval) -> String { + if seconds < 60 { + return String(format: "%.1fs", seconds) + } + let minutes = Int(seconds) / 60 + let secs = Int(seconds) % 60 + return String(format: "%d:%02d", minutes, secs) + } +} diff --git a/TableProMobile/TableProWidget/QuickConnectWidget.swift b/TableProMobile/TableProWidget/QuickConnectWidget.swift index 2f9636c5c..5a665831f 100644 --- a/TableProMobile/TableProWidget/QuickConnectWidget.swift +++ b/TableProMobile/TableProWidget/QuickConnectWidget.swift @@ -1,7 +1,6 @@ import SwiftUI import WidgetKit -@main struct QuickConnectWidget: Widget { let kind = "com.TablePro.QuickConnect" @@ -15,3 +14,11 @@ struct QuickConnectWidget: Widget { .supportedFamilies([.systemSmall, .systemMedium]) } } + +@main +struct TableProWidgetBundle: WidgetBundle { + var body: some Widget { + QuickConnectWidget() + QueryLiveActivityWidget() + } +} diff --git a/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift b/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift new file mode 100644 index 000000000..363961e56 --- /dev/null +++ b/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift @@ -0,0 +1,12 @@ +import ActivityKit +import Foundation + +struct QueryActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var elapsed: TimeInterval + var rowsStreamed: Int + } + + let connectionName: String + let queryPreview: String +} From c4d06c2ce470cf22a5fbe476e42662e9f5c59689 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 10 May 2026 14:54:31 +0700 Subject: [PATCH 2/8] fix(ios): drop UIInputView for the SQL editor accessory to silence keyboard layout conflicts --- .../Views/Components/SQLHighlightTextView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift b/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift index edef4ca4a..e5cf639d6 100644 --- a/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift +++ b/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift @@ -87,9 +87,9 @@ struct SQLHighlightTextView: UIViewRepresentable { // MARK: - Keyboard Accessory Toolbar func makeAccessoryToolbar() -> UIView { - let toolbar = UIInputView(frame: CGRect(x: 0, y: 0, width: 0, height: 44), inputViewStyle: .keyboard) + let toolbar = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 44)) toolbar.autoresizingMask = .flexibleWidth - toolbar.allowsSelfSizing = true + toolbar.backgroundColor = .secondarySystemBackground let separator = UIView() separator.backgroundColor = .separator From 639d917380d07dbfbe926300dbcff209c4578276 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 10 May 2026 15:01:25 +0700 Subject: [PATCH 3/8] fix(ios): drive Live Activity timer from startedAt so the lock screen ticks each second --- .../TableProMobile/Views/QueryEditorView.swift | 13 +++++++++---- .../TableProWidget/QueryLiveActivityWidget.swift | 16 +++++++++++++--- .../Shared/QueryActivityAttributes.swift | 3 ++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 4fb08bead..bbd05fcde 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -401,7 +401,7 @@ struct QueryEditorView: View { defer { isExecuting = false executionStartTime = nil - endQueryActivity(activity) + endQueryActivity(activity, startedAt: startedAt) } appError = nil @@ -430,17 +430,22 @@ struct QueryEditorView: View { connectionName: coordinator.displayName, queryPreview: String(trimmed.prefix(60)) ) - let initialState = QueryActivityAttributes.ContentState(elapsed: 0, rowsStreamed: 0) + let initialState = QueryActivityAttributes.ContentState( + startedAt: startedAt, + endedAt: nil, + rowsStreamed: 0 + ) return try? Activity.request( attributes: attributes, content: .init(state: initialState, staleDate: startedAt.addingTimeInterval(60 * 60)) ) } - private func endQueryActivity(_ activity: Activity?) { + private func endQueryActivity(_ activity: Activity?, startedAt: Date) { guard let activity else { return } let final = QueryActivityAttributes.ContentState( - elapsed: viewModel.executionTime, + startedAt: startedAt, + endedAt: Date(), rowsStreamed: viewModel.legacyRows.count ) Task { diff --git a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift index 664483880..7acbb9b19 100644 --- a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift +++ b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift @@ -15,7 +15,7 @@ struct QueryLiveActivityWidget: Widget { .foregroundStyle(.tint) } DynamicIslandExpandedRegion(.trailing) { - Text(formatElapsed(context.state.elapsed)) + elapsedText(context.state) .font(.body.monospacedDigit()) .foregroundStyle(.secondary) } @@ -42,7 +42,7 @@ struct QueryLiveActivityWidget: Widget { } compactLeading: { Image(systemName: "terminal.fill") } compactTrailing: { - Text(formatElapsed(context.state.elapsed)) + elapsedText(context.state) .monospacedDigit() } minimal: { Image(systemName: "terminal.fill") @@ -72,7 +72,7 @@ struct QueryLiveActivityWidget: Widget { Spacer() VStack(alignment: .trailing, spacing: 2) { - Text(formatElapsed(context.state.elapsed)) + elapsedText(context.state) .font(.body.monospacedDigit()) if context.state.rowsStreamed > 0 { Text("\(context.state.rowsStreamed) rows") @@ -85,6 +85,16 @@ struct QueryLiveActivityWidget: Widget { .padding(.vertical, 10) } + @ViewBuilder + private func elapsedText(_ state: QueryActivityAttributes.ContentState) -> some View { + if let ended = state.endedAt { + Text(formatElapsed(ended.timeIntervalSince(state.startedAt))) + } else { + // System ticks this label every second without app push updates. + Text(timerInterval: state.startedAt...Date.distantFuture, countsDown: false, showsHours: false) + } + } + private func formatElapsed(_ seconds: TimeInterval) -> String { if seconds < 60 { return String(format: "%.1fs", seconds) diff --git a/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift b/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift index 363961e56..520db6d28 100644 --- a/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift +++ b/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift @@ -3,7 +3,8 @@ import Foundation struct QueryActivityAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { - var elapsed: TimeInterval + var startedAt: Date + var endedAt: Date? var rowsStreamed: Int } From 6cfbcbc7066336a2b467c37703036474d4e7ac32 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 10 May 2026 15:06:23 +0700 Subject: [PATCH 4/8] polish(ios): tap Live Activity opens connection, stale after 5min, drop hardcoded background tint --- TableProMobile/TableProMobile/Views/QueryEditorView.swift | 5 ++++- .../TableProWidget/QueryLiveActivityWidget.swift | 7 +++++-- .../TableProWidget/Shared/QueryActivityAttributes.swift | 1 + 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index bbd05fcde..339e03eb9 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -427,6 +427,7 @@ struct QueryEditorView: View { private func startQueryActivity(trimmed: String, startedAt: Date) -> Activity? { guard ActivityAuthorizationInfo().areActivitiesEnabled else { return nil } let attributes = QueryActivityAttributes( + connectionId: coordinator.connection.id, connectionName: coordinator.displayName, queryPreview: String(trimmed.prefix(60)) ) @@ -435,9 +436,11 @@ struct QueryEditorView: View { endedAt: nil, rowsStreamed: 0 ) + // 5-minute stale window: if the app crashes mid-query, iOS marks the + // activity stale instead of showing a forever-ticking timer. return try? Activity.request( attributes: attributes, - content: .init(state: initialState, staleDate: startedAt.addingTimeInterval(60 * 60)) + content: .init(state: initialState, staleDate: startedAt.addingTimeInterval(5 * 60)) ) } diff --git a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift index 7acbb9b19..0c22a6d00 100644 --- a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift +++ b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift @@ -6,8 +6,7 @@ struct QueryLiveActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: QueryActivityAttributes.self) { context in lockScreenView(context: context) - .activityBackgroundTint(Color.black.opacity(0.7)) - .activitySystemActionForegroundColor(.white) + .widgetURL(deepLink(connectionId: context.attributes.connectionId)) } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { @@ -95,6 +94,10 @@ struct QueryLiveActivityWidget: Widget { } } + private func deepLink(connectionId: UUID) -> URL? { + URL(string: "tablepro://connect/\(connectionId.uuidString)") + } + private func formatElapsed(_ seconds: TimeInterval) -> String { if seconds < 60 { return String(format: "%.1fs", seconds) diff --git a/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift b/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift index 520db6d28..da897d0c8 100644 --- a/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift +++ b/TableProMobile/TableProWidget/Shared/QueryActivityAttributes.swift @@ -8,6 +8,7 @@ struct QueryActivityAttributes: ActivityAttributes { var rowsStreamed: Int } + let connectionId: UUID let connectionName: String let queryPreview: String } From 1af7c128a31db466d3e649bad8ae86f087729864 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 10 May 2026 15:09:15 +0700 Subject: [PATCH 5/8] fix(ios): only show Reconnecting chip during actual reconnect, not ping health check --- .../Coordinators/ConnectionCoordinator.swift | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index 4d6cb4472..2995d9ae5 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -132,25 +132,28 @@ final class ConnectionCoordinator { func reconnectIfNeeded() async { guard let session, !isSwitching, !isReconnecting else { return } + do { + _ = try await session.driver.ping() + return + } catch { + // Ping failed; fall through to actual reconnect path below. + } + isReconnecting = true defer { isReconnecting = false } do { - _ = try await session.driver.ping() + await appState.sshProvider.setPendingConnectionId(connection.id) + let newSession = try await appState.connectionManager.connect(connection) + self.session = newSession } catch { - do { - await appState.sshProvider.setPendingConnectionId(connection.id) - let newSession = try await appState.connectionManager.connect(connection) - self.session = newSession - } catch { - let context = ErrorContext( - operation: "reconnect", - databaseType: connection.type, - host: connection.host, - sshEnabled: connection.sshEnabled - ) - phase = .error(ErrorClassifier.classify(error, context: context)) - self.session = nil - } + let context = ErrorContext( + operation: "reconnect", + databaseType: connection.type, + host: connection.host, + sshEnabled: connection.sshEnabled + ) + phase = .error(ErrorClassifier.classify(error, context: context)) + self.session = nil } } From 0499ad1f3b606f90d19589c7e16a22a28fa4b7c5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 10 May 2026 15:18:37 +0700 Subject: [PATCH 6/8] refactor(ios): canonical Live Activity layout and push streaming row count updates --- .../Views/QueryEditorView.swift | 33 +++++ .../QueryLiveActivityWidget.swift | 119 ++++++++++++------ 2 files changed, 115 insertions(+), 37 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 339e03eb9..962923565 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -398,7 +398,9 @@ struct QueryEditorView: View { let startedAt = Date() executionStartTime = startedAt let activity = startQueryActivity(trimmed: trimmed, startedAt: startedAt) + let progressUpdater = startActivityProgressUpdater(activity: activity, startedAt: startedAt) defer { + progressUpdater.cancel() isExecuting = false executionStartTime = nil endQueryActivity(activity, startedAt: startedAt) @@ -444,6 +446,37 @@ struct QueryEditorView: View { ) } + /// Polls the streaming row count once per second while the query runs and pushes + /// `activity.update(state:)` only when the count changes. The system rate-limits + /// activity updates anyway, and the lock screen card just needs a fresh number + /// when the user wakes the device mid-query - it does not need real-time ticks + /// for the count (the elapsed time ticks itself via `Text(timerInterval:)`). + private func startActivityProgressUpdater( + activity: Activity?, + startedAt: Date + ) -> Task { + Task { [weak viewModel] in + guard let activity else { return } + var lastReportedCount = 0 + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + if Task.isCancelled { return } + let count = viewModel?.legacyRows.count ?? 0 + guard count != lastReportedCount else { continue } + lastReportedCount = count + let state = QueryActivityAttributes.ContentState( + startedAt: startedAt, + endedAt: nil, + rowsStreamed: count + ) + await activity.update(.init( + state: state, + staleDate: startedAt.addingTimeInterval(5 * 60) + )) + } + } + } + private func endQueryActivity(_ activity: Activity?, startedAt: Date) { guard let activity else { return } let final = QueryActivityAttributes.ContentState( diff --git a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift index 0c22a6d00..488ed6078 100644 --- a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift +++ b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift @@ -9,46 +9,24 @@ struct QueryLiveActivityWidget: Widget { .widgetURL(deepLink(connectionId: context.attributes.connectionId)) } dynamicIsland: { context in DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - Image(systemName: "terminal.fill") - .foregroundStyle(.tint) - } - DynamicIslandExpandedRegion(.trailing) { - elapsedText(context.state) - .font(.body.monospacedDigit()) - .foregroundStyle(.secondary) - } - DynamicIslandExpandedRegion(.center) { - Text(context.attributes.connectionName) - .font(.caption) - .foregroundStyle(.secondary) - } - DynamicIslandExpandedRegion(.bottom) { - HStack { - Text(context.attributes.queryPreview) - .font(.system(.footnote, design: .monospaced)) - .lineLimit(1) - .truncationMode(.tail) - Spacer() - if context.state.rowsStreamed > 0 { - Label("\(context.state.rowsStreamed)", systemImage: "list.bullet") - .font(.caption) - .labelStyle(.titleAndIcon) - .foregroundStyle(.secondary) - } - } - } + expandedLeading(context: context) + expandedTrailing(context: context) + expandedCenter(context: context) + expandedBottom(context: context) } compactLeading: { Image(systemName: "terminal.fill") + .foregroundStyle(.tint) } compactTrailing: { - elapsedText(context.state) - .monospacedDigit() + compactStatus(state: context.state) } minimal: { - Image(systemName: "terminal.fill") + compactStatus(state: context.state) } + .widgetURL(deepLink(connectionId: context.attributes.connectionId)) } } + // MARK: - Lock Screen + @ViewBuilder private func lockScreenView(context: ActivityViewContext) -> some View { HStack(spacing: 12) { @@ -60,10 +38,10 @@ struct QueryLiveActivityWidget: Widget { VStack(alignment: .leading, spacing: 2) { Text(context.attributes.connectionName) - .font(.caption) - .foregroundStyle(.secondary) + .font(.subheadline.weight(.medium)) Text(context.attributes.queryPreview) - .font(.system(.subheadline, design: .monospaced)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) } @@ -74,7 +52,7 @@ struct QueryLiveActivityWidget: Widget { elapsedText(context.state) .font(.body.monospacedDigit()) if context.state.rowsStreamed > 0 { - Text("\(context.state.rowsStreamed) rows") + Text("^[\(context.state.rowsStreamed) row](inflect: true)") .font(.caption2) .foregroundStyle(.secondary) } @@ -84,12 +62,79 @@ struct QueryLiveActivityWidget: Widget { .padding(.vertical, 10) } + // MARK: - Dynamic Island Expanded + + @DynamicIslandExpandedContentBuilder + private func expandedLeading(context: ActivityViewContext) -> DynamicIslandExpandedRegion { + DynamicIslandExpandedRegion(.leading) { + Image(systemName: "terminal.fill") + .font(.title3) + .foregroundStyle(.tint) + .frame(width: 32, height: 32) + .background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 7)) + } + } + + @DynamicIslandExpandedContentBuilder + private func expandedTrailing(context: ActivityViewContext) -> DynamicIslandExpandedRegion { + DynamicIslandExpandedRegion(.trailing) { + elapsedText(context.state) + .font(.title3.monospacedDigit()) + .foregroundStyle(context.state.endedAt == nil ? .primary : .secondary) + } + } + + @DynamicIslandExpandedContentBuilder + private func expandedCenter(context: ActivityViewContext) -> DynamicIslandExpandedRegion { + DynamicIslandExpandedRegion(.center) { + Text(context.attributes.connectionName) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + } + } + + @DynamicIslandExpandedContentBuilder + private func expandedBottom(context: ActivityViewContext) -> DynamicIslandExpandedRegion { + DynamicIslandExpandedRegion(.bottom) { + HStack { + Text(context.attributes.queryPreview) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + if context.state.rowsStreamed > 0 { + Label("^[\(context.state.rowsStreamed) row](inflect: true)", systemImage: "list.bullet") + .font(.caption) + .labelStyle(.titleAndIcon) + .foregroundStyle(.secondary) + } + } + } + } + + // MARK: - Compact / Minimal Status + + @ViewBuilder + private func compactStatus(state: QueryActivityAttributes.ContentState) -> some View { + if state.endedAt != nil { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.mini) + .tint(.tint) + } + } + + // MARK: - Helpers + @ViewBuilder private func elapsedText(_ state: QueryActivityAttributes.ContentState) -> some View { if let ended = state.endedAt { Text(formatElapsed(ended.timeIntervalSince(state.startedAt))) } else { - // System ticks this label every second without app push updates. Text(timerInterval: state.startedAt...Date.distantFuture, countsDown: false, showsHours: false) } } From bb9a38e64fcc0d1667c0195450a85b51e08428e9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 10 May 2026 15:20:50 +0700 Subject: [PATCH 7/8] fix(ios): inline DynamicIsland expanded regions instead of extracting them as helpers --- .../QueryLiveActivityWidget.swift | 89 +++++++------------ 1 file changed, 33 insertions(+), 56 deletions(-) diff --git a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift index 488ed6078..78d65527e 100644 --- a/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift +++ b/TableProMobile/TableProWidget/QueryLiveActivityWidget.swift @@ -9,10 +9,39 @@ struct QueryLiveActivityWidget: Widget { .widgetURL(deepLink(connectionId: context.attributes.connectionId)) } dynamicIsland: { context in DynamicIsland { - expandedLeading(context: context) - expandedTrailing(context: context) - expandedCenter(context: context) - expandedBottom(context: context) + DynamicIslandExpandedRegion(.leading) { + Image(systemName: "terminal.fill") + .font(.title3) + .foregroundStyle(.tint) + .frame(width: 32, height: 32) + .background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 7)) + } + DynamicIslandExpandedRegion(.trailing) { + elapsedText(context.state) + .font(.title3.monospacedDigit()) + .foregroundStyle(context.state.endedAt == nil ? .primary : .secondary) + } + DynamicIslandExpandedRegion(.center) { + Text(context.attributes.connectionName) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.bottom) { + HStack { + Text(context.attributes.queryPreview) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + if context.state.rowsStreamed > 0 { + Label("^[\(context.state.rowsStreamed) row](inflect: true)", systemImage: "list.bullet") + .font(.caption) + .labelStyle(.titleAndIcon) + .foregroundStyle(.secondary) + } + } + } } compactLeading: { Image(systemName: "terminal.fill") .foregroundStyle(.tint) @@ -62,57 +91,6 @@ struct QueryLiveActivityWidget: Widget { .padding(.vertical, 10) } - // MARK: - Dynamic Island Expanded - - @DynamicIslandExpandedContentBuilder - private func expandedLeading(context: ActivityViewContext) -> DynamicIslandExpandedRegion { - DynamicIslandExpandedRegion(.leading) { - Image(systemName: "terminal.fill") - .font(.title3) - .foregroundStyle(.tint) - .frame(width: 32, height: 32) - .background(.tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 7)) - } - } - - @DynamicIslandExpandedContentBuilder - private func expandedTrailing(context: ActivityViewContext) -> DynamicIslandExpandedRegion { - DynamicIslandExpandedRegion(.trailing) { - elapsedText(context.state) - .font(.title3.monospacedDigit()) - .foregroundStyle(context.state.endedAt == nil ? .primary : .secondary) - } - } - - @DynamicIslandExpandedContentBuilder - private func expandedCenter(context: ActivityViewContext) -> DynamicIslandExpandedRegion { - DynamicIslandExpandedRegion(.center) { - Text(context.attributes.connectionName) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - } - } - - @DynamicIslandExpandedContentBuilder - private func expandedBottom(context: ActivityViewContext) -> DynamicIslandExpandedRegion { - DynamicIslandExpandedRegion(.bottom) { - HStack { - Text(context.attributes.queryPreview) - .font(.system(.footnote, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - Spacer() - if context.state.rowsStreamed > 0 { - Label("^[\(context.state.rowsStreamed) row](inflect: true)", systemImage: "list.bullet") - .font(.caption) - .labelStyle(.titleAndIcon) - .foregroundStyle(.secondary) - } - } - } - } - // MARK: - Compact / Minimal Status @ViewBuilder @@ -124,7 +102,6 @@ struct QueryLiveActivityWidget: Widget { ProgressView() .progressViewStyle(.circular) .controlSize(.mini) - .tint(.tint) } } From e3b4d2a82f04430a536fef39b5885e72414812e0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 10 May 2026 15:39:08 +0700 Subject: [PATCH 8/8] feat(ios): hide query preview in Live Activities setting --- CHANGELOG.md | 2 +- .../TableProMobile/Localizable.xcstrings | 89 +++++++++++++++++-- .../Platform/AppPreferences.swift | 5 ++ .../Views/QueryEditorView.swift | 5 +- .../TableProMobile/Views/SettingsView.swift | 9 +- 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 324e84acf..39cf078f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS: iCloud sync runs every 30 minutes in the background via `BGAppRefreshTask` while the app is closed (gated by the iCloud Sync setting); iOS schedules the actual cadence based on usage and battery - iOS: Cmd+F focuses the search field in Tables and Data Browser (iPad keyboard canonical) - iOS: search text in Tables and Data Browser persists across process kill via `@SceneStorage` (per-window on iPad) -- iOS Settings: iCloud Sync toggle (off keeps connections, groups, and tags on this device only and disables the sync toolbar button), Rows per Page picker (50/100/200/500, applied to new data browser sessions), Default Safe Mode picker (applied when adding a new connection) +- iOS Settings: iCloud Sync toggle (off keeps connections, groups, and tags on this device only and disables the sync toolbar button), Rows per Page picker (50/100/200/500, applied to new data browser sessions), Default Safe Mode picker (applied when adding a new connection), "Hide query in Live Activities" toggle that swaps the SQL preview for a generic "Running query" label on the lock screen and Dynamic Island - iOS: alert when the active connection is deleted mid-session (for example via iCloud sync from another device), so a stale screen no longer fails silently on the next action - iOS: Face ID, Touch ID, or Optic ID lock with cold-launch protection and idle timeout (1, 5, 15, or 60 minutes), opt-in from Settings - iOS: Connection Info tab replaces the per-connection Settings tab, showing host, SSL, SSH tunnel, active database, and live connection status diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 4dcb09b72..4fc4b8d04 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -1263,7 +1263,14 @@ } }, "Create Group" : { - + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo nhóm" + } + } + } }, "Create New Database" : { "localizations" : { @@ -1282,7 +1289,14 @@ } }, "Create Tag" : { - + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạo thẻ" + } + } + } }, "Database" : { "localizations" : { @@ -1469,7 +1483,14 @@ } }, "Delete group" : { - + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa nhóm" + } + } + } }, "Delete Group" : { "localizations" : { @@ -1488,7 +1509,14 @@ } }, "Delete row" : { - + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa hàng" + } + } + } }, "Delete Row" : { "localizations" : { @@ -1507,7 +1535,14 @@ } }, "Delete tag" : { - + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa thẻ" + } + } + } }, "Descending" : { "localizations" : { @@ -4226,7 +4261,14 @@ } }, "The query returned no rows." : { - + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn không trả về hàng nào." + } + } + } }, "The server is not responding. Check the host and port." : { "localizations" : { @@ -4687,7 +4729,40 @@ } } } + }, + "Hide query in Live Activities" : { + "extractionState" : "manual", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ẩn truy vấn trong Live Activity" + } + } + } + }, + "When on, the lock screen and Dynamic Island show \"Running query\" instead of the SQL preview." : { + "extractionState" : "manual", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khi bật, màn hình khóa và Dynamic Island hiển thị \"Đang chạy truy vấn\" thay cho nội dung SQL." + } + } + } + }, + "Running query" : { + "extractionState" : "manual", + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang chạy truy vấn" + } + } + } } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/TableProMobile/TableProMobile/Platform/AppPreferences.swift b/TableProMobile/TableProMobile/Platform/AppPreferences.swift index aa23b0931..d5716afca 100644 --- a/TableProMobile/TableProMobile/Platform/AppPreferences.swift +++ b/TableProMobile/TableProMobile/Platform/AppPreferences.swift @@ -5,6 +5,7 @@ enum AppPreferences { static let cloudSyncEnabledKey = "com.TablePro.settings.cloudSyncEnabled" static let defaultPageSizeKey = "com.TablePro.settings.defaultPageSize" static let defaultSafeModeKey = "com.TablePro.settings.defaultSafeMode" + static let hideQueryPreviewInActivityKey = "com.TablePro.settings.hideQueryPreviewInActivity" static let pageSizeOptions: [Int] = [50, 100, 200, 500] @@ -23,4 +24,8 @@ enum AppPreferences { let level = SafeModeLevel(rawValue: raw) else { return .off } return level } + + static var hidesQueryPreviewInActivity: Bool { + UserDefaults.standard.bool(forKey: hideQueryPreviewInActivityKey) + } } diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 962923565..690d2d128 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -428,10 +428,13 @@ struct QueryEditorView: View { private func startQueryActivity(trimmed: String, startedAt: Date) -> Activity? { guard ActivityAuthorizationInfo().areActivitiesEnabled else { return nil } + let preview: String = AppPreferences.hidesQueryPreviewInActivity + ? String(localized: "Running query") + : String(trimmed.prefix(60)) let attributes = QueryActivityAttributes( connectionId: coordinator.connection.id, connectionName: coordinator.displayName, - queryPreview: String(trimmed.prefix(60)) + queryPreview: preview ) let initialState = QueryActivityAttributes.ContentState( startedAt: startedAt, diff --git a/TableProMobile/TableProMobile/Views/SettingsView.swift b/TableProMobile/TableProMobile/Views/SettingsView.swift index 24506cb0b..1efd620d0 100644 --- a/TableProMobile/TableProMobile/Views/SettingsView.swift +++ b/TableProMobile/TableProMobile/Views/SettingsView.swift @@ -8,6 +8,7 @@ struct SettingsView: View { @AppStorage(AppPreferences.cloudSyncEnabledKey) private var cloudSyncEnabled = true @AppStorage(AppPreferences.defaultPageSizeKey) private var defaultPageSize = 100 @AppStorage(AppPreferences.defaultSafeModeKey) private var defaultSafeModeRaw = SafeModeLevel.off.rawValue + @AppStorage(AppPreferences.hideQueryPreviewInActivityKey) private var hideQueryPreviewInActivity = false private let auth = BiometricAuthService() @@ -17,12 +18,18 @@ struct SettingsView: View { syncSection defaultsSection - Section("Privacy") { + Section { Toggle(String(localized: "Share anonymous usage data"), isOn: $shareAnalytics) Text("Help improve TablePro by sharing anonymous usage statistics (no personal data or queries).") .font(.caption) .foregroundStyle(.secondary) + + Toggle(String(localized: "Hide query in Live Activities"), isOn: $hideQueryPreviewInActivity) + } header: { + Text("Privacy") + } footer: { + Text("When on, the lock screen and Dynamic Island show \"Running query\" instead of the SQL preview.") } Section("About") {