payfrit-beacon-ios/PayfritBeacon/ServicePointListView.swift
John Pinkyfloyd 5283d2d265 Fix DX-Smart provisioning protocol and add debug logging
Fix critical packet format bugs matching SDK: frame select/type/trigger/disable
commands now send empty data, RSSI@1m corrected to -59 dBm. Add DebugLog,
read-config mode, service point list, and dev scheme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:01:12 -08:00

343 lines
14 KiB
Swift

import SwiftUI
struct ServicePointListView: View {
let businessId: Int
let businessName: String
var onBack: () -> Void
@State private var namespace: BusinessNamespace?
@State private var servicePoints: [ServicePoint] = []
@State private var isLoading = true
@State private var errorMessage: String?
// Add service point
@State private var showAddSheet = false
@State private var newServicePointName = ""
@State private var isAdding = false
// Beacon scan
@State private var showScanView = false
// Re-provision existing service point
@State private var tappedServicePoint: ServicePoint?
@State private var reprovisionServicePoint: ServicePoint?
var body: some View {
NavigationStack {
Group {
if isLoading {
VStack {
Spacer()
ProgressView("Loading...")
Spacer()
}
} else if let error = errorMessage {
VStack(spacing: 16) {
Spacer()
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.orange)
Text(error)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button("Retry") { loadData() }
.buttonStyle(.borderedProminent)
.tint(.payfritGreen)
Spacer()
}
.padding()
} else {
List {
// Namespace section
if let ns = namespace {
Section {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("UUID")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(formatUuidWithDashes(ns.uuid))
.font(.system(.caption, design: .monospaced))
Button {
UIPasteboard.general.string = formatUuidWithDashes(ns.uuid)
} label: {
Image(systemName: "doc.on.doc")
.font(.caption)
}
}
HStack {
Text("Major")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text("\(ns.major)")
.font(.system(.body, design: .monospaced).weight(.semibold))
Button {
UIPasteboard.general.string = "\(ns.major)"
} label: {
Image(systemName: "doc.on.doc")
.font(.caption)
}
}
}
} header: {
Label("Beacon Namespace", systemImage: "antenna.radiowaves.left.and.right")
}
}
// Service points section
Section {
if servicePoints.isEmpty {
VStack(spacing: 8) {
Text("No service points yet")
.foregroundColor(.secondary)
Text("Tap + to add one")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else {
ForEach(servicePoints) { sp in
Button {
tappedServicePoint = sp
} label: {
HStack {
Text(sp.name)
.foregroundColor(.primary)
Spacer()
if let minor = sp.beaconMinor {
Text("Minor: \(minor)")
.font(.caption)
.foregroundColor(.secondary)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
} header: {
Text("Service Points")
}
}
}
}
.navigationTitle(businessName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Back", action: onBack)
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button {
showScanView = true
} label: {
Image(systemName: "antenna.radiowaves.left.and.right")
}
Button {
showAddSheet = true
} label: {
Image(systemName: "plus")
}
}
}
}
.onAppear { loadData() }
.sheet(isPresented: $showAddSheet) { addServicePointSheet }
.fullScreenCover(isPresented: $showScanView) {
ScanView(
businessId: businessId,
businessName: businessName,
onBack: {
showScanView = false
loadData()
}
)
}
.fullScreenCover(item: $reprovisionServicePoint) { sp in
ScanView(
businessId: businessId,
businessName: businessName,
reprovisionServicePoint: sp,
onBack: {
reprovisionServicePoint = nil
loadData()
}
)
}
.confirmationDialog(
tappedServicePoint?.name ?? "Service Point",
isPresented: Binding(
get: { tappedServicePoint != nil },
set: { if !$0 { tappedServicePoint = nil } }
),
titleVisibility: .visible
) {
Button("Re-provision Beacon") {
reprovisionServicePoint = tappedServicePoint
tappedServicePoint = nil
}
Button("Delete", role: .destructive) {
if let sp = tappedServicePoint {
deleteServicePoint(sp)
}
tappedServicePoint = nil
}
Button("Cancel", role: .cancel) {
tappedServicePoint = nil
}
}
}
// MARK: - Add Sheet
private var addServicePointSheet: some View {
NavigationStack {
VStack(spacing: 20) {
VStack(alignment: .leading, spacing: 8) {
Text("Service Point Name")
.font(.subheadline)
.foregroundColor(.secondary)
TextField("e.g., Table 1", text: $newServicePointName)
.textFieldStyle(.roundedBorder)
.font(.title3)
}
.padding(.horizontal)
if let ns = namespace {
VStack(alignment: .leading, spacing: 8) {
Text("Beacon config will be assigned automatically:")
.font(.caption)
.foregroundColor(.secondary)
HStack {
VStack(alignment: .leading) {
Text("UUID").font(.caption2).foregroundColor(.secondary)
Text(formatUuidWithDashes(ns.uuid))
.font(.system(.caption2, design: .monospaced))
}
}
HStack(spacing: 24) {
VStack(alignment: .leading) {
Text("Major").font(.caption2).foregroundColor(.secondary)
Text("\(ns.major)").font(.system(.caption, design: .monospaced).weight(.semibold))
}
VStack(alignment: .leading) {
Text("Minor").font(.caption2).foregroundColor(.secondary)
Text("Auto").font(.caption.italic()).foregroundColor(.secondary)
}
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
.padding(.horizontal)
}
Spacer()
}
.padding(.top)
.navigationTitle("Add Service Point")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showAddSheet = false }
.disabled(isAdding)
}
ToolbarItem(placement: .confirmationAction) {
if isAdding {
ProgressView()
} else {
Button("Add") { addServicePoint() }
.disabled(newServicePointName.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
.presentationDetents([.medium])
.interactiveDismissDisabled(isAdding)
.onAppear {
newServicePointName = "Table \(nextTableNumber)"
}
}
// MARK: - Actions
private func loadData() {
isLoading = true
errorMessage = nil
Task {
do {
// Get namespace
let ns = try await Api.shared.allocateBusinessNamespace(businessId: businessId)
namespace = ns
// Get service points
let sps = try await Api.shared.listServicePoints(businessId: businessId)
servicePoints = sps.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
private func deleteServicePoint(_ sp: ServicePoint) {
Task {
do {
try await Api.shared.deleteServicePoint(businessId: businessId, servicePointId: sp.servicePointId)
servicePoints.removeAll { $0.servicePointId == sp.servicePointId }
} catch {
errorMessage = error.localizedDescription
}
}
}
private func addServicePoint() {
let name = newServicePointName.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
isAdding = true
Task {
do {
let sp = try await Api.shared.saveServicePoint(businessId: businessId, name: name)
servicePoints.append(sp)
servicePoints.sort { $0.name.localizedCompare($1.name) == .orderedAscending }
showAddSheet = false
newServicePointName = ""
isAdding = false
} catch {
// Show error somehow
isAdding = false
}
}
}
private var nextTableNumber: Int {
let maxNumber = servicePoints.compactMap { sp -> Int? in
guard let match = sp.name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) else {
return nil
}
let numberStr = sp.name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
return Int(numberStr)
}.max() ?? 0
return maxNumber + 1
}
private func formatUuidWithDashes(_ raw: String) -> String {
let clean = raw.replacingOccurrences(of: "-", with: "").uppercased()
guard clean.count == 32 else { return raw }
let chars = Array(clean)
return "\(String(chars[0..<8]))-\(String(chars[8..<12]))-\(String(chars[12..<16]))-\(String(chars[16..<20]))-\(String(chars[20..<32]))"
}
}