diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index d594f112..567a8a65 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -141,7 +141,10 @@ "Keep the percentage and time remaining visible without hovering." = "Keep the percentage and time remaining visible without hovering."; "Inject test percentages. Visible only when launched with CODEXISLAND_DEBUG=1." = "Inject test percentages. Visible only when launched with CODEXISLAND_DEBUG=1."; "Preview" = "Preview"; +"Pace guide" = "Pace guide"; "resets in %d hours" = "resets in %d hours"; "resets in %d minutes" = "resets in %d minutes"; +"Show a small marker for expected usage before reset." = "Show a small marker for expected usage before reset."; "Swipe to change pages." = "Swipe to change pages."; "Tightens the gap between logos when the island is on a screen without a hardware notch." = "Tightens the gap between logos when the island is on a screen without a hardware notch."; +"used %d%%, guide %d%%" = "used %d%%, guide %d%%"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 37141e75..65d9dc0e 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -141,7 +141,10 @@ "Keep the percentage and time remaining visible without hovering." = "无需悬停即可始终查看用量百分比和剩余时间。"; "Inject test percentages. Visible only when launched with CODEXISLAND_DEBUG=1." = "注入测试百分比。仅在使用 CODEXISLAND_DEBUG=1 启动时显示。"; "Preview" = "预览"; +"Pace guide" = "进度参考线"; "resets in %d hours" = "%d 小时后重置"; "resets in %d minutes" = "%d 分钟后重置"; +"Show a small marker for expected usage before reset." = "显示重置前预期用量的小标记。"; "Swipe to change pages." = "滑动切换页面。"; "Tightens the gap between logos when the island is on a screen without a hardware notch." = "在没有硬件刘海的屏幕上收紧 logo 之间的间距。"; +"used %d%%, guide %d%%" = "已用 %d%%,建议 %d%%"; diff --git a/Sources/Model/PaceGuideStore.swift b/Sources/Model/PaceGuideStore.swift new file mode 100644 index 00000000..7ebc3d97 --- /dev/null +++ b/Sources/Model/PaceGuideStore.swift @@ -0,0 +1,20 @@ +import Foundation + +/// User preference for showing the reset-window pace guide in usage charts. +/// +/// Default off so upgrading users keep the original cleaner charts until +/// they opt in from General settings. +@MainActor +final class PaceGuideStore: ObservableObject { + static let shared = PaceGuideStore() + + private static let key = "MacIsland.paceGuideEnabled" + + @Published var enabled: Bool { + didSet { UserDefaults.standard.set(enabled, forKey: Self.key) } + } + + private init() { + self.enabled = Pref.seededBool(key: Self.key, default: false) + } +} diff --git a/Sources/Views/Charts/BarChart.swift b/Sources/Views/Charts/BarChart.swift index 9200cd1a..abac19dd 100644 --- a/Sources/Views/Charts/BarChart.swift +++ b/Sources/Views/Charts/BarChart.swift @@ -5,6 +5,7 @@ struct BarChart: View { let color: Color let label: String let sub: String + let guide: Double? var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -16,6 +17,13 @@ struct BarChart: View { .fill(color) .frame(width: geo.size.width * CGFloat(value / 100), height: 4) .animation(.strongEaseOut, value: value) + if let guide { + RoundedRectangle(cornerRadius: 1) + .fill(.white.opacity(0.86)) + .frame(width: 2, height: 11) + .offset(x: geo.size.width * CGFloat(guide / 100)) + .animation(.strongEaseOut, value: guide) + } // Tick marks at quartiles. Subtle (12% white) so they // hint at scale without competing with the fill. ForEach([0.25, 0.5, 0.75], id: \.self) { p in diff --git a/Sources/Views/Charts/NumericChart.swift b/Sources/Views/Charts/NumericChart.swift index 4bf3306d..efb076ca 100644 --- a/Sources/Views/Charts/NumericChart.swift +++ b/Sources/Views/Charts/NumericChart.swift @@ -5,6 +5,7 @@ struct NumericChart: View { let color: Color let label: String let sub: String + let guide: Double? var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -39,6 +40,13 @@ struct NumericChart: View { .frame(width: geo.size.width * CGFloat(value / 100), height: 3) .shadow(color: color.opacity(0.7), radius: 4) .animation(.strongEaseOut, value: value) + if let guide { + RoundedRectangle(cornerRadius: 1) + .fill(.white.opacity(0.86)) + .frame(width: 2, height: 10) + .offset(x: geo.size.width * CGFloat(guide / 100)) + .animation(.strongEaseOut, value: guide) + } } } .frame(height: 4) diff --git a/Sources/Views/Charts/RingChart.swift b/Sources/Views/Charts/RingChart.swift index fb4baefa..1404801b 100644 --- a/Sources/Views/Charts/RingChart.swift +++ b/Sources/Views/Charts/RingChart.swift @@ -5,6 +5,7 @@ struct RingChart: View { let color: Color let label: String let sub: String + let guide: Double? var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -20,6 +21,12 @@ struct RingChart: View { // (old → new), which makes the trim feel alive // without ever flashing 0%. .animation(.strongEaseOut, value: value) + if let guide { + RingGuideTick(guide: guide) + .stroke(.white.opacity(0.86), + style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .animation(.strongEaseOut, value: guide) + } } .frame(width: 56, height: 56) @@ -49,3 +56,31 @@ struct RingChart: View { } } } + +private struct RingGuideTick: Shape { + var guide: Double + + var animatableData: Double { + get { guide } + set { guide = newValue } + } + + func path(in rect: CGRect) -> Path { + let radius = min(rect.width, rect.height) / 2 + let center = CGPoint(x: rect.midX, y: rect.midY) + let radians = CGFloat((guide / 100) * 360 - 90) * .pi / 180 + let inner = radius - 8 + let outer = radius + 1 + + var path = Path() + path.move(to: CGPoint( + x: center.x + cos(radians) * inner, + y: center.y + sin(radians) * inner + )) + path.addLine(to: CGPoint( + x: center.x + cos(radians) * outer, + y: center.y + sin(radians) * outer + )) + return path + } +} diff --git a/Sources/Views/Charts/SparkChart.swift b/Sources/Views/Charts/SparkChart.swift index 684ee93e..85ee48d6 100644 --- a/Sources/Views/Charts/SparkChart.swift +++ b/Sources/Views/Charts/SparkChart.swift @@ -6,11 +6,12 @@ struct SparkChart: View { let label: String let sub: String let seed: Int + let guide: Double? var body: some View { VStack(alignment: .leading, spacing: 6) { ChartHead(value: value, label: label) - SparkSVG(value: value, color: color, seed: seed) + SparkSVG(value: value, color: color, seed: seed, guide: guide) .frame(height: 50) .animation(.strongEaseOut, value: value) ChartFoot(caption: sub) @@ -22,6 +23,7 @@ private struct SparkSVG: View { let value: Double let color: Color let seed: Int + let guide: Double? /// Synthesize 36 plausible-looking historical points around the current /// value. Real history would need a usage time-series API neither @@ -60,6 +62,7 @@ private struct SparkSVG: View { let w = geo.size.width, h = geo.size.height let pts = generatePoints(width: w, height: h) let baselineY = h - CGFloat(value / 100) * (h - 8) - 4 + let guideY = guide.map { h - CGFloat($0 / 100) * (h - 8) - 4 } ZStack { // Quartile rules at 4% white, barely there. ForEach([0.25, 0.5, 0.75], id: \.self) { p in @@ -69,13 +72,17 @@ private struct SparkSVG: View { } .stroke(.white.opacity(0.04), lineWidth: 1) } - // Dotted threshold at the current value — the line reading - // "this is now" against the synthesized history. + // Dotted threshold: guide when reset data exists; otherwise + // the current value as the old decorative baseline. Path { path in - path.move(to: CGPoint(x: 0, y: baselineY)) - path.addLine(to: CGPoint(x: w, y: baselineY)) + path.move(to: CGPoint(x: 0, y: guideY ?? baselineY)) + path.addLine(to: CGPoint(x: w, y: guideY ?? baselineY)) } - .stroke(color.opacity(0.25), style: StrokeStyle(lineWidth: 1, dash: [2, 3])) + .stroke( + guideY == nil ? color.opacity(0.25) : .white.opacity(0.38), + style: StrokeStyle(lineWidth: guideY == nil ? 1 : 1.5, dash: [2, 3]) + ) + .animation(.strongEaseOut, value: guide ?? value) // Gradient area fill under the curve. Path { p in diff --git a/Sources/Views/Charts/SteppedChart.swift b/Sources/Views/Charts/SteppedChart.swift index 88db1684..841e906d 100644 --- a/Sources/Views/Charts/SteppedChart.swift +++ b/Sources/Views/Charts/SteppedChart.swift @@ -5,23 +5,37 @@ struct SteppedChart: View { let color: Color let label: String let sub: String + let guide: Double? var body: some View { VStack(alignment: .leading, spacing: 8) { ChartHead(value: value, label: label) - HStack(spacing: 2) { - let segments = 30 - let filled = (value / 100) * Double(segments) - ForEach(0.. Double? { + guard paceGuide.enabled, + window.error == nil, + let resetAt = window.resetAt + else { return nil } + + let duration: TimeInterval = labelKey == "week" + ? 7 * 24 * 60 * 60 + : 5 * 60 * 60 + let remaining = resetAt.timeIntervalSinceNow + return min(100, max(0, (1 - remaining / duration) * 100)) + } + + private func accessibilityValue() -> String { + guard let guide = paceGuidePercent() else { return subCaption() } + return L10n.tr("used %d%%, guide %d%%", + window.percentInt, + Int(guide.rounded())) } private func subCaption() -> String {