import SwiftUI import SFSafeSymbols struct ContentView: View { private let deviceInfoUpdateInterval = 3.0 private let minTempColor = Color(hue: 0.624, saturation: 0.5, brightness: 1.0) private let minTemperature = -20.0 private let maxTempColor = Color(hue: 1.0, saturation: 0.5, brightness: 1.0) private let maxTemperature = 40.0 private let disconnectedColor = Color(white: 0.8) @ObservedObject var scanner: BluetoothScanner @EnvironmentObject var storage: PersistentStorage @EnvironmentObject var transfer: TransferHandler @State var showDeviceInfo = false @State var showHistory = false @State var showLog = false @State var showDataTransferView = false @State var deviceInfoUpdateTimer: Timer? var averageTemperature: Double? { guard scanner.configuredDevice != nil else { return nil } guard let info = scanner.lastDeviceInfo else { return nil } let t1 = info.sensor1?.optionalValue guard let t0 = info.sensor0?.optionalValue else { return t1 } guard let t1 else { return t0 } return (t0 + t1) / 2 } var hasTemperature: Bool { averageTemperature != nil } var temperatureString: String { guard let temp = averageTemperature else { return "?" } return String(format: "%.0f°", locale: .current, temp) } var temperatureIcon: SFSymbol { guard let temp = averageTemperature else { return .thermometerMediumSlash } guard temp > 0 else { return .thermometerSnowflake } guard temp > 15 else { return .thermometerLow } guard temp > 25 else { return .thermometerMedium } return .thermometerHigh } var backgroundColor: Color { guard let temp = averageTemperature else { return disconnectedColor } guard temp > minTemperature else { return minTempColor } guard temp < maxTemperature else { return maxTempColor } let ratio = (temp - minTemperature) / (maxTemperature - minTemperature) return minTempColor.blend(to: maxTempColor, intensity: ratio) } var backgroundGradient: Gradient { let color = backgroundColor let lighter = color.opacity(0.5) return .init(colors: [lighter, color]) } var connectionSymbol: SFSymbol { if scanner.configuredDevice != nil { return .iphoneCircle } if !scanner.bluetoothIsAvailable { return .antennaRadiowavesLeftAndRightSlash } switch scanner.connectionState { case .noDeviceFound: if scanner.isScanningForDevices { return .iphoneRadiowavesLeftAndRightCircle } return .iphoneSlashCircle case .connecting: return .arrowRightToLineCircle case .discoveringService: return .linkCircle case .discoveringCharacteristic: return .magnifyingglassCircle } } var hasNoDeviceInfo: Bool { scanner.lastDeviceInfo == nil } var isDisconnected: Bool { scanner.configuredDevice == nil } var body: some View { VStack { Spacer() if hasTemperature { Text(temperatureString) .font(.system(size: 150, weight: .light)) .foregroundColor(.white) } else { Image(systemSymbol: temperatureIcon) .font(.system(size: 100, weight: .thin)) .foregroundColor(.gray) } Spacer() Button { self.showHistory = true } label: { ZStack { TemperatureHistoryChart(points: $storage.recentMeasurements) .frame(height: 300) .background(Color.white.opacity(0.1)) .cornerRadius(8) if storage.recentMeasurements.isEmpty { Text("No recent measurements") .foregroundColor(.white) } } } HStack(alignment: .center) { Button { self.showLog = true } label: { Image(systemSymbol: .paperclipCircle) .foregroundColor(.white) } Spacer() Button { if scanner.isScanningForDevices { scanner.isScanningForDevices = false } else if scanner.isConnectingOrConnected { scanner.disconnect() } else { scanner.isScanningForDevices = true } } label: { Image(systemSymbol: connectionSymbol) .foregroundColor(.white) } .foregroundColor(.white) Spacer() if scanner.lastDeviceInfo != nil { Button { self.showDeviceInfo = true } label: { Image(systemSymbol: .infoCircle) .foregroundColor(.white) } Spacer() Button { showDataTransferView = true } label: { Image(systemSymbol: .arrowUpArrowDownCircle) .foregroundColor(.white) } } else { Image(systemSymbol: .infoCircle) .foregroundColor(.gray) Spacer() Image(systemSymbol: .arrowUpArrowDownCircle) .foregroundColor(.gray) } } .padding() .font(.system(size: 30, weight: .light)) } .padding() .sheet(isPresented: $showDeviceInfo) { if let info = scanner.lastDeviceInfo { DeviceInfoView(info: info, isPresented: $showDeviceInfo) } else { EmptyView() } } .sheet(isPresented: $showHistory) { HistoryList() .environmentObject(storage) } .sheet(isPresented: $showLog) { LogView() .environmentObject(log) .environmentObject(storage) } .sheet(isPresented: $showDataTransferView) { if let client = scanner.configuredDevice { TransferView( bluetoothClient: client, info: $scanner.lastDeviceInfo) .environmentObject(storage) .environmentObject(transfer) } else { EmptyView() } } .background(backgroundGradient) .onAppear(perform: startDeviceInfoUpdates) .onDisappear(perform: endDeviceInfoUpdates) } // MARK: Device info updates private func startDeviceInfoUpdates() { deviceInfoUpdateTimer?.invalidate() log.info("Starting device info updates") deviceInfoUpdateTimer = Timer.scheduledTimer(withTimeInterval: deviceInfoUpdateInterval, repeats: true) { timer in self.updateDeviceInfo() } deviceInfoUpdateTimer?.fire() } private func updateDeviceInfo() { guard let bluetoothDevice = scanner.configuredDevice else { return } Task { await bluetoothDevice.updateInfo() } } private func endDeviceInfoUpdates() { deviceInfoUpdateTimer?.invalidate() deviceInfoUpdateTimer = nil } } struct ContentView_Previews: PreviewProvider { static var previews: some View { let storage = PersistentStorage(lastMeasurements: TemperatureMeasurement.mockData) ContentView(scanner: .init()) .environmentObject(storage) } } extension HorizontalAlignment { private struct InfoTextAlignment: AlignmentID { static func defaultValue(in context: ViewDimensions) -> CGFloat { context[HorizontalAlignment.leading] } } static let infoTextAlignmentGuide = HorizontalAlignment( InfoTextAlignment.self ) }