diff --git a/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift b/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift index 2b75bbb7..b9a66c04 100644 --- a/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift +++ b/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift @@ -15,13 +15,21 @@ struct NodeDetailPresentation { let title: String var subtitle: String + let secondarySubtitle: String? var properties: [Properties] init( node: CatalogTreeNode, server: ServerDetailPresentation?, - eligibility: ActivationEligibility?, isActiveTarget: Bool + eligibility: ActivationEligibility?, isActiveTarget: Bool, + subscription: SubscriptionSummary? = nil, + locale: Locale = .current, timeZone: TimeZone = .current ) { title = node.name + let isRootSubscriptionGroup = Self.isRootSubscriptionGroup(node) + secondarySubtitle = + isRootSubscriptionGroup + ? String(localized: "配置由远端管理,本地不能修改。", table: "ServerDetails") + : nil var descriptions = [ node.isGroup ? (node.isManual @@ -57,10 +65,67 @@ struct NodeDetailPresentation { } } subtitle = descriptions.joined(separator: " · ") - properties = [Self.information(for: node, eligibility: eligibility)] + properties = [] + if isRootSubscriptionGroup, + let subscriptionProperties = Self.subscriptionInformation( + summary: subscription, locale: locale, timeZone: timeZone) + { + properties.append(subscriptionProperties) + } + properties.append(Self.information(for: node, eligibility: eligibility)) if let server { properties.append(Self.connection(for: server)) } } + private static func isRootSubscriptionGroup(_ node: CatalogTreeNode) -> Bool { + node.isGroup && node.source == .subscription && node.parentID == nil + } + + private static func subscriptionInformation( + summary: SubscriptionSummary?, locale: Locale, timeZone: TimeZone + ) -> Properties? { + guard let summary else { return nil } + var items: [Property] = [] + if let date = summary.lastSucceededAt { + items.append( + Property( + label: String(localized: "最后更新时间", table: "ServerDetails"), + value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone))) + } + if let used = summary.information?.bytesUsed { + items.append( + Property( + label: String(localized: "已用流量", table: "ServerDetails"), + value: SubscriptionInformation.byteCountText(used, locale: locale))) + } + if let remaining = summary.information?.bytesRemaining { + items.append( + Property( + label: String(localized: "剩余流量", table: "ServerDetails"), + value: SubscriptionInformation.byteCountText(remaining, locale: locale))) + } + if let date = summary.information?.expiresAt { + items.append( + Property( + label: String(localized: "到期时间", table: "ServerDetails"), + value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone))) + } + if let date = summary.information?.trafficResetAt { + items.append( + Property( + label: String(localized: "重置时间", table: "ServerDetails"), + value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone))) + } + guard !items.isEmpty else { return nil } + return Properties(title: String(localized: "订阅", table: "ServerDetails"), items: items) + } + + private static func subscriptionTimestamp( + _ date: Date, locale: Locale, timeZone: TimeZone + ) -> String { + date.formatted( + Date.FormatStyle(date: .abbreviated, time: .shortened, locale: locale, timeZone: timeZone)) + } + private static func connection(for server: ServerDetailPresentation) -> Properties { let program: String switch server.plugin.selection { diff --git a/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings b/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings index 096b0e38..834e436f 100644 --- a/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings +++ b/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings @@ -341,6 +341,73 @@ } } } + }, + "配置由远端管理,本地不能修改。": { + "extractionState": "manual", + "comment": "Second subtitle line for the root group of a subscription.", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Managed remotely; cannot be modified locally." + } + } + } + }, + "最后更新时间": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last Update" + } + } + } + }, + "已用流量": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used Data" + } + } + } + }, + "剩余流量": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remaining Data" + } + } + } + }, + "到期时间": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expiration" + } + } + } + }, + "重置时间": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Traffic Reset" + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift index 04cc3588..dee4419c 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift @@ -2,6 +2,8 @@ import SwiftUI /// 装配当前节点的详情会话;查询、参数装载与命令均留在共用视图之外。 struct CatalogNodeDetailView: View { + @Environment(\.locale) private var locale + @Environment(\.timeZone) private var timeZone @ObservedObject var workflow: CatalogWorkflow @ObservedObject var activation: ActivationFeedbackState let nodeID: NodeID @@ -67,8 +69,10 @@ struct CatalogNodeDetailView: View { private func presentation(for node: CatalogTreeNode) -> NodeDetailPresentation { let server = node.isGroup ? nil : workflow.serverDetailPresentation(for: nodeID) + let subscription = subscriptionSummary(for: node) var detail = NodeDetailPresentation( - node: node, server: server, eligibility: eligibility, isActiveTarget: isActiveTarget) + node: node, server: server, eligibility: eligibility, isActiveTarget: isActiveTarget, + subscription: subscription, locale: locale, timeZone: timeZone) if let server, server.plugin.selection != .none { if parameters.failure != nil { detail.appendPluginParameterFailure() @@ -89,4 +93,9 @@ struct CatalogNodeDetailView: View { } return detail } + + private func subscriptionSummary(for node: CatalogTreeNode) -> SubscriptionSummary? { + guard node.isGroup, node.source == .subscription, node.parentID == nil else { return nil } + return workflow.subscriptions.first { $0.groupID == node.id } + } } diff --git a/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift index f9d23c65..26624e0a 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift @@ -13,10 +13,18 @@ struct NodeDetailView: View { Text(detail.title) .font(.title2.weight(.semibold)) .textSelection(.enabled) - Text(detail.subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .textSelection(.enabled) + VStack(alignment: .leading, spacing: 4) { + Text(detail.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .textSelection(.enabled) + if let secondarySubtitle = detail.secondarySubtitle { + Text(secondarySubtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } HStack(spacing: 16) { actions } .buttonStyle(.link) }