Skip to content

Commit 5cad8f4

Browse files
authored
refactor(ios): extract row detail logic into RowDetailViewModel (#1166)
1 parent 649eca3 commit 5cad8f4

3 files changed

Lines changed: 380 additions & 285 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
### Changed
1818

19+
- Internal: iOS row detail (edit lifecycle, save SQL build, lazy cell value load, primary key extraction, success-toast auto-dismiss) moves out of the View into `RowDetailViewModel`. The View now keeps only sheet flags and haptic triggers; behavior is unchanged
1920
- Internal: iOS connection form (test connection, save, file picker handlers, default port resolution, credential hydration) moves out of the View into `ConnectionFormViewModel`. The View drops from 53 to 5 `@State` properties; behavior is unchanged
2021
- Internal: iOS data browser business logic (page load, pagination, sort, filter, search, delete, foreign-key fetch, memory pressure) moves out of the View into `DataBrowserViewModel`. The View drops 30 of its 33 `@State` properties and a dozen private functions; behavior is unchanged
2122
- iOS: metadata badges (column types, primary key markers, row counts) cap at the first accessibility size so they stay readable without breaking layouts at the largest Dynamic Type sizes
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
//
2+
// RowDetailViewModel.swift
3+
// TableProMobile
4+
//
5+
6+
import Foundation
7+
import os
8+
import TableProDatabase
9+
import TableProModels
10+
11+
@MainActor
12+
@Observable
13+
final class RowDetailViewModel {
14+
private static let logger = Logger(subsystem: "com.TablePro", category: "RowDetailViewModel")
15+
16+
let columns: [ColumnInfo]
17+
let columnDetails: [ColumnInfo]
18+
let foreignKeys: [ForeignKeyInfo]
19+
let table: TableInfo?
20+
let session: ConnectionSession?
21+
let databaseType: DatabaseType
22+
let safeModeLevel: SafeModeLevel
23+
24+
private(set) var rows: [Row]
25+
var currentIndex: Int
26+
var isEditing = false
27+
private(set) var editedValues: [String?] = []
28+
private(set) var loadingCell: Int?
29+
private(set) var fullValueOverrides: [Int: [Int: String?]] = [:]
30+
private(set) var isSaving = false
31+
var operationError: AppError?
32+
private(set) var showSaveSuccess = false
33+
34+
@ObservationIgnored let onSaved: (() -> Void)?
35+
@ObservationIgnored let loadFullValueProvider: ((CellRef) async throws -> String?)?
36+
@ObservationIgnored private var dismissSuccessTask: Task<Void, Never>?
37+
38+
init(
39+
columns: [ColumnInfo],
40+
rows: [Row],
41+
initialIndex: Int,
42+
table: TableInfo? = nil,
43+
session: ConnectionSession? = nil,
44+
columnDetails: [ColumnInfo] = [],
45+
databaseType: DatabaseType = .sqlite,
46+
safeModeLevel: SafeModeLevel = .off,
47+
foreignKeys: [ForeignKeyInfo] = [],
48+
onSaved: (() -> Void)? = nil,
49+
loadFullValue: ((CellRef) async throws -> String?)? = nil
50+
) {
51+
self.columns = columns
52+
self.rows = rows
53+
self.currentIndex = initialIndex
54+
self.table = table
55+
self.session = session
56+
self.columnDetails = columnDetails
57+
self.databaseType = databaseType
58+
self.safeModeLevel = safeModeLevel
59+
self.foreignKeys = foreignKeys
60+
self.onSaved = onSaved
61+
self.loadFullValueProvider = loadFullValue
62+
}
63+
64+
deinit {
65+
dismissSuccessTask?.cancel()
66+
}
67+
68+
// MARK: - Computed
69+
70+
var isView: Bool {
71+
guard let table else { return false }
72+
return table.type == .view || table.type == .materializedView
73+
}
74+
75+
var canEdit: Bool {
76+
table != nil && session != nil && !columnDetails.isEmpty && !isView
77+
&& !safeModeLevel.blocksWrites
78+
&& columnDetails.contains(where: { $0.isPrimaryKey })
79+
}
80+
81+
var supportsLazyLoading: Bool { loadFullValueProvider != nil }
82+
83+
var currentRowCells: [Cell] {
84+
guard currentIndex >= 0, currentIndex < rows.count else { return [] }
85+
return rows[currentIndex].cells
86+
}
87+
88+
var currentRow: [String?] {
89+
row(at: currentIndex)
90+
}
91+
92+
func row(at index: Int) -> [String?] {
93+
guard index >= 0, index < rows.count else { return [] }
94+
let overrides = fullValueOverrides[index] ?? [:]
95+
return rows[index].legacyValues.enumerated().map { idx, base in
96+
overrides[idx] ?? base
97+
}
98+
}
99+
100+
func cells(at index: Int) -> [Cell] {
101+
guard index >= 0, index < rows.count else { return [] }
102+
return rows[index].cells
103+
}
104+
105+
func columnDetail(for name: String) -> ColumnInfo? {
106+
columnDetails.first { $0.name == name }
107+
}
108+
109+
func isPrimaryKey(at index: Int) -> Bool {
110+
guard index >= 0, index < columns.count else { return false }
111+
let column = columns[index]
112+
return columnDetail(for: column.name)?.isPrimaryKey ?? column.isPrimaryKey
113+
}
114+
115+
// MARK: - Edit Lifecycle
116+
117+
func startEditing() {
118+
editedValues = currentRow
119+
isEditing = true
120+
showSaveSuccess = false
121+
}
122+
123+
func cancelEditing() {
124+
isEditing = false
125+
editedValues = []
126+
showSaveSuccess = false
127+
}
128+
129+
func setEditedValue(_ value: String, at index: Int) {
130+
guard index < editedValues.count else { return }
131+
editedValues[index] = value
132+
}
133+
134+
func toggleNull(at index: Int) {
135+
guard index < editedValues.count else { return }
136+
if editedValues[index] == nil {
137+
editedValues[index] = ""
138+
} else {
139+
editedValues[index] = nil
140+
}
141+
}
142+
143+
// MARK: - Save
144+
145+
func saveChanges() async -> Bool {
146+
guard let session, let table else { return false }
147+
148+
isSaving = true
149+
defer { isSaving = false }
150+
151+
let pkValues: [(column: String, value: String)] = columnDetails.compactMap { col in
152+
guard col.isPrimaryKey else { return nil }
153+
let colIndex = columns.firstIndex(where: { $0.name == col.name })
154+
guard let colIndex, colIndex < currentRow.count, let value = currentRow[colIndex] else { return nil }
155+
return (column: col.name, value: value)
156+
}
157+
158+
guard !pkValues.isEmpty else {
159+
operationError = AppError(
160+
category: .config,
161+
title: String(localized: "Cannot Save"),
162+
message: String(localized: "No primary key values found."),
163+
recovery: String(localized: "This table needs a primary key to identify the row."),
164+
underlying: nil
165+
)
166+
return false
167+
}
168+
169+
var changes: [(column: String, value: String?)] = []
170+
for (index, column) in columns.enumerated() {
171+
if isPrimaryKey(at: index) { continue }
172+
guard index < editedValues.count else { continue }
173+
let oldValue = index < currentRow.count ? currentRow[index] : nil
174+
let newValue = editedValues[index]
175+
if oldValue != newValue {
176+
changes.append((column: column.name, value: newValue))
177+
}
178+
}
179+
180+
guard !changes.isEmpty else {
181+
isEditing = false
182+
editedValues = []
183+
return true
184+
}
185+
186+
let sql = SQLBuilder.buildUpdate(
187+
table: table.name,
188+
type: databaseType,
189+
changes: changes,
190+
primaryKeys: pkValues
191+
)
192+
193+
do {
194+
_ = try await session.driver.execute(query: sql)
195+
guard currentIndex >= 0, currentIndex < rows.count else { return false }
196+
let newCells = editedValues.map { value -> Cell in
197+
value.map { Cell.text($0) } ?? .null
198+
}
199+
rows[currentIndex] = Row(cells: newCells)
200+
fullValueOverrides[currentIndex] = nil
201+
isEditing = false
202+
showSaveSuccess = true
203+
onSaved?()
204+
scheduleSuccessDismiss()
205+
return true
206+
} catch {
207+
let context = ErrorContext(operation: "saveChanges", databaseType: databaseType)
208+
operationError = ErrorClassifier.classify(error, context: context)
209+
return false
210+
}
211+
}
212+
213+
private func scheduleSuccessDismiss() {
214+
dismissSuccessTask?.cancel()
215+
dismissSuccessTask = Task { [weak self] in
216+
try? await Task.sleep(for: .seconds(2))
217+
guard !Task.isCancelled else { return }
218+
await MainActor.run { self?.showSaveSuccess = false }
219+
}
220+
}
221+
222+
// MARK: - Lazy Load
223+
224+
func loadFullValue(ref: CellRef, cellIndex: Int) async {
225+
guard let loadFullValueProvider else { return }
226+
loadingCell = cellIndex
227+
defer { loadingCell = nil }
228+
do {
229+
let fullValue = try await loadFullValueProvider(ref)
230+
var rowOverrides = fullValueOverrides[currentIndex] ?? [:]
231+
rowOverrides[cellIndex] = fullValue
232+
fullValueOverrides[currentIndex] = rowOverrides
233+
} catch {
234+
operationError = AppError(
235+
category: .network,
236+
title: String(localized: "Load Failed"),
237+
message: error.localizedDescription,
238+
recovery: String(localized: "Try again or check your connection."),
239+
underlying: error
240+
)
241+
}
242+
}
243+
244+
func hasOverride(forRow rowIndex: Int, cellIndex: Int) -> Bool {
245+
fullValueOverrides[rowIndex]?[cellIndex] != nil
246+
}
247+
}

0 commit comments

Comments
 (0)