mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 07:14:28 +01:00
opt.: watchOS & iOS widget (#847)
This commit is contained in:
@@ -15,6 +15,142 @@ let demoStatus = Status(name: "Server", cpu: "31.7%", mem: "1.3g / 1.9g", disk:
|
|||||||
let domain = "com.lollipopkit.toolbox"
|
let domain = "com.lollipopkit.toolbox"
|
||||||
let bgColor = DynamicColor(dark: UIColor.black, light: UIColor.white)
|
let bgColor = DynamicColor(dark: UIColor.black, light: UIColor.white)
|
||||||
|
|
||||||
|
// Widget-specific constants
|
||||||
|
enum WidgetConstants {
|
||||||
|
enum Dimensions {
|
||||||
|
static let smallGauge: CGFloat = 56
|
||||||
|
static let mediumGauge: CGFloat = 64
|
||||||
|
static let largeGauge: CGFloat = 76
|
||||||
|
static let refreshIconSmall: CGFloat = 12
|
||||||
|
static let refreshIconLarge: CGFloat = 14
|
||||||
|
static let cornerRadius: CGFloat = 12
|
||||||
|
static let shadowRadius: CGFloat = 2
|
||||||
|
}
|
||||||
|
enum Thresholds {
|
||||||
|
static let warningThreshold: Double = 0.6
|
||||||
|
static let criticalThreshold: Double = 0.85
|
||||||
|
}
|
||||||
|
enum Spacing {
|
||||||
|
static let tight: CGFloat = 4
|
||||||
|
static let normal: CGFloat = 8
|
||||||
|
static let loose: CGFloat = 12
|
||||||
|
static let extraLoose: CGFloat = 16
|
||||||
|
}
|
||||||
|
enum Colors {
|
||||||
|
static let cardBackground = Color(.systemBackground)
|
||||||
|
static let secondaryText = Color(.secondaryLabel)
|
||||||
|
static let success = Color(.systemGreen)
|
||||||
|
static let warning = Color(.systemOrange)
|
||||||
|
static let critical = Color(.systemRed)
|
||||||
|
static let accent = Color(.systemBlue)
|
||||||
|
}
|
||||||
|
static let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance optimization: cache parsed values
|
||||||
|
struct ParseCache {
|
||||||
|
private static var percentCache: [String: Double] = [:]
|
||||||
|
private static var usagePercentCache: [String: Double] = [:]
|
||||||
|
|
||||||
|
static func parsePercent(_ text: String) -> Double {
|
||||||
|
if let cached = percentCache[text] { return cached }
|
||||||
|
let trimmed = text.trimmingCharacters(in: CharacterSet(charactersIn: "% "))
|
||||||
|
let result = Double(trimmed).map { max(0, min(1, $0 / 100.0)) } ?? 0
|
||||||
|
percentCache[text] = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseUsagePercent(_ text: String) -> Double {
|
||||||
|
if let cached = usagePercentCache[text] { return cached }
|
||||||
|
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count == 2 else { return 0 }
|
||||||
|
let used = PerformanceUtils.parseSizeToBytes(parts[0])
|
||||||
|
let total = PerformanceUtils.parseSizeToBytes(parts[1])
|
||||||
|
let result = total <= 0 ? 0 : max(0, min(1, used / total))
|
||||||
|
usagePercentCache[text] = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseNetworkTotal(_ text: String) -> (totalBytes: Double, displayText: String) {
|
||||||
|
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count == 2 else { return (0, "0 B") }
|
||||||
|
let upload = PerformanceUtils.parseSizeToBytes(parts[0])
|
||||||
|
let download = PerformanceUtils.parseSizeToBytes(parts[1])
|
||||||
|
let total = upload + download
|
||||||
|
let displayText = PerformanceUtils.formatSize(total)
|
||||||
|
return (total, displayText)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseNetworkPercent(_ text: String) -> Double {
|
||||||
|
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count == 2 else { return 0 }
|
||||||
|
let upload = PerformanceUtils.parseSizeToBytes(parts[0])
|
||||||
|
let download = PerformanceUtils.parseSizeToBytes(parts[1])
|
||||||
|
let total = upload + download
|
||||||
|
// Return upload percentage of total traffic
|
||||||
|
return total <= 0 ? 0 : max(0, min(1, upload / total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PerformanceUtils {
|
||||||
|
// Precomputed multipliers for performance
|
||||||
|
private static let sizeMultipliers: [Character: Double] = [
|
||||||
|
"k": 1024,
|
||||||
|
"m": pow(1024, 2),
|
||||||
|
"g": pow(1024, 3),
|
||||||
|
"t": pow(1024, 4),
|
||||||
|
"p": pow(1024, 5)
|
||||||
|
]
|
||||||
|
|
||||||
|
static func parseSizeToBytes(_ text: String) -> Double {
|
||||||
|
let lower = text.lowercased().replacingOccurrences(of: "b", with: "")
|
||||||
|
let unitChar = lower.trimmingCharacters(in: .whitespaces).last
|
||||||
|
let numberPart: String
|
||||||
|
let multiplier: Double
|
||||||
|
|
||||||
|
if let u = unitChar, let mult = sizeMultipliers[u] {
|
||||||
|
multiplier = mult
|
||||||
|
numberPart = String(lower.dropLast())
|
||||||
|
} else {
|
||||||
|
multiplier = 1.0
|
||||||
|
numberPart = lower
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = Double(numberPart.trimmingCharacters(in: .whitespaces)) ?? 0
|
||||||
|
return value * multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
static func percentStr(_ value: Double) -> String {
|
||||||
|
let pct = max(0, min(1, value)) * 100
|
||||||
|
let rounded = (pct * 10).rounded() / 10
|
||||||
|
return rounded.truncatingRemainder(dividingBy: 1) == 0
|
||||||
|
? String(format: "%.0f%%", rounded)
|
||||||
|
: String(format: "%.1f%%", rounded)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func thresholdColor(_ value: Double) -> Color {
|
||||||
|
let v = max(0, min(1, value))
|
||||||
|
switch v {
|
||||||
|
case ..<WidgetConstants.Thresholds.warningThreshold: return WidgetConstants.Colors.success
|
||||||
|
case ..<WidgetConstants.Thresholds.criticalThreshold: return WidgetConstants.Colors.warning
|
||||||
|
default: return WidgetConstants.Colors.critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func formatSize(_ bytes: Double) -> String {
|
||||||
|
let units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
var size = bytes
|
||||||
|
var unitIndex = 0
|
||||||
|
|
||||||
|
while size >= 1024 && unitIndex < units.count - 1 {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(format: "%.1f %@", size, units[unitIndex])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Provider: IntentTimelineProvider {
|
struct Provider: IntentTimelineProvider {
|
||||||
func placeholder(in context: Context) -> SimpleEntry {
|
func placeholder(in context: Context) -> SimpleEntry {
|
||||||
SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus))
|
SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus))
|
||||||
@@ -29,11 +165,13 @@ struct Provider: IntentTimelineProvider {
|
|||||||
var url = configuration.url
|
var url = configuration.url
|
||||||
|
|
||||||
let family = context.family
|
let family = context.family
|
||||||
|
#if os(iOS)
|
||||||
if #available(iOSApplicationExtension 16.0, *) {
|
if #available(iOSApplicationExtension 16.0, *) {
|
||||||
if family == .accessoryInline || family == .accessoryRectangular {
|
if family == .accessoryInline || family == .accessoryRectangular {
|
||||||
url = UserDefaults.standard.string(forKey: accessoryKey)
|
url = UserDefaults(suiteName: WidgetConstants.appGroupId)?.string(forKey: "accessory_widget_url")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
let currentDate = Date()
|
let currentDate = Date()
|
||||||
let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
|
let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
|
||||||
@@ -111,7 +249,7 @@ struct StatusWidgetEntryView : View {
|
|||||||
Button(intent: RefreshIntent()) {
|
Button(intent: RefreshIntent()) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 10, height: 12.7)
|
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
||||||
}.tint(.gray)
|
}.tint(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +261,37 @@ struct StatusWidgetEntryView : View {
|
|||||||
case .normal(let data):
|
case .normal(let data):
|
||||||
let sumColor: Color = .primary.opacity(0.7)
|
let sumColor: Color = .primary.opacity(0.7)
|
||||||
switch family {
|
switch family {
|
||||||
|
case .systemMedium:
|
||||||
|
VStack(alignment: .leading, spacing: WidgetConstants.Spacing.normal) {
|
||||||
|
// Title + refresh
|
||||||
|
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: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
||||||
|
}.tint(.gray)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(data.name).font(.system(.title3, design: .monospaced))
|
||||||
|
}
|
||||||
|
Spacer(minLength: WidgetConstants.Spacing.normal)
|
||||||
|
// Gauges row
|
||||||
|
HStack(spacing: WidgetConstants.Spacing.tight) {
|
||||||
|
GaugeTile(label: "CPU", value: ParseCache.parsePercent(data.cpu), display: data.cpu, diameter: WidgetConstants.Dimensions.smallGauge)
|
||||||
|
GaugeTile(label: "MEM", value: ParseCache.parseUsagePercent(data.mem), display: PerformanceUtils.percentStr(ParseCache.parseUsagePercent(data.mem)), diameter: WidgetConstants.Dimensions.smallGauge)
|
||||||
|
GaugeTile(label: "DISK", value: ParseCache.parseUsagePercent(data.disk), display: PerformanceUtils.percentStr(ParseCache.parseUsagePercent(data.disk)), diameter: WidgetConstants.Dimensions.smallGauge)
|
||||||
|
GaugeTile(label: "NET", value: ParseCache.parseNetworkPercent(data.net), display: ParseCache.parseNetworkTotal(data.net).displayText, diameter: WidgetConstants.Dimensions.smallGauge)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.bottom, 3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.autoPadding()
|
||||||
|
.widgetBackground()
|
||||||
|
#if os(iOS)
|
||||||
case .accessoryRectangular:
|
case .accessoryRectangular:
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -142,6 +311,7 @@ struct StatusWidgetEntryView : View {
|
|||||||
.widgetBackground()
|
.widgetBackground()
|
||||||
case .accessoryInline:
|
case .accessoryInline:
|
||||||
Text("\(data.name) \(data.cpu)").widgetBackground()
|
Text("\(data.name) \(data.cpu)").widgetBackground()
|
||||||
|
#endif
|
||||||
default:
|
default:
|
||||||
VStack(alignment: .leading, spacing: 3.7) {
|
VStack(alignment: .leading, spacing: 3.7) {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
@@ -151,7 +321,7 @@ struct StatusWidgetEntryView : View {
|
|||||||
Button(intent: RefreshIntent()) {
|
Button(intent: RefreshIntent()) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 10, height: 12.7)
|
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
|
||||||
}.tint(.gray)
|
}.tint(.gray)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -162,9 +332,6 @@ struct StatusWidgetEntryView : View {
|
|||||||
DetailItem(icon: "memorychip", text: data.mem, color: sumColor)
|
DetailItem(icon: "memorychip", text: data.mem, color: sumColor)
|
||||||
DetailItem(icon: "externaldrive", text: data.disk, color: sumColor)
|
DetailItem(icon: "externaldrive", text: data.disk, color: sumColor)
|
||||||
DetailItem(icon: "network", text: data.net, 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)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.autoPadding()
|
.autoPadding()
|
||||||
@@ -177,8 +344,16 @@ struct StatusWidgetEntryView : View {
|
|||||||
extension View {
|
extension View {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func widgetBackground() -> some View {
|
func widgetBackground() -> some View {
|
||||||
// Set bg to black in Night, white in Day
|
// Modern card-style background with subtle effects
|
||||||
let backgroundView = Color(bgColor.resolve())
|
let backgroundView = LinearGradient(
|
||||||
|
gradient: Gradient(colors: [
|
||||||
|
Color(bgColor.resolve()),
|
||||||
|
Color(bgColor.resolve()).opacity(0.95)
|
||||||
|
]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
containerBackground(for: .widget) {
|
containerBackground(for: .widget) {
|
||||||
backgroundView
|
backgroundView
|
||||||
@@ -188,14 +363,29 @@ extension View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS 17 will auto add a SafeArea, so when iOS < 17, add .padding(.all, 17)
|
// Enhanced padding with improved spacing
|
||||||
func autoPadding() -> some View {
|
func autoPadding() -> some View {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
return self
|
return self.padding(.all, WidgetConstants.Spacing.tight)
|
||||||
} else {
|
} else {
|
||||||
return self.padding(.all, 17)
|
return self.padding(.all, WidgetConstants.Spacing.extraLoose + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modern card container with shadow and rounded corners
|
||||||
|
func modernCard(cornerRadius: CGFloat = WidgetConstants.Dimensions.cornerRadius) -> some View {
|
||||||
|
self
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.fill(WidgetConstants.Colors.cardBackground)
|
||||||
|
.shadow(
|
||||||
|
color: .black.opacity(0.08),
|
||||||
|
radius: WidgetConstants.Dimensions.shadowRadius,
|
||||||
|
x: 0,
|
||||||
|
y: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatusWidget: Widget {
|
struct StatusWidget: Widget {
|
||||||
@@ -207,11 +397,15 @@ struct StatusWidget: Widget {
|
|||||||
}
|
}
|
||||||
.configurationDisplayName("Status")
|
.configurationDisplayName("Status")
|
||||||
.description("Status of your servers.")
|
.description("Status of your servers.")
|
||||||
|
#if os(iOS)
|
||||||
if #available(iOSApplicationExtension 16.0, *) {
|
if #available(iOSApplicationExtension 16.0, *) {
|
||||||
return cfg.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryInline])
|
return cfg.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
|
||||||
} else {
|
} else {
|
||||||
return cfg.supportedFamilies([.systemSmall])
|
return cfg.supportedFamilies([.systemSmall, .systemMedium])
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
return cfg.supportedFamilies([.systemSmall, .systemMedium])
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,29 +422,174 @@ struct DetailItem: View {
|
|||||||
let color: Color
|
let color: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 6.7) {
|
HStack(spacing: WidgetConstants.Spacing.normal) {
|
||||||
Image(systemName: icon).resizable().foregroundColor(color).frame(width: 11, height: 11, alignment: .center)
|
Image(systemName: icon)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.foregroundColor(color.opacity(0.8))
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(color.opacity(0.1))
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
)
|
||||||
|
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, WidgetConstants.Spacing.tight)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced circular progress indicator
|
||||||
|
struct CirclePercent: View {
|
||||||
|
// eg: 31.7%
|
||||||
|
let percent: String
|
||||||
|
@State private var animatedProgress: Double = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
|
||||||
|
let progress = (percentD ?? 0) / 100
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
// Background circle
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.primary.opacity(0.15), lineWidth: 2.5)
|
||||||
|
|
||||||
|
// Progress circle with gradient
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(max(0, min(1, animatedProgress))))
|
||||||
|
.stroke(
|
||||||
|
AngularGradient(
|
||||||
|
gradient: Gradient(colors: [
|
||||||
|
PerformanceUtils.thresholdColor(progress).opacity(0.7),
|
||||||
|
PerformanceUtils.thresholdColor(progress)
|
||||||
|
]),
|
||||||
|
center: .center
|
||||||
|
),
|
||||||
|
style: StrokeStyle(lineWidth: 3, lineCap: .round)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
|
||||||
|
// Percentage text
|
||||||
|
Text(percent)
|
||||||
|
.font(.system(size: 8, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.primary.opacity(0.8))
|
||||||
|
}
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 0.8).delay(0.2)) {
|
||||||
|
animatedProgress = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: progress) { newProgress in
|
||||||
|
withAnimation(.easeInOut(duration: 0.5)) {
|
||||||
|
animatedProgress = newProgress
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空心圆,显示百分比
|
// Modern gauge tile with enhanced visual design
|
||||||
struct CirclePercent: View {
|
struct GaugeTile: View {
|
||||||
// eg: 31.7%
|
let label: String
|
||||||
let percent: String
|
// 0..1
|
||||||
|
let value: Double
|
||||||
|
// eg: "31.7%"
|
||||||
|
let display: String
|
||||||
|
let diameter: CGFloat
|
||||||
|
|
||||||
|
@State private var animatedValue: Double = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// 31.7% -> 0.317
|
VStack(spacing: WidgetConstants.Spacing.normal) {
|
||||||
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
|
ZStack {
|
||||||
let double = (percentD ?? 0) / 100
|
// Background circle with subtle shadow effect
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: CGFloat(double))
|
.stroke(Color.primary.opacity(0.1), lineWidth: 4)
|
||||||
.stroke(Color.primary, lineWidth: 3)
|
.background(
|
||||||
.animation(.easeInOut(duration: 0.5))
|
Circle()
|
||||||
|
.fill(WidgetConstants.Colors.cardBackground)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: WidgetConstants.Dimensions.shadowRadius, x: 0, y: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress arc with gradient effect
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(max(0, min(1, animatedValue))))
|
||||||
|
.stroke(
|
||||||
|
AngularGradient(
|
||||||
|
gradient: Gradient(colors: [
|
||||||
|
PerformanceUtils.thresholdColor(value).opacity(0.8),
|
||||||
|
PerformanceUtils.thresholdColor(value)
|
||||||
|
]),
|
||||||
|
center: .center,
|
||||||
|
startAngle: .degrees(-90),
|
||||||
|
endAngle: .degrees(270)
|
||||||
|
),
|
||||||
|
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
|
||||||
|
// Center value text with improved typography
|
||||||
|
Text(display)
|
||||||
|
.font(.system(size: diameter < 60 ? 11 : 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 0.8).delay(0.1)) {
|
||||||
|
animatedValue = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: value) { newValue in
|
||||||
|
withAnimation(.easeInOut(duration: 0.6)) {
|
||||||
|
animatedValue = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label with enhanced styling
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(WidgetConstants.Colors.secondaryText)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.tracking(0.5)
|
||||||
|
} else {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(WidgetConstants.Colors.secondaryText)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy functions maintained for compatibility - now delegate to optimized versions
|
||||||
|
func parsePercent(_ text: String) -> Double {
|
||||||
|
return ParseCache.parsePercent(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUsagePercent(_ text: String) -> Double {
|
||||||
|
return ParseCache.parseUsagePercent(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSizeToBytes(_ text: String) -> Double {
|
||||||
|
return PerformanceUtils.parseSizeToBytes(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentStr(_ value: Double) -> String {
|
||||||
|
return PerformanceUtils.percentStr(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func thresholdColor(_ value: Double) -> Color {
|
||||||
|
return PerformanceUtils.thresholdColor(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DynamicColor {
|
struct DynamicColor {
|
||||||
|
|||||||
@@ -9,22 +9,62 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@ObservedObject var _mgr = PhoneConnMgr()
|
@ObservedObject var _mgr = PhoneConnMgr()
|
||||||
|
@State private var selection: Int = 0
|
||||||
|
@State private var refreshAllCounter: Int = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let _count = _mgr.urls.count == 0 ? 1 : _mgr.urls.count
|
let hasServers = !_mgr.urls.isEmpty
|
||||||
TabView {
|
let pagesCount = hasServers ? _mgr.urls.count : 1
|
||||||
ForEach(0 ..< _count, id:\.self) { index in
|
TabView(selection: $selection) {
|
||||||
let url = _count == 1 && _mgr.urls.count == 0 ? nil : _mgr.urls[index]
|
ForEach(0 ..< pagesCount, id:\.self) { index in
|
||||||
PageView(url: url, state: .loading)
|
let url = hasServers ? _mgr.urls[index] : nil
|
||||||
|
PageView(
|
||||||
|
url: url,
|
||||||
|
state: .loading,
|
||||||
|
refreshAllCounter: refreshAllCounter,
|
||||||
|
onRefreshAll: { refreshAllCounter += 1 }
|
||||||
|
)
|
||||||
|
.tag(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(PageTabViewStyle())
|
.tabViewStyle(PageTabViewStyle())
|
||||||
|
// 当 URL 列表变化时,尽量保持当前选中的页面不变
|
||||||
|
.onChange(of: _mgr.urls) { newValue in
|
||||||
|
let newCount = newValue.count
|
||||||
|
// 当没有服务器时,只有占位页
|
||||||
|
if newCount == 0 {
|
||||||
|
selection = 0
|
||||||
|
} else if selection >= newCount {
|
||||||
|
// 如果当前选择超出范围,则跳到最后一个有效页
|
||||||
|
selection = max(0, newCount - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 持久化当前选择,供 Widget 使用
|
||||||
|
.onChange(of: selection) { newIndex in
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
if let defaults = UserDefaults(suiteName: appGroupId) {
|
||||||
|
defaults.set(newIndex, forKey: "watch_shared_selected_index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// 尽量恢复上一次的选择
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
let saved = UserDefaults(suiteName: appGroupId)?.integer(forKey: "watch_shared_selected_index") ?? 0
|
||||||
|
if !_mgr.urls.isEmpty {
|
||||||
|
selection = min(max(0, saved), _mgr.urls.count - 1)
|
||||||
|
} else {
|
||||||
|
selection = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PageView: View {
|
struct PageView: View {
|
||||||
let url: String?
|
let url: String?
|
||||||
@State var state: ContentState
|
@State var state: ContentState
|
||||||
|
// 触发所有页面刷新的计数器
|
||||||
|
let refreshAllCounter: Int
|
||||||
|
let onRefreshAll: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if url == nil {
|
if url == nil {
|
||||||
@@ -36,6 +76,7 @@ struct PageView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
Group {
|
||||||
switch state {
|
switch state {
|
||||||
case .loading:
|
case .loading:
|
||||||
ProgressView().padding().onAppear {
|
ProgressView().padding().onAppear {
|
||||||
@@ -46,11 +87,18 @@ struct PageView: View {
|
|||||||
case .http(let description):
|
case .http(let description):
|
||||||
VStack(alignment: .center) {
|
VStack(alignment: .center) {
|
||||||
Text(description)
|
Text(description)
|
||||||
|
HStack(spacing: 10) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
state = .loading
|
state = .loading
|
||||||
}){
|
}){
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
}.buttonStyle(.plain)
|
}.buttonStyle(.plain)
|
||||||
|
Button(action: {
|
||||||
|
onRefreshAll()
|
||||||
|
}){
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case .url(_):
|
case .url(_):
|
||||||
Link("View help", destination: helpUrl)
|
Link("View help", destination: helpUrl)
|
||||||
@@ -60,11 +108,18 @@ struct PageView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text(status.name).font(.system(.title, design: .monospaced))
|
Text(status.name).font(.system(.title, design: .monospaced))
|
||||||
Spacer()
|
Spacer()
|
||||||
|
HStack(spacing: 10) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
state = .loading
|
state = .loading
|
||||||
}){
|
}){
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
}.buttonStyle(.plain)
|
}.buttonStyle(.plain)
|
||||||
|
Button(action: {
|
||||||
|
onRefreshAll()
|
||||||
|
}){
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
DetailItem(icon: "cpu", text: status.cpu)
|
DetailItem(icon: "cpu", text: status.cpu)
|
||||||
@@ -74,6 +129,12 @@ struct PageView: View {
|
|||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding([.horizontal], 11)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding([.horizontal], 11)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: refreshAllCounter) { _ in
|
||||||
|
if let url = url {
|
||||||
|
getStatus(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStatus(url: String) {
|
func getStatus(url: String) {
|
||||||
@@ -87,25 +148,32 @@ struct PageView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
|
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
|
||||||
guard error == nil else {
|
// 所有 UI 状态更新必须在主线程执行,否则可能导致 TabView 跳回第一页等问题
|
||||||
state = .error(.http(error!.localizedDescription))
|
func setStateOnMain(_ newState: ContentState) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.state = newState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
setStateOnMain(.error(.http(error.localizedDescription)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
state = .error(.http("empty data"))
|
setStateOnMain(.error(.http("empty data")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let jsonAll = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
guard let jsonAll = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
||||||
state = .error(.http("json parse fail"))
|
setStateOnMain(.error(.http("json parse fail")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let code = jsonAll["code"] as? Int else {
|
guard let code = jsonAll["code"] as? Int else {
|
||||||
state = .error(.http("code is nil"))
|
setStateOnMain(.error(.http("code is nil")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (code != 0) {
|
if (code != 0) {
|
||||||
let msg = jsonAll["msg"] as? String ?? ""
|
let msg = jsonAll["msg"] as? String ?? ""
|
||||||
state = .error(.http(msg))
|
setStateOnMain(.error(.http(msg)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,10 +183,35 @@ struct PageView: View {
|
|||||||
let cpu = json["cpu"] as? String ?? ""
|
let cpu = json["cpu"] as? String ?? ""
|
||||||
let mem = json["mem"] as? String ?? ""
|
let mem = json["mem"] as? String ?? ""
|
||||||
let net = json["net"] as? String ?? ""
|
let net = json["net"] as? String ?? ""
|
||||||
state = .normal(Status(name: name, cpu: cpu, mem: mem, disk: disk, net: net))
|
let status = Status(name: name, cpu: cpu, mem: mem, disk: disk, net: net)
|
||||||
|
setStateOnMain(.normal(status))
|
||||||
|
// 将最新数据写入 App Group,供表盘/叠放的 Widget 使用
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
if let defaults = UserDefaults(suiteName: appGroupId) {
|
||||||
|
var statusMap = (defaults.dictionary(forKey: "watch_shared_status_by_url") as? [String: [String: String]]) ?? [:]
|
||||||
|
statusMap[url.absoluteString] = [
|
||||||
|
"name": status.name,
|
||||||
|
"cpu": status.cpu,
|
||||||
|
"mem": status.mem,
|
||||||
|
"disk": status.disk,
|
||||||
|
"net": status.net
|
||||||
|
]
|
||||||
|
defaults.set(statusMap, forKey: "watch_shared_status_by_url")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听“刷新全部”触发器变化,主动刷新当前页
|
||||||
|
@ViewBuilder
|
||||||
|
var _onRefreshAllHook: some View {
|
||||||
|
EmptyView()
|
||||||
|
.onChange(of: refreshAllCounter) { _ in
|
||||||
|
if let url = url {
|
||||||
|
getStatus(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -44,7 +44,13 @@ class PhoneConnMgr: NSObject, WCSessionDelegate, ObservableObject {
|
|||||||
func updateUrls(_ val: [String: Any]) {
|
func updateUrls(_ val: [String: Any]) {
|
||||||
if let urls = val["urls"] as? [String] {
|
if let urls = val["urls"] as? [String] {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.urls = urls.filter { !$0.isEmpty }
|
let list = urls.filter { !$0.isEmpty }
|
||||||
|
self.urls = list
|
||||||
|
// Save URLs to App Group for widget access
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
if let defaults = UserDefaults(suiteName: appGroupId) {
|
||||||
|
defaults.set(list, forKey: "watch_shared_urls")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
ios/WatchWidget/WatchStatusWidget.swift
Normal file
141
ios/WatchWidget/WatchStatusWidget.swift
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
//
|
||||||
|
// WatchStatusWidget.swift
|
||||||
|
// WatchStatusWidget Extension
|
||||||
|
//
|
||||||
|
// Created by AI Assistant
|
||||||
|
//
|
||||||
|
|
||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Simple model, independent from Runner target
|
||||||
|
struct Status: Hashable {
|
||||||
|
let name: String
|
||||||
|
let cpu: String
|
||||||
|
let mem: String
|
||||||
|
let disk: String
|
||||||
|
let net: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchProvider: TimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> WatchEntry {
|
||||||
|
WatchEntry(date: Date(), status: Status(name: "Server", cpu: "32%", mem: "1.3g/1.9g", disk: "7.1g/30g", net: "712k/1.2m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) {
|
||||||
|
completion(loadEntry())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchEntry>) -> Void) {
|
||||||
|
let entry = loadEntry()
|
||||||
|
let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date().addingTimeInterval(900)
|
||||||
|
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadEntry() -> WatchEntry {
|
||||||
|
let appGroupId = "group.com.lollipopkit.toolbox"
|
||||||
|
guard let defaults = UserDefaults(suiteName: appGroupId) else {
|
||||||
|
return WatchEntry(date: Date(), status: Status(name: "Server", cpu: "--%", mem: "-", disk: "-", net: "-"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let urls = (defaults.array(forKey: "watch_shared_urls") as? [String]) ?? []
|
||||||
|
let idx = defaults.integer(forKey: "watch_shared_selected_index")
|
||||||
|
var status: Status? = nil
|
||||||
|
|
||||||
|
if !urls.isEmpty {
|
||||||
|
let i = min(max(0, idx), urls.count - 1)
|
||||||
|
let url = urls[i]
|
||||||
|
|
||||||
|
// Load status from shared defaults
|
||||||
|
if let statusMap = defaults.dictionary(forKey: "watch_shared_status_by_url") as? [String: [String: String]],
|
||||||
|
let statusDict = statusMap[url] {
|
||||||
|
status = Status(
|
||||||
|
name: statusDict["name"] ?? "",
|
||||||
|
cpu: statusDict["cpu"] ?? "",
|
||||||
|
mem: statusDict["mem"] ?? "",
|
||||||
|
disk: statusDict["disk"] ?? "",
|
||||||
|
net: statusDict["net"] ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return WatchEntry(
|
||||||
|
date: Date(),
|
||||||
|
status: status ?? Status(name: "Server", cpu: "--%", mem: "-", disk: "-", net: "-")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let status: Status
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchStatusWidgetEntryView: View {
|
||||||
|
var entry: WatchProvider.Entry
|
||||||
|
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .accessoryCircular:
|
||||||
|
ZStack {
|
||||||
|
Circle().stroke(Color.primary.opacity(0.15), lineWidth: 4)
|
||||||
|
CirclePercent(percent: entry.status.cpu)
|
||||||
|
Text(entry.status.cpu.replacingOccurrences(of: "%", with: "")).font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||||
|
}
|
||||||
|
.padding(2)
|
||||||
|
case .accessoryRectangular:
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack {
|
||||||
|
Text(entry.status.name).font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Label(entry.status.cpu, systemImage: "cpu").font(.system(size: 11, design: .monospaced))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .accessoryInline:
|
||||||
|
Text("\(entry.status.name) \(entry.status.cpu)")
|
||||||
|
default:
|
||||||
|
VStack {
|
||||||
|
Text(entry.status.name)
|
||||||
|
Text(entry.status.cpu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchStatusWidget: Widget {
|
||||||
|
let kind: String = "WatchStatusWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in
|
||||||
|
WatchStatusWidgetEntryView(entry: entry)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("Server Status")
|
||||||
|
.description("Shows the selected server status.")
|
||||||
|
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchStatusWidget_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
WatchStatusWidgetEntryView(entry: WatchEntry(date: Date(), status: Status(name: "Server", cpu: "37%", mem: "1.3g/1.9g", disk: "7.1g/30g", net: "712k/1.2m")))
|
||||||
|
.previewContext(WidgetPreviewContext(family: .accessoryRectangular))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers reused from iOS widget with lightweight versions
|
||||||
|
struct CirclePercent: View {
|
||||||
|
let percent: String
|
||||||
|
var body: some View {
|
||||||
|
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "% "))) ?? 0
|
||||||
|
let p = max(0, min(100, percentD)) / 100.0
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(p))
|
||||||
|
.stroke(Color.primary, style: StrokeStyle(lineWidth: 4, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
17
ios/WatchWidget/WatchStatusWidgetBundle.swift
Normal file
17
ios/WatchWidget/WatchStatusWidgetBundle.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// WatchStatusWidgetBundle.swift
|
||||||
|
// WatchStatusWidget Extension
|
||||||
|
//
|
||||||
|
// Created by AI Assistant
|
||||||
|
//
|
||||||
|
|
||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct WatchStatusWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
WatchStatusWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user