Skip to content

Commit a21022d

Browse files
authored
feat(ios): Live Activity for running queries and iPad multi-window support (#1182)
* feat(ios): Live Activity for running queries and iPad multi-window support * fix(ios): drop UIInputView for the SQL editor accessory to silence keyboard layout conflicts * fix(ios): drive Live Activity timer from startedAt so the lock screen ticks each second * polish(ios): tap Live Activity opens connection, stale after 5min, drop hardcoded background tint * fix(ios): only show Reconnecting chip during actual reconnect, not ping health check * refactor(ios): canonical Live Activity layout and push streaming row count updates * fix(ios): inline DynamicIsland expanded regions instead of extracting them as helpers * feat(ios): hide query preview in Live Activities setting
1 parent 55f2850 commit a21022d

13 files changed

Lines changed: 366 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- iOS: Live Activity for running queries shows query preview, elapsed time, and row count on the lock screen and Dynamic Island
13+
- iOS: multi-window support on iPad - drag a tab off to open a second window, each window remembers its own selected connection across launches
1214
- iOS: VoiceOver "Delete row" / "Delete group" / "Delete tag" custom actions on rows whose only deletion path was a swipe gesture
1315
- iOS: empty Groups and Tags screens show a Create button so the action is reachable without opening the toolbar
1416
- iOS: "No Results" empty state in Query Editor explains the query returned no rows
1517
- 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
1618
- iOS: Cmd+F focuses the search field in Tables and Data Browser (iPad keyboard canonical)
1719
- iOS: search text in Tables and Data Browser persists across process kill via `@SceneStorage` (per-window on iPad)
18-
- 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)
20+
- 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
1921
- 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
2022
- 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
2123
- iOS: Connection Info tab replaces the per-connection Settings tab, showing host, SSL, SSH tunnel, active database, and live connection status

TableProMobile/TableProMobile.xcodeproj/project.pbxproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@
541541
membershipExceptions = (
542542
Shared/SharedConnectionStore.swift,
543543
Shared/WidgetConnectionItem.swift,
544+
Shared/QueryActivityAttributes.swift,
544545
);
545546
target = 5AB9F3D82F7C1C12001F3337 /* TableProMobile */;
546547
};

TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -132,25 +132,28 @@ final class ConnectionCoordinator {
132132

133133
func reconnectIfNeeded() async {
134134
guard let session, !isSwitching, !isReconnecting else { return }
135+
do {
136+
_ = try await session.driver.ping()
137+
return
138+
} catch {
139+
// Ping failed; fall through to actual reconnect path below.
140+
}
141+
135142
isReconnecting = true
136143
defer { isReconnecting = false }
137144
do {
138-
_ = try await session.driver.ping()
145+
await appState.sshProvider.setPendingConnectionId(connection.id)
146+
let newSession = try await appState.connectionManager.connect(connection)
147+
self.session = newSession
139148
} catch {
140-
do {
141-
await appState.sshProvider.setPendingConnectionId(connection.id)
142-
let newSession = try await appState.connectionManager.connect(connection)
143-
self.session = newSession
144-
} catch {
145-
let context = ErrorContext(
146-
operation: "reconnect",
147-
databaseType: connection.type,
148-
host: connection.host,
149-
sshEnabled: connection.sshEnabled
150-
)
151-
phase = .error(ErrorClassifier.classify(error, context: context))
152-
self.session = nil
153-
}
149+
let context = ErrorContext(
150+
operation: "reconnect",
151+
databaseType: connection.type,
152+
host: connection.host,
153+
sshEnabled: connection.sshEnabled
154+
)
155+
phase = .error(ErrorClassifier.classify(error, context: context))
156+
self.session = nil
154157
}
155158
}
156159

TableProMobile/TableProMobile/Info.plist

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,18 @@
1515
<string>_postgresql._tcp</string>
1616
<string>_redis._tcp</string>
1717
</array>
18+
<key>NSSupportsLiveActivities</key>
19+
<true/>
1820
<key>NSUserActivityTypes</key>
1921
<array>
2022
<string>com.TablePro.viewConnection</string>
2123
<string>com.TablePro.viewTable</string>
2224
</array>
25+
<key>UIApplicationSceneManifest</key>
26+
<dict>
27+
<key>UIApplicationSupportsMultipleScenes</key>
28+
<true/>
29+
</dict>
2330
<key>UIBackgroundModes</key>
2431
<array>
2532
<string>fetch</string>

TableProMobile/TableProMobile/Localizable.xcstrings

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,6 +1262,16 @@
12621262
}
12631263
}
12641264
},
1265+
"Create Group" : {
1266+
"localizations" : {
1267+
"vi" : {
1268+
"stringUnit" : {
1269+
"state" : "translated",
1270+
"value" : "Tạo nhóm"
1271+
}
1272+
}
1273+
}
1274+
},
12651275
"Create New Database" : {
12661276
"localizations" : {
12671277
"vi" : {
@@ -1278,6 +1288,16 @@
12781288
}
12791289
}
12801290
},
1291+
"Create Tag" : {
1292+
"localizations" : {
1293+
"vi" : {
1294+
"stringUnit" : {
1295+
"state" : "translated",
1296+
"value" : "Tạo thẻ"
1297+
}
1298+
}
1299+
}
1300+
},
12811301
"Database" : {
12821302
"localizations" : {
12831303
"vi" : {
@@ -1462,6 +1482,16 @@
14621482
}
14631483
}
14641484
},
1485+
"Delete group" : {
1486+
"localizations" : {
1487+
"vi" : {
1488+
"stringUnit" : {
1489+
"state" : "translated",
1490+
"value" : "Xóa nhóm"
1491+
}
1492+
}
1493+
}
1494+
},
14651495
"Delete Group" : {
14661496
"localizations" : {
14671497
"vi" : {
@@ -1478,6 +1508,16 @@
14781508
}
14791509
}
14801510
},
1511+
"Delete row" : {
1512+
"localizations" : {
1513+
"vi" : {
1514+
"stringUnit" : {
1515+
"state" : "translated",
1516+
"value" : "Xóa hàng"
1517+
}
1518+
}
1519+
}
1520+
},
14811521
"Delete Row" : {
14821522
"localizations" : {
14831523
"vi" : {
@@ -1494,6 +1534,16 @@
14941534
}
14951535
}
14961536
},
1537+
"Delete tag" : {
1538+
"localizations" : {
1539+
"vi" : {
1540+
"stringUnit" : {
1541+
"state" : "translated",
1542+
"value" : "Xóa thẻ"
1543+
}
1544+
}
1545+
}
1546+
},
14971547
"Descending" : {
14981548
"localizations" : {
14991549
"vi" : {
@@ -4210,6 +4260,16 @@
42104260
}
42114261
}
42124262
},
4263+
"The query returned no rows." : {
4264+
"localizations" : {
4265+
"vi" : {
4266+
"stringUnit" : {
4267+
"state" : "translated",
4268+
"value" : "Truy vấn không trả về hàng nào."
4269+
}
4270+
}
4271+
}
4272+
},
42134273
"The server is not responding. Check the host and port." : {
42144274
"localizations" : {
42154275
"vi" : {
@@ -4669,6 +4729,39 @@
46694729
}
46704730
}
46714731
}
4732+
},
4733+
"Hide query in Live Activities" : {
4734+
"extractionState" : "manual",
4735+
"localizations" : {
4736+
"vi" : {
4737+
"stringUnit" : {
4738+
"state" : "translated",
4739+
"value" : "Ẩn truy vấn trong Live Activity"
4740+
}
4741+
}
4742+
}
4743+
},
4744+
"When on, the lock screen and Dynamic Island show \"Running query\" instead of the SQL preview." : {
4745+
"extractionState" : "manual",
4746+
"localizations" : {
4747+
"vi" : {
4748+
"stringUnit" : {
4749+
"state" : "translated",
4750+
"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."
4751+
}
4752+
}
4753+
}
4754+
},
4755+
"Running query" : {
4756+
"extractionState" : "manual",
4757+
"localizations" : {
4758+
"vi" : {
4759+
"stringUnit" : {
4760+
"state" : "translated",
4761+
"value" : "Đang chạy truy vấn"
4762+
}
4763+
}
4764+
}
46724765
}
46734766
},
46744767
"version" : "1.0"

TableProMobile/TableProMobile/Platform/AppPreferences.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ enum AppPreferences {
55
static let cloudSyncEnabledKey = "com.TablePro.settings.cloudSyncEnabled"
66
static let defaultPageSizeKey = "com.TablePro.settings.defaultPageSize"
77
static let defaultSafeModeKey = "com.TablePro.settings.defaultSafeMode"
8+
static let hideQueryPreviewInActivityKey = "com.TablePro.settings.hideQueryPreviewInActivity"
89

910
static let pageSizeOptions: [Int] = [50, 100, 200, 500]
1011

@@ -23,4 +24,8 @@ enum AppPreferences {
2324
let level = SafeModeLevel(rawValue: raw) else { return .off }
2425
return level
2526
}
27+
28+
static var hidesQueryPreviewInActivity: Bool {
29+
UserDefaults.standard.bool(forKey: hideQueryPreviewInActivityKey)
30+
}
2631
}

TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ struct SQLHighlightTextView: UIViewRepresentable {
8787
// MARK: - Keyboard Accessory Toolbar
8888

8989
func makeAccessoryToolbar() -> UIView {
90-
let toolbar = UIInputView(frame: CGRect(x: 0, y: 0, width: 0, height: 44), inputViewStyle: .keyboard)
90+
let toolbar = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 44))
9191
toolbar.autoresizingMask = .flexibleWidth
92-
toolbar.allowsSelfSizing = true
92+
toolbar.backgroundColor = .secondarySystemBackground
9393

9494
let separator = UIView()
9595
separator.backgroundColor = .separator

TableProMobile/TableProMobile/Views/ConnectionListView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ struct ConnectionListView: View {
77
@Environment(\.horizontalSizeClass) private var sizeClass
88
@State private var showingAddConnection = false
99
@State private var editingConnection: DatabaseConnection?
10-
@AppStorage("lastConnectionId") private var selectedConnectionIdString: String?
10+
@SceneStorage("lastConnectionId") private var selectedConnectionIdString: String?
1111
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
1212
@State private var showingGroupManagement = false
1313
@State private var showingTagManagement = false

TableProMobile/TableProMobile/Views/QueryEditorView.swift

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import ActivityKit
12
import os
23
import SwiftUI
34
import TableProDatabase
@@ -394,10 +395,15 @@ struct QueryEditorView: View {
394395

395396
editorFocused = false
396397
isExecuting = true
397-
executionStartTime = Date()
398+
let startedAt = Date()
399+
executionStartTime = startedAt
400+
let activity = startQueryActivity(trimmed: trimmed, startedAt: startedAt)
401+
let progressUpdater = startActivityProgressUpdater(activity: activity, startedAt: startedAt)
398402
defer {
403+
progressUpdater.cancel()
399404
isExecuting = false
400405
executionStartTime = nil
406+
endQueryActivity(activity, startedAt: startedAt)
401407
}
402408
appError = nil
403409

@@ -417,4 +423,72 @@ struct QueryEditorView: View {
417423
let item = QueryHistoryItem(query: trimmed, connectionId: connectionId)
418424
coordinator.addHistoryItem(item)
419425
}
426+
427+
// MARK: - Live Activity
428+
429+
private func startQueryActivity(trimmed: String, startedAt: Date) -> Activity<QueryActivityAttributes>? {
430+
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return nil }
431+
let preview: String = AppPreferences.hidesQueryPreviewInActivity
432+
? String(localized: "Running query")
433+
: String(trimmed.prefix(60))
434+
let attributes = QueryActivityAttributes(
435+
connectionId: coordinator.connection.id,
436+
connectionName: coordinator.displayName,
437+
queryPreview: preview
438+
)
439+
let initialState = QueryActivityAttributes.ContentState(
440+
startedAt: startedAt,
441+
endedAt: nil,
442+
rowsStreamed: 0
443+
)
444+
// 5-minute stale window: if the app crashes mid-query, iOS marks the
445+
// activity stale instead of showing a forever-ticking timer.
446+
return try? Activity.request(
447+
attributes: attributes,
448+
content: .init(state: initialState, staleDate: startedAt.addingTimeInterval(5 * 60))
449+
)
450+
}
451+
452+
/// Polls the streaming row count once per second while the query runs and pushes
453+
/// `activity.update(state:)` only when the count changes. The system rate-limits
454+
/// activity updates anyway, and the lock screen card just needs a fresh number
455+
/// when the user wakes the device mid-query - it does not need real-time ticks
456+
/// for the count (the elapsed time ticks itself via `Text(timerInterval:)`).
457+
private func startActivityProgressUpdater(
458+
activity: Activity<QueryActivityAttributes>?,
459+
startedAt: Date
460+
) -> Task<Void, Never> {
461+
Task { [weak viewModel] in
462+
guard let activity else { return }
463+
var lastReportedCount = 0
464+
while !Task.isCancelled {
465+
try? await Task.sleep(for: .seconds(1))
466+
if Task.isCancelled { return }
467+
let count = viewModel?.legacyRows.count ?? 0
468+
guard count != lastReportedCount else { continue }
469+
lastReportedCount = count
470+
let state = QueryActivityAttributes.ContentState(
471+
startedAt: startedAt,
472+
endedAt: nil,
473+
rowsStreamed: count
474+
)
475+
await activity.update(.init(
476+
state: state,
477+
staleDate: startedAt.addingTimeInterval(5 * 60)
478+
))
479+
}
480+
}
481+
}
482+
483+
private func endQueryActivity(_ activity: Activity<QueryActivityAttributes>?, startedAt: Date) {
484+
guard let activity else { return }
485+
let final = QueryActivityAttributes.ContentState(
486+
startedAt: startedAt,
487+
endedAt: Date(),
488+
rowsStreamed: viewModel.legacyRows.count
489+
)
490+
Task {
491+
await activity.end(.init(state: final, staleDate: nil), dismissalPolicy: .immediate)
492+
}
493+
}
420494
}

TableProMobile/TableProMobile/Views/SettingsView.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ struct SettingsView: View {
88
@AppStorage(AppPreferences.cloudSyncEnabledKey) private var cloudSyncEnabled = true
99
@AppStorage(AppPreferences.defaultPageSizeKey) private var defaultPageSize = 100
1010
@AppStorage(AppPreferences.defaultSafeModeKey) private var defaultSafeModeRaw = SafeModeLevel.off.rawValue
11+
@AppStorage(AppPreferences.hideQueryPreviewInActivityKey) private var hideQueryPreviewInActivity = false
1112

1213
private let auth = BiometricAuthService()
1314

@@ -17,12 +18,18 @@ struct SettingsView: View {
1718
syncSection
1819
defaultsSection
1920

20-
Section("Privacy") {
21+
Section {
2122
Toggle(String(localized: "Share anonymous usage data"), isOn: $shareAnalytics)
2223

2324
Text("Help improve TablePro by sharing anonymous usage statistics (no personal data or queries).")
2425
.font(.caption)
2526
.foregroundStyle(.secondary)
27+
28+
Toggle(String(localized: "Hide query in Live Activities"), isOn: $hideQueryPreviewInActivity)
29+
} header: {
30+
Text("Privacy")
31+
} footer: {
32+
Text("When on, the lock screen and Dynamic Island show \"Running query\" instead of the SQL preview.")
2633
}
2734

2835
Section("About") {

0 commit comments

Comments
 (0)