payfrit-beacon-ios/PayfritBeacon/Views/BeaconListScreen.swift
2026-02-01 23:39:29 -08:00

154 lines
5.3 KiB
Swift

import SwiftUI
struct BeaconListScreen: View {
@EnvironmentObject var appState: AppState
@State private var beacons: [Beacon] = []
@State private var isLoading = true
@State private var error: String?
@State private var showAddSheet = false
@State private var isDeleting = false
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Loading beacons...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = error {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.orange)
Text(error)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button("Retry") { loadBeacons() }
.buttonStyle(.borderedProminent)
.tint(.payfritGreen)
}
.padding()
} else if beacons.isEmpty {
VStack(spacing: 16) {
Image(systemName: "sensor.tag.radiowaves.forward.fill")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("No beacons yet")
.font(.title3)
.foregroundColor(.secondary)
Text("Tap + to add your first beacon")
.font(.subheadline)
.foregroundColor(.secondary)
}
} else {
List {
ForEach(beacons) { beacon in
NavigationLink(value: beacon) {
BeaconRow(beacon: beacon)
}
}
.onDelete(perform: deleteBeacons)
}
}
}
.navigationTitle("Beacons")
.navigationDestination(for: Beacon.self) { beacon in
BeaconDetailScreen(beacon: beacon, onSaved: { loadBeacons() })
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showAddSheet = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddSheet) {
BeaconEditSheet(onSaved: { loadBeacons() })
}
.refreshable {
await withCheckedContinuation { continuation in
loadBeacons { continuation.resume() }
}
}
}
.task { loadBeacons() }
}
private func loadBeacons(completion: (() -> Void)? = nil) {
isLoading = beacons.isEmpty
error = nil
Task {
do {
beacons = try await APIService.shared.listBeacons()
} catch let apiError as APIError where apiError == .unauthorized {
await appState.handleUnauthorized()
} catch {
self.error = error.localizedDescription
}
isLoading = false
completion?()
}
}
private func deleteBeacons(at offsets: IndexSet) {
guard !isDeleting else { return }
let toDelete = offsets.map { beacons[$0] }
// Optimistic removal
beacons.remove(atOffsets: offsets)
isDeleting = true
Task {
var failedBeacons: [Beacon] = []
for beacon in toDelete {
do {
try await APIService.shared.deleteBeacon(beaconId: beacon.id)
} catch {
failedBeacons.append(beacon)
self.error = error.localizedDescription
}
}
// Restore any that failed to delete
if !failedBeacons.isEmpty {
beacons.append(contentsOf: failedBeacons)
}
isDeleting = false
}
}
}
// MARK: - Beacon Row
struct BeaconRow: View {
let beacon: Beacon
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(beacon.name)
.font(.headline)
Spacer()
if beacon.isActive {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.caption)
} else {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.font(.caption)
}
}
Text(beacon.formattedUUID)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
.padding(.vertical, 2)
}
}
// Make Beacon Hashable for NavigationLink
extension Beacon: Hashable {
static func == (lhs: Beacon, rhs: Beacon) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}