154 lines
5.3 KiB
Swift
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) }
|
|
}
|