Skip to content

Commit 02be250

Browse files
authored
feat(ios): P0 HIG compliance fixes (#674)
* fix(ios): add confirmation dialogs for connection and group deletion * feat(ios): add SQL keyword accessory toolbar to query editor * feat(ios): add cancel button to connection attempt * feat(ios): add haptic feedback for key user actions * fix(ios): prevent row disappear-reappear on destructive swipe with confirmation Remove `role: .destructive` from swipe action buttons that show a confirmation dialog instead of deleting directly. The destructive role causes SwiftUI to animate the row away immediately, then it reappears when the dialog shows. Use `.tint(.red)` instead to keep the red color. * fix(ios): use structured concurrency for connection cancellation Remove unstructured Task in .task modifier. dismiss() on Cancel removes the view, .task auto-cancels, and existing Task.isCancelled guards in connect()/connectFresh() handle cleanup.
1 parent a397461 commit 02be250

9 files changed

Lines changed: 203 additions & 16 deletions

TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct SQLHighlightTextView: UIViewRepresentable {
1313

1414
func makeUIView(context: Context) -> UITextView {
1515
let textView = UITextView()
16+
context.coordinator.textView = textView
1617
textView.delegate = context.coordinator
1718
textView.font = Self.font
1819
textView.autocorrectionType = .no
@@ -25,6 +26,7 @@ struct SQLHighlightTextView: UIViewRepresentable {
2526
textView.backgroundColor = .clear
2627
textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
2728
textView.textStorage.delegate = context.coordinator
29+
textView.inputAccessoryView = context.coordinator.makeAccessoryToolbar()
2830
return textView
2931
}
3032

@@ -45,6 +47,7 @@ struct SQLHighlightTextView: UIViewRepresentable {
4547
class Coordinator: NSObject, UITextViewDelegate, NSTextStorageDelegate {
4648
var parent: SQLHighlightTextView
4749
var isUpdating = false
50+
weak var textView: UITextView?
4851

4952
init(_ parent: SQLHighlightTextView) {
5053
self.parent = parent
@@ -62,10 +65,96 @@ struct SQLHighlightTextView: UIViewRepresentable {
6265
changeInLength delta: Int
6366
) {
6467
guard editedMask.contains(.editedCharacters), !isUpdating else { return }
65-
// Defer to avoid re-entrant editing during processEditing
6668
DispatchQueue.main.async {
6769
SQLSyntaxHighlighter.highlight(textStorage, in: editedRange)
6870
}
6971
}
72+
73+
// MARK: - Keyboard Accessory Toolbar
74+
75+
func makeAccessoryToolbar() -> UIView {
76+
let toolbar = UIView(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 44))
77+
toolbar.backgroundColor = .secondarySystemBackground
78+
79+
let separator = UIView()
80+
separator.backgroundColor = .separator
81+
separator.translatesAutoresizingMaskIntoConstraints = false
82+
toolbar.addSubview(separator)
83+
84+
let scrollView = UIScrollView()
85+
scrollView.showsHorizontalScrollIndicator = false
86+
scrollView.translatesAutoresizingMaskIntoConstraints = false
87+
88+
let stackView = UIStackView()
89+
stackView.axis = .horizontal
90+
stackView.spacing = 8
91+
stackView.translatesAutoresizingMaskIntoConstraints = false
92+
93+
let keywords = ["SELECT", "FROM", "WHERE", "JOIN", "AND", "OR", "INSERT", "UPDATE", "DELETE", "*", "(", ")", ";"]
94+
for keyword in keywords {
95+
stackView.addArrangedSubview(makeKeywordButton(keyword))
96+
}
97+
98+
scrollView.addSubview(stackView)
99+
100+
let doneButton = UIButton(type: .system)
101+
doneButton.setTitle(String(localized: "Done"), for: .normal)
102+
doneButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
103+
doneButton.addTarget(self, action: #selector(dismissKeyboard), for: .touchUpInside)
104+
doneButton.translatesAutoresizingMaskIntoConstraints = false
105+
doneButton.setContentHuggingPriority(.required, for: .horizontal)
106+
doneButton.setContentCompressionResistancePriority(.required, for: .horizontal)
107+
108+
toolbar.addSubview(scrollView)
109+
toolbar.addSubview(doneButton)
110+
111+
NSLayoutConstraint.activate([
112+
separator.topAnchor.constraint(equalTo: toolbar.topAnchor),
113+
separator.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor),
114+
separator.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor),
115+
separator.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale),
116+
117+
scrollView.topAnchor.constraint(equalTo: toolbar.topAnchor),
118+
scrollView.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor, constant: 8),
119+
scrollView.bottomAnchor.constraint(equalTo: toolbar.bottomAnchor),
120+
scrollView.trailingAnchor.constraint(equalTo: doneButton.leadingAnchor, constant: -8),
121+
122+
stackView.topAnchor.constraint(equalTo: scrollView.topAnchor),
123+
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
124+
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
125+
stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
126+
stackView.heightAnchor.constraint(equalTo: scrollView.heightAnchor),
127+
128+
doneButton.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor, constant: -12),
129+
doneButton.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor)
130+
])
131+
132+
return toolbar
133+
}
134+
135+
private func makeKeywordButton(_ keyword: String) -> UIButton {
136+
var config = UIButton.Configuration.gray()
137+
config.title = keyword
138+
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
139+
var attrs = incoming
140+
attrs.font = UIFont.monospacedSystemFont(ofSize: 14, weight: .medium)
141+
return attrs
142+
}
143+
config.cornerStyle = .capsule
144+
config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)
145+
let button = UIButton(configuration: config)
146+
button.addTarget(self, action: #selector(keywordTapped(_:)), for: .touchUpInside)
147+
return button
148+
}
149+
150+
@objc private func keywordTapped(_ sender: UIButton) {
151+
guard let keyword = sender.configuration?.title else { return }
152+
let needsSpace = keyword.count > 1
153+
textView?.insertText(needsSpace ? keyword + " " : keyword)
154+
}
155+
156+
@objc private func dismissKeyboard() {
157+
textView?.resignFirstResponder()
158+
}
70159
}
71160
}

TableProMobile/TableProMobile/Views/ConnectedView.swift

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ struct ConnectedView: View {
3030
@State private var activeSchema: String = "public"
3131
@State private var isSwitching = false
3232
@State private var isReconnecting = false
33+
@State private var hapticSuccess = false
34+
@State private var hapticError = false
35+
36+
@Environment(\.dismiss) private var dismiss
3337

3438
enum ConnectedTab: String, CaseIterable {
3539
case tables = "Tables"
@@ -52,8 +56,14 @@ struct ConnectedView: View {
5256
var body: some View {
5357
Group {
5458
if isConnecting {
55-
ProgressView {
56-
Text(String(format: String(localized: "Connecting to %@..."), displayName))
59+
VStack(spacing: 16) {
60+
ProgressView {
61+
Text(String(format: String(localized: "Connecting to %@..."), displayName))
62+
}
63+
Button(String(localized: "Cancel")) {
64+
dismiss()
65+
}
66+
.buttonStyle(.bordered)
5767
}
5868
.frame(maxWidth: .infinity, maxHeight: .infinity)
5969
} else if let appError {
@@ -78,6 +88,8 @@ struct ConnectedView: View {
7888
.animation(.default, value: isSwitching)
7989
}
8090
}
91+
.sensoryFeedback(.success, trigger: hapticSuccess)
92+
.sensoryFeedback(.error, trigger: hapticError)
8193
.alert("Error", isPresented: $showFailureAlert) {
8294
Button("OK", role: .cancel) {}
8395
} message: {
@@ -158,7 +170,9 @@ struct ConnectedView: View {
158170
}
159171
.task {
160172
await connect()
161-
queryHistory = historyStorage.load(for: connection.id)
173+
if !Task.isCancelled {
174+
queryHistory = historyStorage.load(for: connection.id)
175+
}
162176
}
163177
.onChange(of: scenePhase) { _, phase in
164178
if phase == .active, session != nil {
@@ -205,6 +219,7 @@ struct ConnectedView: View {
205219
self.session = existing
206220
do {
207221
self.tables = try await existing.driver.fetchTables(schema: nil)
222+
guard !Task.isCancelled else { return }
208223
await loadDatabases()
209224
await loadSchemas()
210225
} catch {
@@ -225,12 +240,18 @@ struct ConnectedView: View {
225240

226241
do {
227242
let session = try await appState.connectionManager.connect(connection)
243+
guard !Task.isCancelled else {
244+
await appState.connectionManager.disconnect(connection.id)
245+
return
246+
}
228247
self.session = session
229248
self.tables = try await session.driver.fetchTables(schema: nil)
230249
isConnecting = false
250+
hapticSuccess.toggle()
231251
await loadDatabases()
232252
await loadSchemas()
233253
} catch {
254+
guard !Task.isCancelled else { return }
234255
let context = ErrorContext(
235256
operation: "connect",
236257
databaseType: connection.type,
@@ -239,6 +260,7 @@ struct ConnectedView: View {
239260
)
240261
appError = ErrorClassifier.classify(error, context: context)
241262
isConnecting = false
263+
hapticError.toggle()
242264
}
243265
}
244266

TableProMobile/TableProMobile/Views/ConnectionFormView.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ struct ConnectionFormView: View {
6666
@State private var testResult: TestResult?
6767
@State private var credentialError: String?
6868
@State private var showCredentialError = false
69+
@State private var hapticSuccess = false
70+
@State private var hapticError = false
6971

7072
private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionFormView")
7173

@@ -280,6 +282,8 @@ struct ConnectionFormView: View {
280282
} message: {
281283
Text("Enter a name for the new SQLite database.")
282284
}
285+
.sensoryFeedback(.success, trigger: hapticSuccess)
286+
.sensoryFeedback(.error, trigger: hapticError)
283287
.alert("Keychain Warning", isPresented: $showCredentialError) {
284288
Button("OK", role: .cancel) {}
285289
} message: {
@@ -529,6 +533,7 @@ struct ConnectionFormView: View {
529533
_ = try await appState.connectionManager.connect(testConn)
530534
await appState.connectionManager.disconnect(tempId)
531535
testResult = TestResult(success: true, message: String(localized: "Connection successful"), recovery: nil)
536+
hapticSuccess.toggle()
532537
} catch {
533538
let context = ErrorContext(
534539
operation: "testConnection",
@@ -538,6 +543,7 @@ struct ConnectionFormView: View {
538543
)
539544
let classified = ErrorClassifier.classify(error, context: context)
540545
testResult = TestResult(success: false, message: classified.message, recovery: classified.recovery)
546+
hapticError.toggle()
541547
}
542548
}
543549

TableProMobile/TableProMobile/Views/ConnectionListView.swift

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ struct ConnectionListView: View {
1818
@State private var filterTagId: UUID?
1919
@State private var groupByGroup = false
2020
@State private var editMode: EditMode = .inactive
21+
@State private var connectionToDelete: DatabaseConnection?
22+
23+
private var showDeleteConfirmation: Binding<Bool> {
24+
Binding(
25+
get: { connectionToDelete != nil },
26+
set: { if !$0 { connectionToDelete = nil } }
27+
)
28+
}
2129

2230
private var displayedConnections: [DatabaseConnection] {
2331
var result = appState.connections
@@ -175,6 +183,22 @@ struct ConnectionListView: View {
175183
localTags: appState.tags
176184
)
177185
}
186+
.confirmationDialog(
187+
String(localized: "Delete Connection"),
188+
isPresented: showDeleteConfirmation,
189+
titleVisibility: .visible
190+
) {
191+
Button(String(localized: "Delete"), role: .destructive) {
192+
if let connection = connectionToDelete {
193+
if selectedConnectionId == connection.id {
194+
selectedConnectionId = nil
195+
}
196+
appState.removeConnection(connection)
197+
}
198+
}
199+
} message: {
200+
Text("Are you sure you want to delete this connection? Saved credentials will be permanently removed.")
201+
}
178202
}
179203
}
180204

@@ -313,14 +337,12 @@ struct ConnectionListView: View {
313337
ConnectionRow(connection: connection, tag: appState.tag(for: connection.tagId))
314338
}
315339
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
316-
Button(role: .destructive) {
317-
if selectedConnectionId == connection.id {
318-
selectedConnectionId = nil
319-
}
320-
appState.removeConnection(connection)
340+
Button {
341+
connectionToDelete = connection
321342
} label: {
322343
Label("Delete", systemImage: "trash")
323344
}
345+
.tint(.red)
324346
}
325347
.contextMenu {
326348
Button {
@@ -338,10 +360,7 @@ struct ConnectionListView: View {
338360
}
339361
Divider()
340362
Button(role: .destructive) {
341-
if selectedConnectionId == connection.id {
342-
selectedConnectionId = nil
343-
}
344-
appState.removeConnection(connection)
363+
connectionToDelete = connection
345364
} label: {
346365
Label("Delete", systemImage: "trash")
347366
}

TableProMobile/TableProMobile/Views/DataBrowserView.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ struct DataBrowserView: View {
4141
@State private var foreignKeys: [ForeignKeyInfo] = []
4242
@State private var fkPreviewItem: FKPreviewItem?
4343
@State private var memoryWarningMessage: String?
44+
@State private var hapticSuccess = false
45+
@State private var hapticError = false
4446

4547
private var isView: Bool {
4648
table.type == .view || table.type == .materializedView
@@ -157,6 +159,8 @@ struct DataBrowserView: View {
157159
}
158160
}
159161
}
162+
.sensoryFeedback(.success, trigger: hapticSuccess)
163+
.sensoryFeedback(.error, trigger: hapticError)
160164
.alert("Go to Page", isPresented: $showGoToPage) {
161165
TextField("Page number", text: $goToPageInput)
162166
.keyboardType(.numberPad)
@@ -283,12 +287,13 @@ struct DataBrowserView: View {
283287
}
284288
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
285289
if !isView && hasPrimaryKeys && !connection.safeModeLevel.blocksWrites {
286-
Button(role: .destructive) {
290+
Button {
287291
deleteTarget = primaryKeyValues(for: row)
288292
showDeleteConfirmation = true
289293
} label: {
290294
Label("Delete", systemImage: "trash")
291295
}
296+
.tint(.red)
292297
}
293298
}
294299
}
@@ -595,12 +600,14 @@ struct DataBrowserView: View {
595600
query: SQLBuilder.buildDelete(table: table.name, type: connection.type, primaryKeys: pkValues)
596601
)
597602
await loadData()
603+
hapticSuccess.toggle()
598604
} catch {
599605
operationError = ErrorClassifier.classify(
600606
error,
601607
context: ErrorContext(operation: "deleteRow", databaseType: connection.type, host: connection.host)
602608
)
603609
showOperationError = true
610+
hapticError.toggle()
604611
}
605612
}
606613

0 commit comments

Comments
 (0)