import SwiftUI import MacToolsPluginKit struct DeviceBatterySettingsView: View { @ObservedObject var store: DeviceBatteryStore let localization: PluginLocalization let onChange: () -> Void let onNotificationSettingsChange: () -> Void @State private var thresholdText: String init( store: DeviceBatteryStore, localization: PluginLocalization = PluginLocalization(bundle: .main), onChange: @escaping () -> Void, onNotificationSettingsChange: @escaping () -> Void = {} ) { self.store = store self.localization = localization self.onChange = onChange self.onNotificationSettingsChange = onNotificationSettingsChange _thresholdText = State(initialValue: "\(store.lowBatteryNotificationThreshold)") } var body: some View { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { layoutSection sourceSection notificationSection } .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: store.lowBatteryNotificationThreshold) { _, newValue in thresholdText = "\(newValue)" } } private var layoutSection: some View { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { sectionHeader( systemName: "rectangle.grid.2x2", title: localization.string("settings.layout.title", defaultValue: "组件布局") ) VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowContentControl) { HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { ForEach(DeviceBatteryLayoutMode.allCases, id: \.self) { mode in DeviceBatteryLayoutModeButton( mode: mode, isSelected: store.layoutMode == mode, iconSystemName: iconName(for: mode), localization: localization, action: { store.setLayoutMode(mode) onChange() } ) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(PluginSettingsTheme.Spacing.cardContent) .pluginSettingsCardBackground(.host) } } private var sourceSection: some View { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { sectionHeader( systemName: "bolt.horizontal.circle", title: localization.string("settings.sources.title", defaultValue: "显示内容") ) VStack(spacing: 0) { sourceToggle( title: localization.string("settings.source.internal.title", defaultValue: "Mac 内置电池"), description: localization.string( "settings.source.internal.description", defaultValue: "显示本机电量、充电状态和剩余时间。" ), isOn: store.showInternalBattery, isFirst: true, action: store.setShowInternalBattery ) sourceToggle( title: localization.string( "settings.source.appleMobile.title", defaultValue: "iPhone、iPad 与 Apple Watch" ), description: localization.string( "settings.source.appleMobile.description", defaultValue: "读取已信任并通过 USB 或 Wi-Fi 连接的 Apple 移动设备。" ), isOn: store.showAppleMobileDevices, action: store.setShowAppleMobileDevices ) sourceToggle( title: localization.string("settings.source.bluetooth.title", defaultValue: "蓝牙与 Apple 外设"), description: localization.string( "settings.source.bluetooth.description", defaultValue: "读取系统可见的蓝牙设备、AirPods 分体电量和 Magic 外设。" ), isOn: store.showBluetoothDevices, action: store.setShowBluetoothDevices ) sourceToggle( title: localization.string("settings.source.rapoo.title", defaultValue: "厂商 HID 鼠标"), description: localization.string( "settings.source.rapoo.description", defaultValue: "读取已适配鼠标的电量、充电状态、设备型号和名称。" ), isOn: store.showRapooDevices, isLast: true, action: store.setShowRapooDevices ) } .pluginSettingsCardBackground(.plugin) } } private var notificationSection: some View { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { sectionHeader( systemName: "bell.badge", title: localization.string("settings.notification.title", defaultValue: "低电量通知") ) VStack(spacing: 0) { HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { Text(localization.string("settings.notification.lowBattery.title", defaultValue: "发送低电量通知")) .font(PluginSettingsTheme.Typography.rowTitle) Text(localization.string( "settings.notification.lowBattery.description", defaultValue: "设备电量低于阈值时发送系统通知。" )) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) .lineLimit(2) } Spacer(minLength: 12) Toggle("", isOn: Binding( get: { store.lowBatteryNotificationEnabled }, set: { newValue in store.setLowBatteryNotificationEnabled(newValue) onNotificationSettingsChange() } )) .toggleStyle(.switch) .labelsHidden() } .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) .padding(.vertical, PluginSettingsTheme.Spacing.interactiveRowVertical) if store.lowBatteryNotificationEnabled { Divider() .padding(.leading, PluginSettingsTheme.Spacing.rowHorizontal) thresholdRow } } .pluginSettingsCardBackground(.plugin) } } private var thresholdRow: some View { HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { Text(localization.string("settings.notification.threshold.title", defaultValue: "通知阈值")) .font(PluginSettingsTheme.Typography.rowTitle) Text(localization.string( "settings.notification.threshold.description", defaultValue: "低于此百分比时提醒。" )) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) .lineLimit(2) } Spacer(minLength: 12) HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { TextField( "", text: Binding( get: { thresholdText }, set: { newValue in thresholdText = newValue.filter(\.isNumber) guard let threshold = Int(thresholdText) else { return } store.setLowBatteryNotificationThreshold(threshold) onNotificationSettingsChange() } ) ) .textFieldStyle(.roundedBorder) .font(PluginSettingsTheme.Typography.monospacedValue) .multilineTextAlignment(.trailing) .frame(width: 58) .onSubmit(commitThresholdText) Text("%") .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) Stepper("", value: Binding( get: { store.lowBatteryNotificationThreshold }, set: { newValue in store.setLowBatteryNotificationThreshold(newValue) onNotificationSettingsChange() } ), in: DeviceBatteryLowBatteryThresholds.minimum...DeviceBatteryLowBatteryThresholds.maximum) .labelsHidden() .controlSize(.small) } } .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) .padding(.vertical, PluginSettingsTheme.Spacing.interactiveRowVertical) } private func sourceToggle( title: String, description: String, isOn: Bool, isFirst: Bool = false, isLast: Bool = false, action: @escaping (Bool) -> Void ) -> some View { VStack(spacing: 0) { if !isFirst { Divider() .padding(.leading, PluginSettingsTheme.Spacing.rowHorizontal) } HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { Text(title) .font(PluginSettingsTheme.Typography.rowTitle) Text(description) .font(PluginSettingsTheme.Typography.rowDescription) .foregroundStyle(.secondary) .lineLimit(2) } Spacer(minLength: 12) Toggle("", isOn: Binding( get: { isOn }, set: { action($0) onChange() } )) .toggleStyle(.switch) .labelsHidden() } .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) .padding(.vertical, PluginSettingsTheme.Spacing.interactiveRowVertical) } } private func sectionHeader(systemName: String, title: String) -> some View { Label { Text(title) .font(PluginSettingsTheme.Typography.sectionTitle) } icon: { Image(systemName: systemName) } .foregroundStyle(.secondary) } private func commitThresholdText() { let threshold = Int(thresholdText) ?? store.lowBatteryNotificationThreshold store.setLowBatteryNotificationThreshold(threshold) thresholdText = "\(store.lowBatteryNotificationThreshold)" onNotificationSettingsChange() } private func iconName(for mode: DeviceBatteryLayoutMode) -> String { switch mode { case .grid: return "list.bullet.rectangle" case .list: return "gauge.with.dots.needle.67percent" } } } private struct DeviceBatteryLayoutModeButton: View { let mode: DeviceBatteryLayoutMode let isSelected: Bool let iconSystemName: String let localization: PluginLocalization let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { Image(systemName: iconSystemName) .pluginSettingsRowIconStyle(isSelected ? Color.accentColor : Color.primary) VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { Text(mode.title(localization: localization)) .font(PluginSettingsTheme.Typography.secondaryLabel.weight(.semibold)) .lineLimit(1) Text(mode.subtitle(localization: localization)) .font(PluginSettingsTheme.Typography.statusBadge) .foregroundStyle(isSelected ? Color.accentColor.opacity(0.78) : .secondary) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity) .frame(height: PluginSettingsTheme.Size.controlHeight * 2) .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) .foregroundStyle(isSelected ? Color.accentColor : Color.primary) .contentShape(Rectangle()) } .buttonStyle(.plain) .background( RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.control, style: .continuous) .fill( isSelected ? PluginSettingsTheme.Palette.activeControlBackground : PluginSettingsTheme.Palette.recessedControlBackground ) ) } }