From 13e28675af516b0f57701ba5bd3e81f99953ec18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 13 Aug 2025 01:44:02 +0800 Subject: [PATCH] opt.: watchOS & iOS widget (#847) --- ios/StatusWidget/StatusWidget.swift | 395 ++++++++++++++++-- ios/WatchApp/ContentView.swift | 153 +++++-- ios/WatchApp/PhoneConnMgr.swift | 8 +- ios/WatchWidget/WatchStatusWidget.swift | 141 +++++++ ios/WatchWidget/WatchStatusWidgetBundle.swift | 17 + 5 files changed, 655 insertions(+), 59 deletions(-) create mode 100644 ios/WatchWidget/WatchStatusWidget.swift create mode 100644 ios/WatchWidget/WatchStatusWidgetBundle.swift diff --git a/ios/StatusWidget/StatusWidget.swift b/ios/StatusWidget/StatusWidget.swift index 262f67a1..6031a18f 100644 --- a/ios/StatusWidget/StatusWidget.swift +++ b/ios/StatusWidget/StatusWidget.swift @@ -15,6 +15,142 @@ let demoStatus = Status(name: "Server", cpu: "31.7%", mem: "1.3g / 1.9g", disk: let domain = "com.lollipopkit.toolbox" 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 .. 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 { func placeholder(in context: Context) -> SimpleEntry { SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus)) @@ -29,11 +165,13 @@ struct Provider: IntentTimelineProvider { var url = configuration.url let family = context.family + #if os(iOS) if #available(iOSApplicationExtension 16.0, *) { 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 refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)! @@ -111,7 +249,7 @@ struct StatusWidgetEntryView : View { Button(intent: RefreshIntent()) { Image(systemName: "arrow.clockwise") .resizable() - .frame(width: 10, height: 12.7) + .frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27) }.tint(.gray) } } @@ -123,6 +261,37 @@ struct StatusWidgetEntryView : View { case .normal(let data): let sumColor: Color = .primary.opacity(0.7) 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: VStack(alignment: .leading, spacing: 2) { HStack { @@ -142,6 +311,7 @@ struct StatusWidgetEntryView : View { .widgetBackground() case .accessoryInline: Text("\(data.name) \(data.cpu)").widgetBackground() + #endif default: VStack(alignment: .leading, spacing: 3.7) { if #available(iOS 17.0, *) { @@ -151,7 +321,7 @@ struct StatusWidgetEntryView : View { Button(intent: RefreshIntent()) { Image(systemName: "arrow.clockwise") .resizable() - .frame(width: 10, height: 12.7) + .frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27) }.tint(.gray) } } else { @@ -162,9 +332,6 @@ struct StatusWidgetEntryView : View { 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() @@ -177,8 +344,16 @@ struct StatusWidgetEntryView : View { extension View { @ViewBuilder func widgetBackground() -> some View { - // Set bg to black in Night, white in Day - let backgroundView = Color(bgColor.resolve()) + // Modern card-style background with subtle effects + let backgroundView = LinearGradient( + gradient: Gradient(colors: [ + Color(bgColor.resolve()), + Color(bgColor.resolve()).opacity(0.95) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + if #available(iOS 17.0, *) { containerBackground(for: .widget) { 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 { if #available(iOS 17.0, *) { - return self + return self.padding(.all, WidgetConstants.Spacing.tight) } 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 { @@ -207,11 +397,15 @@ struct StatusWidget: Widget { } .configurationDisplayName("Status") .description("Status of your servers.") - if #available(iOSApplicationExtension 16.0, *) { - return cfg.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryInline]) + #if os(iOS) + if #available(iOSApplicationExtension 16.0, *) { + return cfg.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline]) } else { - return cfg.supportedFamilies([.systemSmall]) + return cfg.supportedFamilies([.systemSmall, .systemMedium]) } + #else + return cfg.supportedFamilies([.systemSmall, .systemMedium]) + #endif } } @@ -228,31 +422,176 @@ struct DetailItem: View { let color: Color var body: some View { - HStack(spacing: 6.7) { - Image(systemName: icon).resizable().foregroundColor(color).frame(width: 11, height: 11, alignment: .center) + HStack(spacing: WidgetConstants.Spacing.normal) { + 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) - .font(.system(size: 11, design: .monospaced)) + .font(.system(size: 12, weight: .medium, design: .rounded)) .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 + } } } } -// 空心圆,显示百分比 -struct CirclePercent: View { - // eg: 31.7% - let percent: String +// Modern gauge tile with enhanced visual design +struct GaugeTile: View { + let label: 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 { - // 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)) + VStack(spacing: WidgetConstants.Spacing.normal) { + ZStack { + // Background circle with subtle shadow effect + Circle() + .stroke(Color.primary.opacity(0.1), lineWidth: 4) + .background( + 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 { let dark: UIColor let light: UIColor diff --git a/ios/WatchApp/ContentView.swift b/ios/WatchApp/ContentView.swift index c6b3b66f..fa8ef396 100644 --- a/ios/WatchApp/ContentView.swift +++ b/ios/WatchApp/ContentView.swift @@ -9,22 +9,62 @@ import SwiftUI struct ContentView: View { @ObservedObject var _mgr = PhoneConnMgr() + @State private var selection: Int = 0 + @State private var refreshAllCounter: Int = 0 var body: some View { - let _count = _mgr.urls.count == 0 ? 1 : _mgr.urls.count - TabView { - ForEach(0 ..< _count, id:\.self) { index in - let url = _count == 1 && _mgr.urls.count == 0 ? nil : _mgr.urls[index] - PageView(url: url, state: .loading) + let hasServers = !_mgr.urls.isEmpty + let pagesCount = hasServers ? _mgr.urls.count : 1 + TabView(selection: $selection) { + ForEach(0 ..< pagesCount, id:\.self) { index in + let url = hasServers ? _mgr.urls[index] : nil + PageView( + url: url, + state: .loading, + refreshAllCounter: refreshAllCounter, + onRefreshAll: { refreshAllCounter += 1 } + ) + .tag(index) } } .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 { let url: String? @State var state: ContentState + // 触发所有页面刷新的计数器 + let refreshAllCounter: Int + let onRefreshAll: () -> Void var body: some View { if url == nil { @@ -36,35 +76,50 @@ struct PageView: View { Spacer() } } else { - switch state { - case .loading: - ProgressView().padding().onAppear { - getStatus(url: url!) - } - case .error(let err): + Group { + switch state { + case .loading: + ProgressView().padding().onAppear { + getStatus(url: url!) + } + case .error(let err): switch err { case .http(let description): VStack(alignment: .center) { Text(description) - Button(action: { - state = .loading - }){ - Image(systemName: "arrow.clockwise") - }.buttonStyle(.plain) + HStack(spacing: 10) { + Button(action: { + state = .loading + }){ + Image(systemName: "arrow.clockwise") + }.buttonStyle(.plain) + Button(action: { + onRefreshAll() + }){ + Image(systemName: "arrow.triangle.2.circlepath") + }.buttonStyle(.plain) + } } case .url(_): Link("View help", destination: helpUrl) } - case .normal(let status): - VStack(alignment: .leading) { + case .normal(let status): + VStack(alignment: .leading) { HStack { Text(status.name).font(.system(.title, design: .monospaced)) Spacer() - Button(action: { - state = .loading - }){ - Image(systemName: "arrow.clockwise") - }.buttonStyle(.plain) + HStack(spacing: 10) { + Button(action: { + state = .loading + }){ + Image(systemName: "arrow.clockwise") + }.buttonStyle(.plain) + Button(action: { + onRefreshAll() + }){ + Image(systemName: "arrow.triangle.2.circlepath") + }.buttonStyle(.plain) + } } Spacer() DetailItem(icon: "cpu", text: status.cpu) @@ -72,6 +127,12 @@ struct PageView: View { DetailItem(icon: "externaldrive", text: status.disk) DetailItem(icon: "network", text: status.net) }.frame(maxWidth: .infinity, maxHeight: .infinity).padding([.horizontal], 11) + } + } + .onChange(of: refreshAllCounter) { _ in + if let url = url { + getStatus(url: url) + } } } } @@ -87,25 +148,32 @@ struct PageView: View { return } let task = URLSession.shared.dataTask(with: url) { (data, response, error) in - guard error == nil else { - state = .error(.http(error!.localizedDescription)) + // 所有 UI 状态更新必须在主线程执行,否则可能导致 TabView 跳回第一页等问题 + func setStateOnMain(_ newState: ContentState) { + DispatchQueue.main.async { + self.state = newState + } + } + + if let error = error { + setStateOnMain(.error(.http(error.localizedDescription))) return } guard let data = data else { - state = .error(.http("empty data")) + setStateOnMain(.error(.http("empty data"))) return } 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 } guard let code = jsonAll["code"] as? Int else { - state = .error(.http("code is nil")) + setStateOnMain(.error(.http("code is nil"))) return } if (code != 0) { let msg = jsonAll["msg"] as? String ?? "" - state = .error(.http(msg)) + setStateOnMain(.error(.http(msg))) return } @@ -115,10 +183,35 @@ struct PageView: View { let cpu = json["cpu"] as? String ?? "" let mem = json["mem"] 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() } + + // 监听“刷新全部”触发器变化,主动刷新当前页 + @ViewBuilder + var _onRefreshAllHook: some View { + EmptyView() + .onChange(of: refreshAllCounter) { _ in + if let url = url { + getStatus(url: url) + } + } + } } struct ContentView_Previews: PreviewProvider { diff --git a/ios/WatchApp/PhoneConnMgr.swift b/ios/WatchApp/PhoneConnMgr.swift index 0d0df99c..58a7ca5a 100644 --- a/ios/WatchApp/PhoneConnMgr.swift +++ b/ios/WatchApp/PhoneConnMgr.swift @@ -44,7 +44,13 @@ class PhoneConnMgr: NSObject, WCSessionDelegate, ObservableObject { func updateUrls(_ val: [String: Any]) { if let urls = val["urls"] as? [String] { 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") + } } } } diff --git a/ios/WatchWidget/WatchStatusWidget.swift b/ios/WatchWidget/WatchStatusWidget.swift new file mode 100644 index 00000000..cad47e7c --- /dev/null +++ b/ios/WatchWidget/WatchStatusWidget.swift @@ -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) -> 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)) + } +} + diff --git a/ios/WatchWidget/WatchStatusWidgetBundle.swift b/ios/WatchWidget/WatchStatusWidgetBundle.swift new file mode 100644 index 00000000..0f3ee614 --- /dev/null +++ b/ios/WatchWidget/WatchStatusWidgetBundle.swift @@ -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() + } +} +