From 5dcfe0f147c7871eaf5f3a88bd9e9fded38d4633 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 13 Apr 2026 22:37:22 -0500 Subject: v1.2.0 domain management and wallet expansion Complete v1.1-aligned domain flows with forwards and glue CRUD, improve API payload resilience, and add wallet transaction/payment views. Harden error handling and UX states across domain screens, remove out-of-scope DNSSEC/renewal record flows for now, and keep auth/session behavior aligned with token-based usage. --- Rune/Views/Domains/DomainDetailView.swift | 8 +- Rune/Views/Domains/DomainEditView.swift | 8 -- Rune/Views/Domains/DomainListView.swift | 6 - Rune/Views/Domains/ForwardListView.swift | 6 - Rune/Views/Domains/GlueEditView.swift | 139 ++++++++++++++++++++++ Rune/Views/Domains/GlueListView.swift | 139 ++++++++++++++++++++++ Rune/Views/Domains/RecordListView.swift | 6 - Rune/Views/Settings/SettingsView.swift | 19 ++- Rune/Views/Settings/WalletPaymentDetailView.swift | 45 +++++++ Rune/Views/Settings/WalletTransactionsView.swift | 75 ++++++++++++ 10 files changed, 418 insertions(+), 33 deletions(-) create mode 100644 Rune/Views/Domains/GlueEditView.swift create mode 100644 Rune/Views/Domains/GlueListView.swift create mode 100644 Rune/Views/Settings/WalletPaymentDetailView.swift create mode 100644 Rune/Views/Settings/WalletTransactionsView.swift (limited to 'Rune/Views') diff --git a/Rune/Views/Domains/DomainDetailView.swift b/Rune/Views/Domains/DomainDetailView.swift index b4ee00d..7fc811a 100644 --- a/Rune/Views/Domains/DomainDetailView.swift +++ b/Rune/Views/Domains/DomainDetailView.swift @@ -35,7 +35,11 @@ struct DomainDetailView: View { NavigationLink("Records") { RecordListView(domainName: domain.name, viewModel: viewModel, client: client) } + NavigationLink("Glue Records") { + GlueListView(domainName: domain.name, viewModel: viewModel, client: client) + } } + } .listStyle(.insetGrouped) .toolbar { @@ -79,11 +83,11 @@ struct DomainDetailView: View { private func nameserverText(_ nameservers: [String]?) -> String { guard let nameservers else { - return "Not available" + return "Njalla" } guard !nameservers.isEmpty else { - return "Default" + return "Njalla" } return nameservers.joined(separator: ", ") diff --git a/Rune/Views/Domains/DomainEditView.swift b/Rune/Views/Domains/DomainEditView.swift index 0b3b1f8..a5fd99b 100644 --- a/Rune/Views/Domains/DomainEditView.swift +++ b/Rune/Views/Domains/DomainEditView.swift @@ -65,14 +65,6 @@ struct DomainEditView: View { .controlSize(.large) } } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel", role: .cancel) { - dismiss() - } - .disabled(viewModel.isSaving) - } - } .interactiveDismissDisabled(viewModel.isSaving) .alert("Request Failed", isPresented: localErrorBinding) { Button("OK", role: .cancel) {} diff --git a/Rune/Views/Domains/DomainListView.swift b/Rune/Views/Domains/DomainListView.swift index 68f2ede..ad8134a 100644 --- a/Rune/Views/Domains/DomainListView.swift +++ b/Rune/Views/Domains/DomainListView.swift @@ -61,12 +61,6 @@ struct DomainListView: View { .refreshable { await viewModel.loadDomains(client: client) } - .overlay(alignment: .top) { - if viewModel.isLoadingDomains && !viewModel.domains.isEmpty { - ProgressView() - .padding(.top, 8) - } - } } } diff --git a/Rune/Views/Domains/ForwardListView.swift b/Rune/Views/Domains/ForwardListView.swift index 4e20ab2..6846a79 100644 --- a/Rune/Views/Domains/ForwardListView.swift +++ b/Rune/Views/Domains/ForwardListView.swift @@ -88,12 +88,6 @@ struct ForwardListView: View { await viewModel.loadForwards(for: domainName, client: client) debugLog("Refresh complete with \(viewModel.forwards.count) forwards for \(domainName)") } - .overlay(alignment: .top) { - if viewModel.isLoadingForwards && !viewModel.forwards.isEmpty { - ProgressView() - .padding(.top, 8) - } - } .alert(deleteAlertTitle, isPresented: deleteBinding) { Button("Delete Forward", role: .destructive) { guard let forwardPendingDeletion else { return } diff --git a/Rune/Views/Domains/GlueEditView.swift b/Rune/Views/Domains/GlueEditView.swift new file mode 100644 index 0000000..b56a5d7 --- /dev/null +++ b/Rune/Views/Domains/GlueEditView.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct GlueEditView: View { + let domainName: String + let existingRecord: GlueRecord? + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @Environment(\.dismiss) private var dismiss + @State private var name: String + @State private var address4: String + @State private var address6: String + @State private var localErrorMessage: String? + + init(domainName: String, existingRecord: GlueRecord?, viewModel: DomainViewModel, client: NjallaClient) { + self.domainName = domainName + self.existingRecord = existingRecord + self.viewModel = viewModel + self.client = client + _name = State(initialValue: existingRecord?.name ?? "") + _address4 = State(initialValue: existingRecord?.address4 ?? "") + _address6 = State(initialValue: existingRecord?.address6 ?? "") + } + + var body: some View { + Form { + Section("Record") { + TextField("Name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .disabled(existingRecord != nil) + + TextField("IPv4", text: $address4) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + TextField("IPv6", text: $address6) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Section { + Button("Save") { + Task { + await save() + } + } + .disabled(viewModel.isSaving) + } + } + .navigationTitle(existingRecord == nil ? "Add Glue" : "Edit Glue") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isSaving { + ProgressView() + .controlSize(.large) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", role: .cancel) { + dismiss() + } + .disabled(viewModel.isSaving) + } + } + .alert("Invalid Glue Data", isPresented: localErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(localErrorMessage ?? "") + } + .alert("Request Failed", isPresented: mutationErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.mutationErrorMessage ?? "") + } + } + + private func save() async { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedAddress4 = address4.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedAddress6 = address6.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedName.isEmpty else { + localErrorMessage = "Glue record name is required." + return + } + + guard !trimmedAddress4.isEmpty || !trimmedAddress6.isEmpty else { + localErrorMessage = "Provide at least one address (IPv4 or IPv6)." + return + } + + do { + if existingRecord == nil { + try await viewModel.addGlue( + for: domainName, + name: trimmedName, + address4: trimmedAddress4.isEmpty ? nil : trimmedAddress4, + address6: trimmedAddress6.isEmpty ? nil : trimmedAddress6, + client: client + ) + } else { + try await viewModel.editGlue( + for: domainName, + name: trimmedName, + address4: trimmedAddress4.isEmpty ? nil : trimmedAddress4, + address6: trimmedAddress6.isEmpty ? nil : trimmedAddress6, + client: client + ) + } + dismiss() + } catch { + return + } + } + + private var localErrorBinding: Binding { + Binding( + get: { localErrorMessage != nil }, + set: { newValue in + if !newValue { + localErrorMessage = nil + } + } + ) + } + + private var mutationErrorBinding: Binding { + Binding( + get: { viewModel.mutationErrorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.dismissMutationError() + } + } + ) + } +} diff --git a/Rune/Views/Domains/GlueListView.swift b/Rune/Views/Domains/GlueListView.swift new file mode 100644 index 0000000..b390a84 --- /dev/null +++ b/Rune/Views/Domains/GlueListView.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct GlueListView: View { + let domainName: String + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @State private var showingAddGlue = false + @State private var recordPendingDeletion: GlueRecord? + + var body: some View { + List { + if let errorMessage = viewModel.glueErrorMessage { + Section { + InlineErrorView(message: errorMessage, retryTitle: "Retry Glue Records") { + Task { + await viewModel.loadGlue(for: domainName, client: client) + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + } + + if viewModel.isLoadingGlue && viewModel.glueRecords.isEmpty { + Section { + HStack { + Spacer() + ProgressView("Loading Glue") + Spacer() + } + } + } else if viewModel.glueRecords.isEmpty { + Section { + ContentUnavailableView( + "No Glue Records", + systemImage: "list.bullet.rectangle", + description: Text("No glue records are configured for this domain.") + ) + } + } else { + ForEach(viewModel.glueRecords) { record in + NavigationLink { + GlueEditView(domainName: domainName, existingRecord: record, viewModel: viewModel, client: client) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(record.name) + .font(.headline) + Text("IPv4: \(record.address4 ?? "n/a")") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("IPv6: \(record.address6 ?? "n/a")") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + .swipeActions { + Button("Delete", role: .destructive) { + recordPendingDeletion = record + } + } + .disabled(viewModel.isSaving) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Glue Records") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + Button { + showingAddGlue = true + } label: { + Label("Add Glue", systemImage: "plus") + } + .disabled(viewModel.isSaving) + } + .sheet(isPresented: $showingAddGlue) { + NavigationStack { + GlueEditView(domainName: domainName, existingRecord: nil, viewModel: viewModel, client: client) + } + } + .task { + await viewModel.loadGlue(for: domainName, client: client) + } + .refreshable { + await viewModel.loadGlue(for: domainName, client: client) + } + .alert(deleteAlertTitle, isPresented: deleteBinding) { + Button("Delete Glue", role: .destructive) { + guard let recordPendingDeletion else { return } + Task { + do { + try await viewModel.removeGlue(recordPendingDeletion, client: client) + self.recordPendingDeletion = nil + } catch { + return + } + } + } + Button("Cancel", role: .cancel) { + recordPendingDeletion = nil + } + } message: { + Text("Delete glue record \(recordPendingDeletion?.name ?? "")?") + } + .alert("Request Failed", isPresented: mutationErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.mutationErrorMessage ?? "") + } + } + + private var deleteAlertTitle: String { + guard let recordPendingDeletion else { return "" } + return "Delete glue record \(recordPendingDeletion.name)?" + } + + private var deleteBinding: Binding { + Binding( + get: { recordPendingDeletion != nil }, + set: { newValue in + if !newValue { + recordPendingDeletion = nil + } + } + ) + } + + private var mutationErrorBinding: Binding { + Binding( + get: { viewModel.mutationErrorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.dismissMutationError() + } + } + ) + } +} diff --git a/Rune/Views/Domains/RecordListView.swift b/Rune/Views/Domains/RecordListView.swift index 6c40ab0..74ab286 100644 --- a/Rune/Views/Domains/RecordListView.swift +++ b/Rune/Views/Domains/RecordListView.swift @@ -73,12 +73,6 @@ struct RecordListView: View { .refreshable { await viewModel.loadRecords(for: domainName, client: client) } - .overlay(alignment: .top) { - if viewModel.isLoadingRecords && !viewModel.records.isEmpty { - ProgressView() - .padding(.top, 8) - } - } .alert("Request Failed", isPresented: mutationErrorBinding) { Button("OK", role: .cancel) {} } message: { diff --git a/Rune/Views/Settings/SettingsView.swift b/Rune/Views/Settings/SettingsView.swift index 4d07887..a776956 100644 --- a/Rune/Views/Settings/SettingsView.swift +++ b/Rune/Views/Settings/SettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SettingsView: View { @ObservedObject var viewModel: SettingsViewModel + @ObservedObject var walletViewModel: WalletViewModel let onLogout: () -> Void @State private var showingLogoutConfirmation = false @@ -33,6 +34,12 @@ struct SettingsView: View { } } .disabled(viewModel.client == nil || viewModel.isLoadingBalance) + + if let client = viewModel.client { + NavigationLink("Transactions") { + WalletTransactionsView(viewModel: walletViewModel, client: client) + } + } } Section("Account") { @@ -57,11 +64,13 @@ struct SettingsView: View { titleVisibility: .visible ) { Button("Log Out", role: .destructive) { - do { - try viewModel.logout() - onLogout() - } catch { - viewModel.errorMessage = error.localizedDescription + Task { + do { + try await viewModel.logout() + onLogout() + } catch { + viewModel.errorMessage = error.localizedDescription + } } } diff --git a/Rune/Views/Settings/WalletPaymentDetailView.swift b/Rune/Views/Settings/WalletPaymentDetailView.swift new file mode 100644 index 0000000..f5ef605 --- /dev/null +++ b/Rune/Views/Settings/WalletPaymentDetailView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct WalletPaymentDetailView: View { + let transactionID: String + @ObservedObject var viewModel: WalletViewModel + let client: NjallaClient + + var body: some View { + Group { + if viewModel.isLoadingPayment && viewModel.selectedPayment == nil { + ProgressView("Loading Payment") + } else if let payment = viewModel.selectedPayment { + List { + Section("Payment") { + detailRow(label: "ID", value: payment.id ?? transactionID) + detailRow(label: "Status", value: payment.status ?? "Not available") + detailRow(label: "Amount", value: payment.amount.map { "€\($0)" } ?? "Not available") + detailRow(label: "Address", value: payment.address ?? "Not available") + detailRow(label: "URL", value: payment.url ?? "Not available") + } + } + .listStyle(.insetGrouped) + } else if let errorMessage = viewModel.paymentErrorMessage { + ContentUnavailableView("Payment Unavailable", systemImage: "exclamationmark.triangle", description: Text(errorMessage)) + } else { + ContentUnavailableView("Payment Unavailable", systemImage: "creditcard", description: Text("No payment details were returned for this transaction.")) + } + } + .navigationTitle("Payment") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.loadPayment(id: transactionID, client: client) + } + } + + private func detailRow(label: String, value: String) -> some View { + HStack { + Text(label) + Spacer() + Text(value) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + } + } +} diff --git a/Rune/Views/Settings/WalletTransactionsView.swift b/Rune/Views/Settings/WalletTransactionsView.swift new file mode 100644 index 0000000..f0106de --- /dev/null +++ b/Rune/Views/Settings/WalletTransactionsView.swift @@ -0,0 +1,75 @@ +import SwiftUI + +struct WalletTransactionsView: View { + @ObservedObject var viewModel: WalletViewModel + let client: NjallaClient + + var body: some View { + List { + if let errorMessage = viewModel.transactionsErrorMessage { + Section { + InlineErrorView(message: errorMessage, retryTitle: "Retry Transactions") { + Task { + await viewModel.loadTransactions(client: client) + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + } + + if viewModel.isLoadingTransactions && viewModel.transactions.isEmpty { + Section { + HStack { + Spacer() + ProgressView("Loading Transactions") + Spacer() + } + } + } else if viewModel.transactions.isEmpty { + Section { + ContentUnavailableView( + "No Transactions", + systemImage: "eurosign.circle", + description: Text("No wallet transactions were returned for this account.") + ) + } + } else { + ForEach(viewModel.transactions) { transaction in + NavigationLink { + WalletPaymentDetailView(transactionID: transaction.id, viewModel: viewModel, client: client) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(transaction.type ?? "Transaction") + .font(.headline) + Text(transactionDateText(transaction)) + .font(.subheadline) + .foregroundStyle(.secondary) + if let amount = transaction.amount { + Text("Amount: €\(amount)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Transactions") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.loadTransactions(client: client) + } + .refreshable { + await viewModel.loadTransactions(client: client) + } + } + + private func transactionDateText(_ transaction: WalletTransaction) -> String { + if let date = transaction.date, !date.isEmpty { + return date + } + return "ID: \(transaction.id)" + } +} -- cgit v1.2.3