// Copyright (C) 2026 ysicing // SPDX-License-Identifier: AGPL-3.0-or-later import Localization import SwiftUI import WidgetData import WidgetKit struct SharedDataEntry: TimelineEntry { let date: Date let snapshot: WidgetSnapshot } struct SharedDataProvider: TimelineProvider { func placeholder(in context: Context) -> SharedDataEntry { let now = Date() return SharedDataEntry(date: now, snapshot: WidgetSnapshot( aiEnabled: true, quotas: [.init(provider: "codex", kind: "weekly", remainingPercent: 68, resetsAt: now.addingTimeInterval(2 * 24 * 3600), fetchedAt: now, isStale: false)], dailyTokens: [.init(provider: "codex", day: now, tokens: 28_400), .init(provider: "claude", day: now, tokens: 12_600)], publicIPEnabled: true, addresses: [.init(family: "v4", ip: "203.0.113.8", countryCode: "CN", city: "上海", organization: "Example Network", purityScore: 85, purityGrade: "A", region: "上海", cityEnglish: "Shanghai", regionEnglish: "Shanghai", asn: "AS4134", isNative: true, ipType: "Residential IP", riskFlags: [])])) } func getSnapshot(in context: Context, completion: @escaping (SharedDataEntry) -> Void) { completion(context.isPreview ? placeholder(in: context) : read()) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let entry = read() // 不发起网络请求,也不周期轮询:数据变化时主应用会主动要求重载; // 这里只需在额度到期(须隐藏)和午夜换日(当日 Token 归零)时自行刷新。 let nextReset = entry.snapshot.currentQuotas(at: entry.date) .compactMap(\.resetsAt).min().map { $0.addingTimeInterval(1) } let nextMidnight = Calendar.autoupdatingCurrent.dateInterval(of: .day, for: entry.date)?.end let next = [nextReset, nextMidnight].compactMap { $0 }.min() completion(Timeline(entries: [entry], policy: next.map { .after($0) } ?? .never)) } private func read() -> SharedDataEntry { let snapshot = WidgetSnapshotStore().load() L10n.configure(AppLanguage(rawValue: snapshot.language) ?? .system) return SharedDataEntry(date: .now, snapshot: snapshot) } } private struct WidgetHeading: View { let title: String let symbol: String var body: some View { Label(title, systemImage: symbol) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .lineLimit(1) } } private struct WidgetEmptyState: View { let enabled: Bool var body: some View { Text(enabled ? tr("暂无数据") : tr("尚未启用")) .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) } } private struct AIQuotaWidgetView: View { @Environment(\.widgetFamily) private var family let entry: SharedDataEntry private var featured: [WidgetSnapshot.Quota] { ["codex", "claude"].compactMap { provider in let windows = entry.snapshot.currentQuotas(at: entry.date).filter { $0.provider == provider } return windows.first(where: { $0.kind == "weekly" }) ?? windows.first(where: { $0.kind.hasSuffix("Weekly") }) ?? windows.first(where: { $0.kind == "session" }) } } var body: some View { VStack(alignment: .leading, spacing: 8) { WidgetHeading(title: tr("订阅额度"), symbol: "sparkles") if let first = featured.first { if family == .systemMedium { HStack(alignment: .top, spacing: 16) { ForEach(featured, id: \.provider) { quota in quotaColumn(quota) } Spacer(minLength: 0) } } else { quotaColumn(first) } } else { WidgetEmptyState(enabled: entry.snapshot.aiEnabled) } Spacer(minLength: 0) } .padding() .containerBackground(.background, for: .widget) .environment(\.locale, L10n.locale) } private func quotaColumn(_ quota: WidgetSnapshot.Quota) -> some View { VStack(alignment: .leading, spacing: 6) { Text(quota.provider == "codex" ? "Codex" : "Claude") .font(.subheadline.weight(.semibold)) HStack(alignment: .firstTextBaseline, spacing: 4) { Text(AIUsageFormat.quotaPercent(remainingPercent: quota.remainingPercent, showsRemaining: entry.snapshot.quotaShowsRemaining != false, locale: L10n.locale)) .font(.system(size: 27, weight: .semibold, design: .rounded)) .monospacedDigit() Text(tr(entry.snapshot.quotaShowsRemaining != false ? "剩余" : "已用")) .font(.caption2) .foregroundStyle(.secondary) } ProgressView(value: AIUsageFormat.quotaValue(remainingPercent: quota.remainingPercent, showsRemaining: entry.snapshot.quotaShowsRemaining != false) ?? 0, total: 100) .tint(quota.remainingPercent < 20 ? .red : .blue) if let reset = quota.resetsAt { Text(tr("重置:") + " " + reset.formatted(date: .abbreviated, time: .shortened)) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.75) } if quota.isStale { Text(tr("上次额度")) .font(.caption2) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) } } struct AIQuotaWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: WidgetKind.aiQuota, provider: SharedDataProvider()) { entry in AIQuotaWidgetView(entry: entry) } .configurationDisplayName(tr("订阅额度")) .description(tr("查看 Codex 和 Claude 的剩余额度与重置时间")) .supportedFamilies([.systemSmall, .systemMedium]) } } private struct TodayTokensWidgetView: View { let entry: SharedDataEntry private var sources: [(name: String, tokens: Int)] { ["codex", "claude"].compactMap { provider in guard let tokens = entry.snapshot.todayTokens(for: provider, at: entry.date) else { return nil } return (provider == "codex" ? "Codex" : "Claude", tokens) } } var body: some View { VStack(alignment: .leading, spacing: 8) { WidgetHeading(title: tr("今日消耗的 Token"), symbol: "sparkles") if sources.isEmpty { WidgetEmptyState(enabled: entry.snapshot.showsLocalUsage) } else { let total = sources.reduce(0) { $0 + $1.tokens } Text(AIUsageFormat.tokens(total, westernUnits: entry.snapshot.westernUnits == true, locale: L10n.locale)) .font(.system(size: 36, weight: .semibold, design: .rounded)) .lineLimit(1) .minimumScaleFactor(0.7) .accessibilityLabel(total.formatted(.number.locale(L10n.locale)) + " Tokens") Spacer(minLength: 0) ForEach(sources, id: \.name) { source in HStack { Text(source.name) Spacer(minLength: 4) Text(AIUsageFormat.tokens(source.tokens, westernUnits: entry.snapshot.westernUnits == true, locale: L10n.locale)) .monospacedDigit() } .font(.caption) .foregroundStyle(.secondary) } } } .padding() .containerBackground(.background, for: .widget) .environment(\.locale, L10n.locale) } } struct TodayTokensWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: WidgetKind.todayTokens, provider: SharedDataProvider()) { entry in TodayTokensWidgetView(entry: entry) } .configurationDisplayName(tr("今日消耗的 Token")) .description(tr("查看今天 Codex 和 Claude 的 Token 用量")) .supportedFamilies([.systemSmall]) } } private struct IPPurityWidgetView: View { @Environment(\.widgetFamily) private var family let entry: SharedDataEntry private var addresses: [WidgetSnapshot.Address] { entry.snapshot.visibleAddresses.filter { $0.purityScore != nil } } var body: some View { VStack(alignment: .leading, spacing: 8) { WidgetHeading(title: tr("IP 纯净度"), symbol: "checkmark.shield") if let first = addresses.first { if family == .systemMedium { VStack(alignment: .leading, spacing: 8) { ForEach(addresses, id: \.family) { address in purityRow(address, compact: addresses.count > 1) } } } else { purityColumn(first) } } else { WidgetEmptyState(enabled: entry.snapshot.publicIPEnabled) } Spacer(minLength: 0) Text("cleanip.io · " + tr("主应用缓存")) .font(.caption2) .foregroundStyle(.tertiary) } .padding() .containerBackground(.background, for: .widget) .environment(\.locale, L10n.locale) } private func purityRow(_ address: WidgetSnapshot.Address, compact: Bool) -> some View { HStack(alignment: .top, spacing: 16) { score(for: address, compact: compact) .frame(width: 90, alignment: .leading) addressDetails(for: address, compact: compact) .frame(maxWidth: .infinity, alignment: .leading) } } private func purityColumn(_ address: WidgetSnapshot.Address) -> some View { VStack(alignment: .leading, spacing: 6) { score(for: address, compact: false) addressDetails(for: address, compact: false) } .frame(maxWidth: .infinity, alignment: .leading) } private func score(for address: WidgetSnapshot.Address, compact: Bool) -> some View { HStack(alignment: .firstTextBaseline, spacing: 4) { Text("\(address.purityScore ?? 0)") .font(.system(size: compact ? 27 : 36, weight: .semibold, design: .rounded)) Text(tr("分")) .font(.caption) .foregroundStyle(.secondary) if let grade = address.purityGrade { Text(grade).font(.caption.weight(.bold)).foregroundStyle(.secondary) } } .lineLimit(1) } private func addressDetails(for address: WidgetSnapshot.Address, compact: Bool) -> some View { VStack(alignment: .leading, spacing: compact ? 2 : 5) { if compact { HStack(spacing: 5) { Text(address.family == "v4" ? "IPv4" : "IPv6") .foregroundStyle(.secondary) Text(address.ip) .font(.caption2.monospaced()) .lineLimit(1) .truncationMode(.middle) } .font(.caption2) } else { Text(address.family == "v4" ? "IPv4" : "IPv6") .font(.caption2.weight(.medium)) .foregroundStyle(.secondary) Text(address.ip) .font(.caption.monospaced()) .lineLimit(1) .truncationMode(.middle) .minimumScaleFactor(0.7) } let labels = ipWidgetTags(for: address) if !labels.isEmpty { Text(labels.joined(separator: " · ")) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } } } } struct IPPurityWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: WidgetKind.ipPurity, provider: SharedDataProvider()) { entry in IPPurityWidgetView(entry: entry) } .configurationDisplayName(tr("IP 纯净度")) .description(tr("显示主应用已查询的公网 IP 纯净度")) .supportedFamilies([.systemSmall, .systemMedium]) } } private struct PublicIPWidgetView: View { @Environment(\.widgetFamily) private var family let entry: SharedDataEntry var body: some View { VStack(alignment: .leading, spacing: 8) { WidgetHeading(title: tr("公网 IP"), symbol: "globe") if let first = entry.snapshot.visibleAddresses.first { if family == .systemMedium { HStack(alignment: .top, spacing: 12) { ForEach(entry.snapshot.visibleAddresses, id: \.family) { address in addressColumn(address) } } } else { addressColumn(first) } } else { WidgetEmptyState(enabled: entry.snapshot.publicIPEnabled) } Spacer(minLength: 0) } .padding() .containerBackground(.background, for: .widget) .environment(\.locale, L10n.locale) } private func addressColumn(_ address: WidgetSnapshot.Address) -> some View { VStack(alignment: .leading, spacing: 5) { Text(address.family == "v4" ? "IPv4" : "IPv6") .font(.caption.weight(.medium)) .foregroundStyle(.secondary) Text(address.ip) .font(.system(size: family == .systemMedium ? 15 : 18, weight: .semibold, design: .monospaced)) .lineLimit(1) .truncationMode(.middle) .minimumScaleFactor(0.7) .help(address.ip) if let location = location(for: address) { Text(location) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } if let asn = address.asn { Text(asn) .font(.caption2.monospaced()) .foregroundStyle(.secondary) .lineLimit(1) } let tags = ipWidgetTags(for: address) if !tags.isEmpty { Text(tags.joined(separator: " · ")) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.75) } } .frame(maxWidth: .infinity, alignment: .leading) } private func location(for address: WidgetSnapshot.Address) -> String? { var parts: [String] = [] if let code = address.countryCode { parts.append(L10n.locale.localizedString(forRegionCode: code) ?? code) } let region = L10n.usesEnglishNames ? (address.regionEnglish ?? address.region) : address.region let city = L10n.usesEnglishNames ? (address.cityEnglish ?? address.city) : address.city for part in [region, city].compactMap({ $0 }) where part != parts.last { parts.append(part) } return parts.isEmpty ? nil : parts.joined(separator: " · ") } } private func ipWidgetTags(for address: WidgetSnapshot.Address) -> [String] { if let flags = address.riskFlags, !flags.isEmpty { return Array(flags.prefix(2)).map { flag in switch flag { case "vpn": "VPN" case "proxy": tr("代理") case "residentialProxy": tr("住宅代理") case "tor": "Tor" case "relay": tr("中继") case "datacenter": tr("机房") case "hosting": tr("托管") case "abuser": tr("滥用记录") default: flag } } } var labels: [String] = [] if let isNative = address.isNative { labels.append(tr(isNative ? "原生 IP" : "广播 IP")) } if let ipType = address.ipType { switch ipType.lowercased() { case "residential ip": labels.append(tr("住宅 IP")) case "datacenter ip", "hosting ip": labels.append(tr("机房 IP")) case "mobile ip": labels.append(tr("移动网络 IP")) case "business ip": labels.append(tr("企业 IP")) default: break } } if labels.isEmpty, address.riskFlags != nil { labels.append(tr("未检出")) } return labels } struct PublicIPWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: WidgetKind.publicIP, provider: SharedDataProvider()) { entry in PublicIPWidgetView(entry: entry) } .configurationDisplayName(tr("公网 IP")) .description(tr("查看公网 IP、归属地、ASN 和标记")) .supportedFamilies([.systemSmall, .systemMedium]) } }