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>
343 lines
14 KiB
Swift
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]))"
|
|
}
|
|
}
|
|
|