diff --git a/CHANGELOG.md b/CHANGELOG.md index 009d1bacc..a8d3eb6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- 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 - 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 - 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 - 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 diff --git a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift new file mode 100644 index 000000000..7531546b2 --- /dev/null +++ b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift @@ -0,0 +1,247 @@ +// +// RowDetailViewModel.swift +// TableProMobile +// + +import Foundation +import os +import TableProDatabase +import TableProModels + +@MainActor +@Observable +final class RowDetailViewModel { + private static let logger = Logger(subsystem: "com.TablePro", category: "RowDetailViewModel") + + let columns: [ColumnInfo] + let columnDetails: [ColumnInfo] + let foreignKeys: [ForeignKeyInfo] + let table: TableInfo? + let session: ConnectionSession? + let databaseType: DatabaseType + let safeModeLevel: SafeModeLevel + + private(set) var rows: [Row] + var currentIndex: Int + var isEditing = false + private(set) var editedValues: [String?] = [] + private(set) var loadingCell: Int? + private(set) var fullValueOverrides: [Int: [Int: String?]] = [:] + private(set) var isSaving = false + var operationError: AppError? + private(set) var showSaveSuccess = false + + @ObservationIgnored let onSaved: (() -> Void)? + @ObservationIgnored let loadFullValueProvider: ((CellRef) async throws -> String?)? + @ObservationIgnored private var dismissSuccessTask: Task? + + init( + columns: [ColumnInfo], + rows: [Row], + initialIndex: Int, + table: TableInfo? = nil, + session: ConnectionSession? = nil, + columnDetails: [ColumnInfo] = [], + databaseType: DatabaseType = .sqlite, + safeModeLevel: SafeModeLevel = .off, + foreignKeys: [ForeignKeyInfo] = [], + onSaved: (() -> Void)? = nil, + loadFullValue: ((CellRef) async throws -> String?)? = nil + ) { + self.columns = columns + self.rows = rows + self.currentIndex = initialIndex + self.table = table + self.session = session + self.columnDetails = columnDetails + self.databaseType = databaseType + self.safeModeLevel = safeModeLevel + self.foreignKeys = foreignKeys + self.onSaved = onSaved + self.loadFullValueProvider = loadFullValue + } + + deinit { + dismissSuccessTask?.cancel() + } + + // MARK: - Computed + + var isView: Bool { + guard let table else { return false } + return table.type == .view || table.type == .materializedView + } + + var canEdit: Bool { + table != nil && session != nil && !columnDetails.isEmpty && !isView + && !safeModeLevel.blocksWrites + && columnDetails.contains(where: { $0.isPrimaryKey }) + } + + var supportsLazyLoading: Bool { loadFullValueProvider != nil } + + var currentRowCells: [Cell] { + guard currentIndex >= 0, currentIndex < rows.count else { return [] } + return rows[currentIndex].cells + } + + var currentRow: [String?] { + row(at: currentIndex) + } + + func row(at index: Int) -> [String?] { + guard index >= 0, index < rows.count else { return [] } + let overrides = fullValueOverrides[index] ?? [:] + return rows[index].legacyValues.enumerated().map { idx, base in + overrides[idx] ?? base + } + } + + func cells(at index: Int) -> [Cell] { + guard index >= 0, index < rows.count else { return [] } + return rows[index].cells + } + + func columnDetail(for name: String) -> ColumnInfo? { + columnDetails.first { $0.name == name } + } + + func isPrimaryKey(at index: Int) -> Bool { + guard index >= 0, index < columns.count else { return false } + let column = columns[index] + return columnDetail(for: column.name)?.isPrimaryKey ?? column.isPrimaryKey + } + + // MARK: - Edit Lifecycle + + func startEditing() { + editedValues = currentRow + isEditing = true + showSaveSuccess = false + } + + func cancelEditing() { + isEditing = false + editedValues = [] + showSaveSuccess = false + } + + func setEditedValue(_ value: String, at index: Int) { + guard index < editedValues.count else { return } + editedValues[index] = value + } + + func toggleNull(at index: Int) { + guard index < editedValues.count else { return } + if editedValues[index] == nil { + editedValues[index] = "" + } else { + editedValues[index] = nil + } + } + + // MARK: - Save + + func saveChanges() async -> Bool { + guard let session, let table else { return false } + + isSaving = true + defer { isSaving = false } + + let pkValues: [(column: String, value: String)] = columnDetails.compactMap { col in + guard col.isPrimaryKey else { return nil } + let colIndex = columns.firstIndex(where: { $0.name == col.name }) + guard let colIndex, colIndex < currentRow.count, let value = currentRow[colIndex] else { return nil } + return (column: col.name, value: value) + } + + guard !pkValues.isEmpty else { + operationError = AppError( + category: .config, + title: String(localized: "Cannot Save"), + message: String(localized: "No primary key values found."), + recovery: String(localized: "This table needs a primary key to identify the row."), + underlying: nil + ) + return false + } + + var changes: [(column: String, value: String?)] = [] + for (index, column) in columns.enumerated() { + if isPrimaryKey(at: index) { continue } + guard index < editedValues.count else { continue } + let oldValue = index < currentRow.count ? currentRow[index] : nil + let newValue = editedValues[index] + if oldValue != newValue { + changes.append((column: column.name, value: newValue)) + } + } + + guard !changes.isEmpty else { + isEditing = false + editedValues = [] + return true + } + + let sql = SQLBuilder.buildUpdate( + table: table.name, + type: databaseType, + changes: changes, + primaryKeys: pkValues + ) + + do { + _ = try await session.driver.execute(query: sql) + guard currentIndex >= 0, currentIndex < rows.count else { return false } + let newCells = editedValues.map { value -> Cell in + value.map { Cell.text($0) } ?? .null + } + rows[currentIndex] = Row(cells: newCells) + fullValueOverrides[currentIndex] = nil + isEditing = false + showSaveSuccess = true + onSaved?() + scheduleSuccessDismiss() + return true + } catch { + let context = ErrorContext(operation: "saveChanges", databaseType: databaseType) + operationError = ErrorClassifier.classify(error, context: context) + return false + } + } + + private func scheduleSuccessDismiss() { + dismissSuccessTask?.cancel() + dismissSuccessTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + await MainActor.run { self?.showSaveSuccess = false } + } + } + + // MARK: - Lazy Load + + func loadFullValue(ref: CellRef, cellIndex: Int) async { + guard let loadFullValueProvider else { return } + loadingCell = cellIndex + defer { loadingCell = nil } + do { + let fullValue = try await loadFullValueProvider(ref) + var rowOverrides = fullValueOverrides[currentIndex] ?? [:] + rowOverrides[cellIndex] = fullValue + fullValueOverrides[currentIndex] = rowOverrides + } catch { + operationError = AppError( + category: .network, + title: String(localized: "Load Failed"), + message: error.localizedDescription, + recovery: String(localized: "Try again or check your connection."), + underlying: error + ) + } + } + + func hasOverride(forRow rowIndex: Int, cellIndex: Int) -> Bool { + fullValueOverrides[rowIndex]?[cellIndex] != nil + } +} diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index 3e50b91b8..72c309fde 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -3,39 +3,18 @@ // TableProMobile // -import os import SwiftUI import TableProDatabase import TableProModels struct RowDetailView: View { - let columns: [ColumnInfo] - @State private var rows: [Row] - let table: TableInfo? - let session: ConnectionSession? - let columnDetails: [ColumnInfo] - let databaseType: DatabaseType - let safeModeLevel: SafeModeLevel - let foreignKeys: [ForeignKeyInfo] - var onSaved: (() -> Void)? - var loadFullValue: ((CellRef) async throws -> String?)? - - @State private var currentIndex: Int - @State private var isEditing = false - @State private var editedValues: [String?] = [] - @State private var loadingCell: Int? - @State private var fullValueOverrides: [Int: [Int: String?]] = [:] - @State private var isSaving = false - @State private var operationError: AppError? - @State private var showOperationError = false - @State private var showSaveSuccess = false + @State private var viewModel: RowDetailViewModel @State private var fkPreviewItem: FKPreviewItem? + @State private var showShareSheet = false + @State private var shareText = "" @State private var hapticSuccess = false @State private var hapticError = false @State private var hapticSelection = 0 - @State private var dismissSuccessTask: Task? - @State private var showShareSheet = false - @State private var shareText = "" init( columns: [ColumnInfo], @@ -50,51 +29,29 @@ struct RowDetailView: View { onSaved: (() -> Void)? = nil, loadFullValue: ((CellRef) async throws -> String?)? = nil ) { - self.columns = columns - _rows = State(initialValue: rows) - self.table = table - self.session = session - self.columnDetails = columnDetails - self.databaseType = databaseType - self.safeModeLevel = safeModeLevel - self.foreignKeys = foreignKeys - self.onSaved = onSaved - self.loadFullValue = loadFullValue - _currentIndex = State(initialValue: initialIndex) - } - - private var currentRowCells: [Cell] { - guard currentIndex >= 0, currentIndex < rows.count else { return [] } - return rows[currentIndex].cells - } - - private var currentRow: [String?] { - guard currentIndex >= 0, currentIndex < rows.count else { return [] } - let overrides = fullValueOverrides[currentIndex] ?? [:] - return rows[currentIndex].legacyValues.enumerated().map { index, base in - if let override = overrides[index] { return override } - return base - } - } - - private var isView: Bool { - guard let table else { return false } - return table.type == .view || table.type == .materializedView - } - - private var canEdit: Bool { - table != nil && session != nil && !columnDetails.isEmpty && !isView - && !safeModeLevel.blocksWrites - && columnDetails.contains(where: { $0.isPrimaryKey }) + _viewModel = State(wrappedValue: RowDetailViewModel( + columns: columns, + rows: rows, + initialIndex: initialIndex, + table: table, + session: session, + columnDetails: columnDetails, + databaseType: databaseType, + safeModeLevel: safeModeLevel, + foreignKeys: foreignKeys, + onSaved: onSaved, + loadFullValue: loadFullValue + )) } var body: some View { - Group { - if isEditing { - rowContent(at: currentIndex) + @Bindable var viewModel = viewModel + return Group { + if viewModel.isEditing { + rowContent(at: viewModel.currentIndex) } else { - TabView(selection: $currentIndex) { - ForEach(rows.indices, id: \.self) { index in + TabView(selection: $viewModel.currentIndex) { + ForEach(viewModel.rows.indices, id: \.self) { index in rowContent(at: index) .tag(index) } @@ -103,14 +60,11 @@ struct RowDetailView: View { } } .background(Color(.systemGroupedBackground)) - .onDisappear { - dismissSuccessTask?.cancel() - } - .onChange(of: currentIndex) { + .onChange(of: viewModel.currentIndex) { hapticSelection += 1 } .overlay(alignment: .bottom) { - if showSaveSuccess { + if viewModel.showSaveSuccess { Label("Row updated", systemImage: "checkmark.circle.fill") .font(.subheadline) .padding() @@ -119,88 +73,33 @@ struct RowDetailView: View { .transition(.move(edge: .bottom).combined(with: .opacity)) } } - .navigationTitle(table?.name ?? String(format: String(localized: "Row %d of %d"), currentIndex + 1, rows.count)) + .navigationTitle(viewModel.table?.name ?? String(format: String(localized: "Row %d of %d"), viewModel.currentIndex + 1, viewModel.rows.count)) .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Menu { - shareMenuContent - } label: { - Image(systemName: "square.and.arrow.up") - } - } - - ToolbarItem(placement: .primaryAction) { - if canEdit { - if isEditing { - Button { - Task { await saveChanges() } - } label: { - if isSaving { - ProgressView() - .controlSize(.small) - } else { - Text("Save") - } - } - .disabled(isSaving) - } else { - Button("Edit") { startEditing() } - } - } - } - - if isEditing { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { cancelEditing() } - .disabled(isSaving) - } - } - - ToolbarItemGroup(placement: .bottomBar) { - Button { - currentIndex -= 1 - } label: { - Image(systemName: "chevron.left") - } - .disabled(currentIndex <= 0 || isEditing) - - Spacer() - - Text("\(currentIndex + 1) of \(rows.count)") - .font(.footnote) - .foregroundStyle(.secondary) - .monospacedDigit() - .fixedSize() - - Spacer() - - Button { - currentIndex += 1 - } label: { - Image(systemName: "chevron.right") - } - .disabled(currentIndex >= rows.count - 1 || isEditing) - } - } + .toolbar { rowDetailToolbar } .sensoryFeedback(.success, trigger: hapticSuccess) .sensoryFeedback(.error, trigger: hapticError) .sensoryFeedback(.selection, trigger: hapticSelection) - .alert(operationError?.title ?? "Error", isPresented: $showOperationError) { + .alert( + viewModel.operationError?.title ?? "Error", + isPresented: Binding( + get: { viewModel.operationError != nil }, + set: { if !$0 { viewModel.operationError = nil } } + ) + ) { Button("OK", role: .cancel) {} } message: { - if let recovery = operationError?.recovery { - Text(verbatim: "\(operationError?.message ?? "") \(recovery)") + if let recovery = viewModel.operationError?.recovery { + Text(verbatim: "\(viewModel.operationError?.message ?? "") \(recovery)") } else { - Text(operationError?.message ?? "") + Text(viewModel.operationError?.message ?? "") } } .sheet(item: $fkPreviewItem) { item in FKPreviewView( fk: item.fk, value: item.value, - session: session, - databaseType: databaseType + session: viewModel.session, + databaseType: viewModel.databaseType ) } .sheet(isPresented: $showShareSheet) { @@ -208,14 +107,78 @@ struct RowDetailView: View { } } + @ToolbarContentBuilder + private var rowDetailToolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Menu { + shareMenuContent + } label: { + Image(systemName: "square.and.arrow.up") + } + } + + ToolbarItem(placement: .primaryAction) { + if viewModel.canEdit { + if viewModel.isEditing { + Button { + Task { await handleSave() } + } label: { + if viewModel.isSaving { + ProgressView() + .controlSize(.small) + } else { + Text("Save") + } + } + .disabled(viewModel.isSaving) + } else { + Button("Edit") { viewModel.startEditing() } + } + } + } + + if viewModel.isEditing { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { viewModel.cancelEditing() } + .disabled(viewModel.isSaving) + } + } + + ToolbarItemGroup(placement: .bottomBar) { + Button { + viewModel.currentIndex -= 1 + } label: { + Image(systemName: "chevron.left") + } + .disabled(viewModel.currentIndex <= 0 || viewModel.isEditing) + + Spacer() + + Text("\(viewModel.currentIndex + 1) of \(viewModel.rows.count)") + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + .fixedSize() + + Spacer() + + Button { + viewModel.currentIndex += 1 + } label: { + Image(systemName: "chevron.right") + } + .disabled(viewModel.currentIndex >= viewModel.rows.count - 1 || viewModel.isEditing) + } + } + @ViewBuilder private var shareMenuContent: some View { Section("Share") { ForEach(ExportFormat.allCases) { format in Button { shareText = ClipboardExporter.exportRow( - columns: columns, row: currentRow, - format: format, tableName: table?.name + columns: viewModel.columns, row: viewModel.currentRow, + format: format, tableName: viewModel.table?.name ) showShareSheet = true } label: { @@ -227,8 +190,8 @@ struct RowDetailView: View { ForEach(ExportFormat.allCases) { format in Button { let text = ClipboardExporter.exportRow( - columns: columns, row: currentRow, - format: format, tableName: table?.name + columns: viewModel.columns, row: viewModel.currentRow, + format: format, tableName: viewModel.table?.name ) ClipboardExporter.copyToClipboard(text) } label: { @@ -240,22 +203,16 @@ struct RowDetailView: View { @ViewBuilder private func rowContent(at rowIndex: Int) -> some View { - let row: [String?] = { - guard rowIndex >= 0, rowIndex < rows.count else { return [] } - let overrides = fullValueOverrides[rowIndex] ?? [:] - return rows[rowIndex].legacyValues.enumerated().map { index, base in - overrides[index] ?? base - } - }() - let cells = rowIndex >= 0 && rowIndex < rows.count ? rows[rowIndex].cells : [] - let values = isEditing ? editedValues : row + let row = viewModel.row(at: rowIndex) + let cells = viewModel.cells(at: rowIndex) + let values = viewModel.isEditing ? viewModel.editedValues : row List { - ForEach(0.. some View { - if let ref = cell.fullValueRef, let loadFullValue { + if let ref = cell.fullValueRef, viewModel.supportsLazyLoading { Button { - Task { await performLoadFullValue(ref: ref, cellIndex: cellIndex, loadFullValue: loadFullValue) } + Task { await viewModel.loadFullValue(ref: ref, cellIndex: cellIndex) } } label: { HStack(spacing: 4) { - if loadingCell == cellIndex { + if viewModel.loadingCell == cellIndex { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.down.circle") @@ -336,43 +294,20 @@ struct RowDetailView: View { .foregroundStyle(.blue) } .buttonStyle(.plain) - .disabled(loadingCell != nil) - } - } - - private func performLoadFullValue(ref: CellRef, cellIndex: Int, loadFullValue: (CellRef) async throws -> String?) async { - loadingCell = cellIndex - defer { loadingCell = nil } - do { - let fullValue = try await loadFullValue(ref) - var rowOverrides = fullValueOverrides[currentIndex] ?? [:] - rowOverrides[cellIndex] = fullValue - fullValueOverrides[currentIndex] = rowOverrides - } catch { - operationError = AppError( - category: .network, - title: String(localized: "Load Failed"), - message: error.localizedDescription, - recovery: String(localized: "Try again or check your connection."), - underlying: error - ) - showOperationError = true + .disabled(viewModel.loadingCell != nil) } } private func editableField(index: Int, value: String?) -> some View { - let binding = Binding( + let textBinding = Binding( get: { - guard index < editedValues.count else { return "" } - return editedValues[index] ?? "" + guard index < viewModel.editedValues.count else { return "" } + return viewModel.editedValues[index] ?? "" }, - set: { newValue in - guard index < editedValues.count else { return } - editedValues[index] = newValue - } + set: { newValue in viewModel.setEditedValue(newValue, at: index) } ) - let isNull = index < editedValues.count ? editedValues[index] == nil : true + let isNull = index < viewModel.editedValues.count ? viewModel.editedValues[index] == nil : true return HStack { if isNull { @@ -381,17 +316,12 @@ struct RowDetailView: View { .foregroundStyle(.secondary) .italic() } else { - TextField("Value", text: binding) + TextField("Value", text: textBinding) .font(.body) } Button { - guard index < editedValues.count else { return } - if editedValues[index] == nil { - editedValues[index] = "" - } else { - editedValues[index] = nil - } + viewModel.toggleNull(at: index) } label: { Text("NULL") .font(.caption2) @@ -419,94 +349,11 @@ struct RowDetailView: View { } } - private func columnDetail(for name: String) -> ColumnInfo? { - columnDetails.first { $0.name == name } - } - - private func startEditing() { - editedValues = currentRow - isEditing = true - showSaveSuccess = false - } - - private func cancelEditing() { - isEditing = false - editedValues = [] - showSaveSuccess = false - } - - private func saveChanges() async { - guard let session, let table else { return } - - isSaving = true - defer { isSaving = false } - - let pkValues: [(column: String, value: String)] = columnDetails.compactMap { col in - guard col.isPrimaryKey else { return nil } - let colIndex = columns.firstIndex(where: { $0.name == col.name }) - guard let colIndex, colIndex < currentRow.count, let value = currentRow[colIndex] else { return nil } - return (column: col.name, value: value) - } - - guard !pkValues.isEmpty else { - operationError = AppError( - category: .config, - title: "Cannot Save", - message: "No primary key values found.", - recovery: "This table needs a primary key to identify the row.", - underlying: nil - ) - showOperationError = true - return - } - - var changes: [(column: String, value: String?)] = [] - for (index, column) in columns.enumerated() { - let isPK = columnDetail(for: column.name)?.isPrimaryKey ?? column.isPrimaryKey - if isPK { continue } - guard index < editedValues.count else { continue } - let oldValue = index < currentRow.count ? currentRow[index] : nil - let newValue = editedValues[index] - if oldValue != newValue { - changes.append((column: column.name, value: newValue)) - } - } - - guard !changes.isEmpty else { - isEditing = false - editedValues = [] - return - } - - let sql = SQLBuilder.buildUpdate( - table: table.name, - type: databaseType, - changes: changes, - primaryKeys: pkValues - ) - - do { - _ = try await session.driver.execute(query: sql) - guard currentIndex >= 0, currentIndex < rows.count else { return } - let newCells = editedValues.map { value -> Cell in - value.map { Cell.text($0) } ?? .null - } - rows[currentIndex] = Row(cells: newCells) - fullValueOverrides[currentIndex] = nil - isEditing = false - showSaveSuccess = true + private func handleSave() async { + let success = await viewModel.saveChanges() + if success { hapticSuccess.toggle() - onSaved?() - dismissSuccessTask?.cancel() - dismissSuccessTask = Task { - try? await Task.sleep(for: .seconds(2)) - guard !Task.isCancelled else { return } - withAnimation { showSaveSuccess = false } - } - } catch { - let context = ErrorContext(operation: "saveChanges", databaseType: databaseType) - operationError = ErrorClassifier.classify(error, context: context) - showOperationError = true + } else { hapticError.toggle() } }