Files

228 lines
8.9 KiB
Swift

import Localization
import Metrics
import SMC
import SwiftUI
/// 菜单栏合并为一个图标时点击弹出的面板:顶部一排标签切换“总览”与这台 Mac 上的各项。
/// 总览把所有指标、高占用进程与常用工具集中在一页;点标签看某一项的完整详情,与每项独立时的弹窗相同
struct CombinedPopoverView: View {
@Environment(AppModel.self) private var model
var body: some View {
let items = tabItems
// 风扇与温度共用一个详情页;标签对应的项目在这台 Mac 上没有时回到总览
let requested = model.combinedPopoverTab == .fan ? .temperature : model.combinedPopoverTab
let tab = requested.flatMap { items.contains($0) ? $0 : nil }
PopoverFrame(width: DS.Size.dashboardPopoverWidth) {
VStack(spacing: DS.Space.s3) {
PopoverTabStrip(items: items, selection: tab) { model.combinedPopoverTab = $0 }
if let tab {
PopoverHeader(item: tab)
} else {
HealthHeader {
let page = PanelTab.overview
MiniIconButton(systemName: page.symbol, help: tr("在主窗口打开“\(page.title)”")) { model.openMainWindow(page) }
MiniIconButton(systemName: "gearshape", help: tr("设置")) { model.openSettings() }
}
}
}
} content: {
if let tab {
PopoverDetail(item: tab).id(tab)
} else {
DashboardPopover()
}
}
}
/// 标签里列出全部项目(与菜单栏里开了哪些无关);风扇并在“温度”里,没有电池的 Mac 不显示电池
private var tabItems: [MenuBarItem] {
MenuBarItem.allCases.filter { item in
switch item {
case .fan: false
case .battery: model.store.battery != nil
default: true
}
}
}
}
// MARK: - 标签
/// 顶部标签:总览 + 各项,苹果标准分段样式(灰色底槽、各段等宽、选中段品牌蓝),名称在悬停提示里
private struct PopoverTabStrip: View {
let items: [MenuBarItem]
let selection: MenuBarItem?
let select: (MenuBarItem?) -> Void
@Namespace private var namespace
private static var radius: CGFloat { DS.Radius.md - DS.Space.s1 / 2 }
var body: some View {
HStack(spacing: 0) {
segment(nil, symbol: overviewSymbol, title: tr("总览"))
ForEach(items) { item in
segment(item, symbol: item.symbol, title: item.popoverTitle)
}
}
.background(DS.Palette.track, in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous))
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: selection)
}
private func segment(_ item: MenuBarItem?, symbol: String, title: String) -> some View {
let selected = item == selection
return Button { select(item) } label: {
Image(systemName: symbol)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
.foregroundStyle(selected ? DS.Palette.onPrimary : DS.Palette.textSecondary)
.frame(maxWidth: .infinity)
.frame(height: DS.Size.segmentHeight + DS.Space.s1)
.background {
if selected {
RoundedRectangle(cornerRadius: Self.radius, style: .continuous)
.fill(DS.Palette.primary)
.matchedGeometryEffect(id: "selection", in: namespace)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(title)
.accessibilityLabel(title)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
/// 总览的图标;主窗口“仪表盘”已经用了方格图标,这里换成仪表盘指针以示区别
private let overviewSymbol = "gauge.with.dots.needle.50percent"
// MARK: - 总览
/// 总览:两列指标卡片(与主窗口仪表盘共用),下面是电池、高占用进程与常用工具
private struct DashboardPopover: View {
@Environment(AppModel.self) private var model
var body: some View {
VStack(spacing: DS.Space.s3) {
WeightedRow {
CPUTile()
GPUTile()
}
WeightedRow {
MemoryTile()
DiskTile()
}
WeightedRow {
NetworkTile()
FanTile()
}
if model.store.battery != nil { BatteryCard() }
TopProcessesCard()
ToolsCard()
}
.padding(.top, DS.Space.s1)
.padding(.bottom, DS.Space.s2)
// 总览用实色卡片排版,不用弹窗详情的分隔线样式
.environment(\.isPopover, false)
}
}
/// 常用工具:摄像头与麦克风占用、防休眠与清理入口,以及累计清理记录
private struct ToolsCard: View {
@Environment(AppModel.self) private var model
@State private var media = MediaUsage()
var body: some View {
let keepAwake = model.keepAwake
let tally = model.cleanupTally
Card(padding: DS.Space.s3, spacing: DS.Space.s3) {
HStack(spacing: DS.Space.s2) {
Image(systemName: media.camera ? "video.fill" : "video")
.foregroundStyle(media.camera ? DS.Palette.success : DS.Palette.textSecondary)
.help(media.camera ? tr("摄像头使用中") : tr("摄像头未在使用"))
Image(systemName: media.microphone ? "mic.fill" : "mic")
.foregroundStyle(media.microphone ? DS.Palette.warning : DS.Palette.textSecondary)
.help(media.microphone ? tr("麦克风使用中") : tr("麦克风未在使用"))
Text(mediaText)
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
Spacer(minLength: DS.Space.s2)
ToolButton(icon: "cup.and.saucer", title: tr("防休眠"), isOn: keepAwake.isActive) {
Task { await keepAwake.setActive(!keepAwake.isActive) }
}
ToolButton(icon: "sparkles", title: tr("清理"), isOn: false) {
model.openMainWindow(.cleaner)
}
}
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
HairlineDivider()
HStack(spacing: DS.Space.s1) {
Image(systemName: "clock.arrow.circlepath").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
Text(tr("清理记录")).dsFont(.xs, weight: .semibold)
}
.foregroundStyle(DS.Palette.textSecondary)
HStack(alignment: .top, spacing: DS.Space.s3) {
stat(Format.bytes(tally.freedBytes, base: .decimal), label: tr("已清理"))
stat("\(tally.uninstalledApps)", label: tr("已卸载"))
stat("\(tally.optimizations)", label: tr("已优化"))
}
}
.task {
// 读设备状态很快,面板打开期间每 2 秒刷新一次
while !Task.isCancelled {
media = MediaUsageSampler.sample()
try? await Task.sleep(for: .seconds(2))
}
}
}
private var mediaText: String {
switch (media.camera, media.microphone) {
case (true, true): tr("摄像头、麦克风使用中")
case (true, false): tr("摄像头使用中")
case (false, true): tr("麦克风使用中")
case (false, false): tr("未在使用")
}
}
private func stat(_ value: String, label: String) -> some View {
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(verbatim: value)
.dsFont(.lg, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.8)
Text(label).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// 工具卡片里的文字按钮:图标加名称,开启时用品牌色
private struct ToolButton: View {
let icon: String
let title: String
let isOn: Bool
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
Label(title, systemImage: icon)
.dsFont(.xs, weight: .semibold)
.foregroundStyle(isOn || hovering ? DS.Palette.primary : DS.Palette.textSecondary)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.accessibilityAddTraits(isOn ? .isSelected : [])
}
}