1 contributor
581 lines | 27.85kb
//
//  ChargeRecordSheetView.swift
//  USB Meter
//
//  Created by Bogdan Timofte on 09/03/2020.
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
//

import SwiftUI

struct ChargeRecordSheetView: View {
    
    @Binding var visibility: Bool
    @EnvironmentObject private var appData: AppData
    @EnvironmentObject private var usbMeter: Meter
    @State private var chargedDeviceLibraryVisibility = false
    @State private var chargerLibraryVisibility = false
    @State private var checkpointEditorVisibility = false
    @State private var editingChargedDevice: ChargedDeviceSummary?
    @State private var targetNotificationEditorVisibility = false
    
    var body: some View {
        NavigationView {
            ScrollView {
                VStack(spacing: 16) {
                    VStack(alignment: .leading, spacing: 8) {
                        HStack {
                            Text("Charge Record")
                                .font(.system(.title3, design: .rounded).weight(.bold))
                            Spacer()
                            Text(usbMeter.chargeRecordStatusText)
                                .font(.caption.weight(.bold))
                                .foregroundColor(usbMeter.chargeRecordStatusColor)
                                .padding(.horizontal, 10)
                                .padding(.vertical, 6)
                                .meterCard(
                                    tint: usbMeter.chargeRecordStatusColor,
                                    fillOpacity: 0.18,
                                    strokeOpacity: 0.24,
                                    cornerRadius: 999
                                )
                        }
                        Text("App-side charge accumulation based on the stop-threshold workflow.")
                            .font(.footnote)
                            .foregroundColor(.secondary)
                    }
                    .frame(maxWidth: .infinity)
                    .padding(18)
                    .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)

                    chargedDeviceSection

                    if let activeChargeSession {
                        chargeMonitorSection(activeChargeSession)
                    }

                    ChargeRecordMetricsTableView(
                        labels: ["Capacity", "Energy", "Duration", "Stop Threshold"],
                        values: [
                            "\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah",
                            "\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh",
                            usbMeter.chargeRecordDurationDescription,
                            "\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A"
                        ]
                    )
                    .padding(18)
                    .meterCard(tint: .pink, fillOpacity: 0.14, strokeOpacity: 0.20)

                    if usbMeter.chargeRecordTimeRange != nil {
                        VStack(alignment: .leading, spacing: 12) {
                            HStack {
                                Text("Charge Curve")
                                    .font(.headline)
                                Spacer()
                                Button("Reset Graph") {
                                    usbMeter.resetChargeRecordGraph()
                                }
                                .foregroundColor(.red)
                            }
                            MeasurementChartView(timeRange: usbMeter.chargeRecordTimeRange)
                                .environmentObject(usbMeter.measurements)
                                .frame(minHeight: 220)
                            Text("Reset Graph clears the current charge-record session and removes older shared samples that are no longer needed for this curve.")
                                .font(.footnote)
                                .foregroundColor(.secondary)
                        }
                        .padding(18)
                        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
                    }

                    VStack(alignment: .leading, spacing: 12) {
                        Text("Stop Threshold")
                            .font(.headline)
                        Slider(value: $usbMeter.chargeRecordStopThreshold, in: 0...0.30, step: 0.01)
                        Text("The app starts accumulating when current rises above this threshold and stops when it falls back to or below it.")
                            .font(.footnote)
                            .foregroundColor(.secondary)
                        Button("Reset") {
                            usbMeter.resetChargeRecord()
                        }
                        .frame(maxWidth: .infinity)
                        .padding(.vertical, 10)
                        .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
                        .buttonStyle(.plain)
                    }
                    .padding(18)
                    .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)

                    if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 {
                        VStack(alignment: .leading, spacing: 12) {
                            Text("Meter Totals")
                                .font(.headline)
                            ChargeRecordMetricsTableView(
                                labels: ["Capacity", "Energy", "Duration", "Meter Threshold"],
                                values: [
                                    "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah",
                                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
                                    usbMeter.recordingDurationDescription,
                                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
                                ]
                            )
                            Text("These values are reported by the meter for the active data group.")
                                .font(.footnote)
                                .foregroundColor(.secondary)
                            if usbMeter.supportsDataGroupCommands {
                                Button("Reset Active Group") {
                                    usbMeter.clear()
                                }
                                .frame(maxWidth: .infinity)
                                .padding(.vertical, 10)
                                .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
                                .buttonStyle(.plain)
                            }
                        }
                        .padding(18)
                        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
                    }
                }
                .padding()
            }
            .background(
                LinearGradient(
                    colors: [.pink.opacity(0.14), Color.clear],
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
                .ignoresSafeArea()
            )
            .navigationBarTitle("Charge Record", displayMode: .inline)
            .navigationBarItems(trailing: Button("Done") { visibility.toggle() })
        }
        .navigationViewStyle(StackNavigationViewStyle())
        .sheet(isPresented: $chargedDeviceLibraryVisibility) {
            ChargedDeviceLibrarySheetView(
                visibility: $chargedDeviceLibraryVisibility,
                meterMACAddress: usbMeter.btSerial.macAddress.description,
                meterTint: usbMeter.color,
                mode: .device
            )
            .environmentObject(appData)
        }
        .sheet(isPresented: $chargerLibraryVisibility) {
            ChargedDeviceLibrarySheetView(
                visibility: $chargerLibraryVisibility,
                meterMACAddress: usbMeter.btSerial.macAddress.description,
                meterTint: usbMeter.color,
                mode: .charger
            )
            .environmentObject(appData)
        }
        .sheet(isPresented: $checkpointEditorVisibility) {
            BatteryCheckpointEditorSheetView()
                .environmentObject(appData)
                .environmentObject(usbMeter)
        }
        .sheet(item: $editingChargedDevice) { chargedDevice in
            ChargedDeviceEditorSheetView(
                meterMACAddress: nil,
                chargedDevice: chargedDevice
            )
            .environmentObject(appData)
        }
        .sheet(isPresented: $targetNotificationEditorVisibility) {
            if let activeChargeSession {
                BatteryTargetNotificationEditorSheetView(
                    sessionID: activeChargeSession.id,
                    initialTargetPercent: activeChargeSession.targetBatteryPercent
                )
                .environmentObject(appData)
            }
        }
    }

    private var selectedChargedDevice: ChargedDeviceSummary? {
        appData.currentChargedDeviceSummary(for: usbMeter.btSerial.macAddress.description)
    }

    private var activeChargeSession: ChargeSessionSummary? {
        appData.activeChargeSessionSummary(for: usbMeter.btSerial.macAddress.description)
    }

    private var selectedCharger: ChargedDeviceSummary? {
        appData.currentChargerSummary(for: usbMeter.btSerial.macAddress.description)
    }

    private var chargedDeviceSection: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Text("Device")
                    .font(.headline)
                Spacer()
                Button("Library") {
                    chargedDeviceLibraryVisibility = true
                }
            }

            if let selectedChargedDevice {
                HStack(alignment: .top, spacing: 14) {
                    ChargedDeviceQRCodeView(
                        qrIdentifier: selectedChargedDevice.qrIdentifier,
                        side: 88
                    )

                    VStack(alignment: .leading, spacing: 8) {
                        Label(selectedChargedDevice.name, systemImage: selectedChargedDevice.deviceClass.symbolName)
                            .font(.headline)

                        Text(selectedChargedDevice.deviceClass.title)
                            .font(.caption.weight(.semibold))
                            .foregroundColor(.secondary)

                        Text(selectedChargedDevice.supportsChargingWhileOff ? "Can finish charging while off" : "Needs on-state sessions to estimate capacity carefully")
                            .font(.caption2)
                            .foregroundColor(.secondary)

                        if selectedChargedDevice.supportedChargingModes.count == 1 {
                            Label(
                                "Charging via \(selectedChargedDevice.preferredChargingTransportMode.title)",
                                systemImage: selectedChargedDevice.preferredChargingTransportMode.symbolName
                            )
                            .font(.caption2)
                            .foregroundColor(.secondary)
                        } else {
                            Picker("Charging Type", selection: chargingTransportModeBinding(for: selectedChargedDevice)) {
                                ForEach(selectedChargedDevice.supportedChargingModes) { chargingTransportMode in
                                    Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
                                        .tag(chargingTransportMode)
                                }
                            }
                            .pickerStyle(.segmented)
                        }

                        if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
                            Text("Estimated \(effectiveChargingTransportMode(for: selectedChargedDevice).title.lowercased()) capacity: \(capacity.format(decimalDigits: 2)) Wh")
                                .font(.caption)
                                .foregroundColor(.secondary)
                        } else if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh {
                            Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }

                        if let minimumCurrent = selectedChargedDevice.resolvedCompletionCurrentAmps(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
                            Text("\(effectiveChargingTransportMode(for: selectedChargedDevice).title) completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
                                .font(.caption2)
                                .foregroundColor(.secondary)
                        } else if let minimumCurrent = selectedChargedDevice.minimumCurrentAmps {
                            Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
                                .font(.caption2)
                                .foregroundColor(.secondary)
                        }
                    }

                    Spacer(minLength: 0)
                }

                if shouldShowWirelessChargerSection(for: selectedChargedDevice) {
                    Divider()

                    VStack(alignment: .leading, spacing: 10) {
                        HStack {
                            Text("Wireless Charger")
                                .font(.subheadline.weight(.semibold))
                            Spacer()
                            Button(selectedCharger == nil ? "Select" : "Change") {
                                chargerLibraryVisibility = true
                            }
                        }

                        if let selectedCharger {
                            HStack(alignment: .top, spacing: 12) {
                                ChargedDeviceQRCodeView(
                                    qrIdentifier: selectedCharger.qrIdentifier,
                                    side: 62
                                )

                                VStack(alignment: .leading, spacing: 6) {
                                    Label(selectedCharger.name, systemImage: selectedCharger.deviceClass.symbolName)
                                        .font(.subheadline.weight(.semibold))

                                    if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
                                        Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
                                            .font(.caption)
                                            .foregroundColor(.secondary)
                                    }

                                    if !selectedCharger.chargerObservedVoltageSelections.isEmpty {
                                        Text(
                                            "Observed voltages: " + selectedCharger.chargerObservedVoltageSelections
                                                .map { "\($0.format(decimalDigits: 1)) V" }
                                                .joined(separator: ", ")
                                        )
                                        .font(.caption2)
                                        .foregroundColor(.secondary)
                                    }
                                }
                            }
                        } else {
                            Text("Wireless sessions need a selected charger in addition to the charged device.")
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }
                    }
                }

                Button("Add Battery Checkpoint") {
                    checkpointEditorVisibility = true
                }
                .frame(maxWidth: .infinity)
                .padding(.vertical, 10)
                .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
                .buttonStyle(.plain)

                Button("Edit Device") {
                    editingChargedDevice = selectedChargedDevice
                }
                .frame(maxWidth: .infinity)
                .padding(.vertical, 10)
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
                .buttonStyle(.plain)
            } else {
                Text("Select or create the device you are charging. New sessions, checkpoints, QR identity, capacity tracking, and curve learning are all anchored to that device.")
                    .font(.footnote)
                    .foregroundColor(.secondary)
            }
        }
        .padding(18)
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
    }

    private func chargeMonitorSection(_ activeChargeSession: ChargeSessionSummary) -> some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Charging Monitor")
                .font(.headline)

            ChargeRecordMetricsTableView(
                labels: ["Source", "Energy", "Charge", "Stop Threshold"],
                values: [
                    activeChargeSession.sourceMode.title,
                    "\(activeChargeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh",
                    "\(activeChargeSession.measuredChargeAh.format(decimalDigits: 3)) Ah",
                    "\(activeChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A"
                ]
            )

            if let selectedChargedDevice,
               let batteryPrediction = selectedChargedDevice.batteryLevelPrediction(for: activeChargeSession) {
                VStack(alignment: .leading, spacing: 4) {
                    Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
                        .font(.caption.weight(.semibold))
                    Text(
                        "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
                    )
                    .font(.caption2)
                    .foregroundColor(.secondary)
                }
            }

            if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
                Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%")
                    .font(.caption.weight(.semibold))
            } else {
                Text("No target battery notification configured.")
                    .font(.caption)
                    .foregroundColor(.secondary)
            }

            Button(activeChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
                targetNotificationEditorVisibility = true
            }
            .frame(maxWidth: .infinity)
            .padding(.vertical, 10)
            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
            .buttonStyle(.plain)

            if activeChargeSession.targetBatteryPercent != nil {
                Button("Clear Target Notification") {
                    _ = appData.setTargetBatteryPercent(nil, for: activeChargeSession.id)
                }
                .frame(maxWidth: .infinity)
                .padding(.vertical, 10)
                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
                .buttonStyle(.plain)
            }

            if activeChargeSession.requiresCompletionConfirmation {
                completionConfirmationCard(activeChargeSession)
            }

            if let capacityEstimateWh = activeChargeSession.capacityEstimateWh {
                Text("Current \(activeChargeSession.chargingTransportMode.title.lowercased()) capacity estimate: \(capacityEstimateWh.format(decimalDigits: 2)) Wh")
                    .font(.caption.weight(.semibold))
            }

            Label(
                "Session charging type: \(activeChargeSession.chargingTransportMode.title)",
                systemImage: activeChargeSession.chargingTransportMode.symbolName
            )
            .font(.caption)
            .foregroundColor(.secondary)

            if activeChargeSession.chargingTransportMode == .wireless {
                if let chargerID = activeChargeSession.chargerID,
                   let charger = appData.chargedDeviceSummary(id: chargerID) {
                    Label("Wireless charger: \(charger.name)", systemImage: "bolt.badge.clock")
                        .font(.caption)
                        .foregroundColor(.secondary)
                } else {
                    Text("No wireless charger is currently selected for this session.")
                        .font(.caption)
                        .foregroundColor(.orange)
                }
            }

            if activeChargeSession.checkpoints.isEmpty == false {
                VStack(alignment: .leading, spacing: 8) {
                    Text("Battery Checkpoints")
                        .font(.subheadline.weight(.semibold))

                    ForEach(activeChargeSession.checkpoints.suffix(4), id: \.id) { checkpoint in
                        HStack {
                            Text(checkpoint.timestamp.format())
                                .font(.caption2)
                                .foregroundColor(.secondary)
                            Spacer()
                            Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
                                .font(.caption.weight(.semibold))
                            Text("•")
                                .foregroundColor(.secondary)
                            Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
                                .font(.caption2)
                                .foregroundColor(.secondary)
                        }
                    }
                }
            }

            Text("The monitor prefers the meter's offline counters when available, then blends them with live samples so reconnects do not lose the real transferred energy.")
                .font(.footnote)
                .foregroundColor(.secondary)
        }
        .padding(18)
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
    }

    private func completionConfirmationCard(_ activeChargeSession: ChargeSessionSummary) -> some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Completion Needs Confirmation")
                .font(.subheadline.weight(.semibold))

            if let contradictionPercent = activeChargeSession.completionContradictionPercent {
                Text("Current dropped below the stop threshold, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
                    .font(.caption)
                    .foregroundColor(.secondary)
            } else {
                Text("Current dropped below the stop threshold, but the estimated battery level does not match a normal charge end.")
                    .font(.caption)
                    .foregroundColor(.secondary)
            }

            if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
                Text("The active target is \(targetBatteryPercent.format(decimalDigits: 0))%.")
                    .font(.caption2)
                    .foregroundColor(.secondary)
            }

            Button("Finish Session") {
                _ = appData.confirmChargeSessionCompletion(sessionID: activeChargeSession.id)
            }
            .frame(maxWidth: .infinity)
            .padding(.vertical, 10)
            .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
            .buttonStyle(.plain)

            Button("Keep Monitoring") {
                _ = appData.continueChargeSessionMonitoring(sessionID: activeChargeSession.id)
            }
            .frame(maxWidth: .infinity)
            .padding(.vertical, 10)
            .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
            .buttonStyle(.plain)
        }
        .padding(14)
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
    }

    private func chargingTransportModeBinding(for chargedDevice: ChargedDeviceSummary) -> Binding<ChargingTransportMode> {
        Binding(
            get: {
                effectiveChargingTransportMode(for: chargedDevice)
            },
            set: { newValue in
                _ = appData.setChargingTransportMode(newValue, for: usbMeter)
            }
        )
    }

    private func effectiveChargingTransportMode(for chargedDevice: ChargedDeviceSummary) -> ChargingTransportMode {
        activeChargeSession?.chargingTransportMode ?? chargedDevice.preferredChargingTransportMode
    }

    private func shouldShowWirelessChargerSection(for chargedDevice: ChargedDeviceSummary) -> Bool {
        effectiveChargingTransportMode(for: chargedDevice) == .wireless
    }
}

struct ChargeRecordSheetView_Previews: PreviewProvider {
    static var previews: some View {
        ChargeRecordSheetView(visibility: .constant(true))
    }
}

struct BatteryTargetNotificationEditorSheetView: View {
    @Environment(\.dismiss) private var dismiss
    @EnvironmentObject private var appData: AppData

    let sessionID: UUID
    let initialTargetPercent: Double?

    @State private var targetPercent: Double

    init(sessionID: UUID, initialTargetPercent: Double?) {
        self.sessionID = sessionID
        self.initialTargetPercent = initialTargetPercent
        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
    }

    var body: some View {
        NavigationView {
            Form {
                Section(header: Text("Target Level")) {
                    VStack(alignment: .leading, spacing: 12) {
                        Text("\(targetPercent.format(decimalDigits: 0))%")
                            .font(.title3.weight(.bold))
                        Slider(value: $targetPercent, in: 20...100, step: 1)
                        Text("A local notification will be generated on synced devices when the estimated battery level reaches this target.")
                            .font(.footnote)
                            .foregroundColor(.secondary)
                    }
                }
            }
            .navigationTitle("Battery Target")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") {
                        dismiss()
                    }
                }

                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
                            dismiss()
                        }
                    }
                }
            }
        }
    }
}