mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 23:34:24 +01:00
280 lines
10 KiB
Swift
280 lines
10 KiB
Swift
//
|
||
// StatusWidget.swift
|
||
// StatusWidget
|
||
//
|
||
// Created by lolli on 2023/7/15.
|
||
//
|
||
|
||
import WidgetKit
|
||
import SwiftUI
|
||
import Intents
|
||
import AppIntents
|
||
import Foundation
|
||
|
||
let demoStatus = Status(name: "Server", cpu: "31.7%", mem: "1.3g / 1.9g", disk: "7.1g / 30.0g", net: "712.3k / 1.2m")
|
||
let domain = "com.lollipopkit.toolbox"
|
||
let bgColor = DynamicColor(dark: UIColor.black, light: UIColor.white)
|
||
|
||
struct Provider: IntentTimelineProvider {
|
||
func placeholder(in context: Context) -> SimpleEntry {
|
||
SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus))
|
||
}
|
||
|
||
func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
|
||
let entry = SimpleEntry(date: Date(), configuration: configuration, state: .normal(demoStatus))
|
||
completion(entry)
|
||
}
|
||
|
||
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
|
||
var url = configuration.url
|
||
|
||
let family = context.family
|
||
if #available(iOSApplicationExtension 16.0, *) {
|
||
if family == .accessoryInline || family == .accessoryRectangular {
|
||
url = UserDefaults.standard.string(forKey: accessoryKey)
|
||
}
|
||
}
|
||
|
||
let currentDate = Date()
|
||
let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
|
||
fetch(url: url) { result in
|
||
let entry: SimpleEntry = SimpleEntry(
|
||
date: currentDate,
|
||
configuration: configuration,
|
||
state: result
|
||
)
|
||
let timeline = Timeline(entries: [entry], policy: .after(refreshDate))
|
||
completion(timeline)
|
||
}
|
||
}
|
||
|
||
func fetch(url: String?, completion: @escaping (ContentState) -> Void) {
|
||
guard let url = url, url.count >= 12 else {
|
||
completion(.error(.url("url is nil OR len < 12")))
|
||
return
|
||
}
|
||
guard let url = URL(string: url) else {
|
||
completion(.error(.url("parse url failed")))
|
||
return
|
||
}
|
||
|
||
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
|
||
if error != nil {
|
||
completion(.error(.http(error?.localizedDescription ?? "unknown http err")))
|
||
return
|
||
}
|
||
guard let data = data else {
|
||
completion(.error(.http("empty http data")))
|
||
return
|
||
}
|
||
let jsonAll = try! JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] ?? [:]
|
||
let code = jsonAll["code"] as? Int ?? 1
|
||
if (code != 0) {
|
||
let msg = jsonAll["msg"] as? String ?? "Empty err"
|
||
completion(.error(.http(msg)))
|
||
return
|
||
}
|
||
|
||
let json = jsonAll["data"] as? [String: Any] ?? [:]
|
||
let name = json["name"] as? String ?? ""
|
||
let disk = json["disk"] as? String ?? ""
|
||
let cpu = json["cpu"] as? String ?? ""
|
||
let mem = json["mem"] as? String ?? ""
|
||
let net = json["net"] as? String ?? ""
|
||
completion(.normal(Status(name: name, cpu: cpu, mem: mem, disk: disk, net: net)))
|
||
}
|
||
task.resume()
|
||
}
|
||
}
|
||
|
||
struct SimpleEntry: TimelineEntry {
|
||
let date: Date
|
||
let configuration: ConfigurationIntent
|
||
let state: ContentState
|
||
}
|
||
|
||
struct StatusWidgetEntryView : View {
|
||
var entry: Provider.Entry
|
||
|
||
@Environment(\.widgetFamily) var family: WidgetFamily
|
||
|
||
var body: some View {
|
||
switch entry.state {
|
||
case .loading:
|
||
Text("Loading").widgetBackground()
|
||
case .error(let err):
|
||
switch err {
|
||
case .http(let description):
|
||
VStack(alignment: .center) {
|
||
Text(description)
|
||
if #available(iOS 17.0, *) {
|
||
Button(intent: RefreshIntent()) {
|
||
Image(systemName: "arrow.clockwise")
|
||
.resizable()
|
||
.frame(width: 10, height: 12.7)
|
||
}.tint(.gray)
|
||
}
|
||
}
|
||
.widgetBackground()
|
||
case .url(_):
|
||
Link("Open wiki ⬅️", destination: helpUrl)
|
||
.widgetBackground()
|
||
}
|
||
case .normal(let data):
|
||
let sumColor: Color = .primary.opacity(0.7)
|
||
switch family {
|
||
case .accessoryRectangular:
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack {
|
||
Text(data.name)
|
||
.font(.system(size: 15, weight: .semibold, design: .monospaced))
|
||
Spacer()
|
||
CirclePercent(percent: data.cpu)
|
||
.padding(.top, 3)
|
||
.padding(.trailing, 3)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
Spacer()
|
||
DetailItem(icon: "memorychip", text: data.mem, color: sumColor)
|
||
DetailItem(icon: "externaldrive", text: data.disk, color: sumColor)
|
||
DetailItem(icon: "network", text: data.net, color: sumColor)
|
||
}
|
||
.widgetBackground()
|
||
case .accessoryInline:
|
||
Text("\(data.name) \(data.cpu)").widgetBackground()
|
||
default:
|
||
VStack(alignment: .leading, spacing: 3.7) {
|
||
if #available(iOS 17.0, *) {
|
||
HStack {
|
||
Text(data.name).font(.system(.title3, design: .monospaced))
|
||
Spacer()
|
||
Button(intent: RefreshIntent()) {
|
||
Image(systemName: "arrow.clockwise")
|
||
.resizable()
|
||
.frame(width: 10, height: 12.7)
|
||
}.tint(.gray)
|
||
}
|
||
} else {
|
||
Text(data.name).font(.system(.title3, design: .monospaced))
|
||
}
|
||
Spacer()
|
||
DetailItem(icon: "cpu", text: data.cpu, color: sumColor)
|
||
DetailItem(icon: "memorychip", text: data.mem, color: sumColor)
|
||
DetailItem(icon: "externaldrive", text: data.disk, color: sumColor)
|
||
DetailItem(icon: "network", text: data.net, color: sumColor)
|
||
Spacer()
|
||
DetailItem(icon: "clock", text: entry.date.toStr(), color: sumColor)
|
||
.padding(.bottom, 3)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
.autoPadding()
|
||
.widgetBackground()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
extension View {
|
||
@ViewBuilder
|
||
func widgetBackground() -> some View {
|
||
// Set bg to black in Night, white in Day
|
||
let backgroundView = Color(bgColor.resolve())
|
||
if #available(iOS 17.0, *) {
|
||
containerBackground(for: .widget) {
|
||
backgroundView
|
||
}
|
||
} else {
|
||
background(backgroundView)
|
||
}
|
||
}
|
||
|
||
// iOS 17 will auto add a SafeArea, so when iOS < 17, add .padding(.all, 17)
|
||
func autoPadding() -> some View {
|
||
if #available(iOS 17.0, *) {
|
||
return self
|
||
} else {
|
||
return self.padding(.all, 17)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct StatusWidget: Widget {
|
||
let kind: String = "StatusWidget"
|
||
|
||
var body: some WidgetConfiguration {
|
||
let cfg = IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
|
||
StatusWidgetEntryView(entry: entry)
|
||
}
|
||
.configurationDisplayName("Status")
|
||
.description("Status of your servers.")
|
||
if #available(iOSApplicationExtension 16.0, *) {
|
||
return cfg.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryInline])
|
||
} else {
|
||
return cfg.supportedFamilies([.systemSmall])
|
||
}
|
||
}
|
||
}
|
||
|
||
struct StatusWidget_Previews: PreviewProvider {
|
||
static var previews: some View {
|
||
StatusWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus)))
|
||
.previewContext(WidgetPreviewContext(family: .systemSmall))
|
||
}
|
||
}
|
||
|
||
struct DetailItem: View {
|
||
let icon: String
|
||
let text: String
|
||
let color: Color
|
||
|
||
var body: some View {
|
||
HStack(spacing: 6.7) {
|
||
Image(systemName: icon).resizable().foregroundColor(color).frame(width: 11, height: 11, alignment: .center)
|
||
Text(text)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(color)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 空心圆,显示百分比
|
||
struct CirclePercent: View {
|
||
// eg: 31.7%
|
||
let percent: String
|
||
|
||
var body: some View {
|
||
// 31.7% -> 0.317
|
||
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
|
||
let double = (percentD ?? 0) / 100
|
||
Circle()
|
||
.trim(from: 0, to: CGFloat(double))
|
||
.stroke(Color.primary, lineWidth: 3)
|
||
.animation(.easeInOut(duration: 0.5))
|
||
}
|
||
}
|
||
|
||
struct DynamicColor {
|
||
let dark: UIColor
|
||
let light: UIColor
|
||
|
||
func resolve() -> UIColor {
|
||
if #available(iOS 13, *) { // 版本号大于等于13
|
||
return UIColor { (traitCollection: UITraitCollection) -> UIColor in
|
||
return traitCollection.userInterfaceStyle == UIUserInterfaceStyle.dark ?
|
||
self.dark : self.light
|
||
}
|
||
}
|
||
return self.light
|
||
}
|
||
}
|
||
|
||
@available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *)
|
||
struct RefreshIntent: AppIntent {
|
||
static var title: LocalizedStringResource = "Refresh"
|
||
static var description = IntentDescription("Refresh status.")
|
||
|
||
func perform() async throws -> some IntentResult {
|
||
return .result()
|
||
}
|
||
}
|