codeburn/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift
iamtoruk 82b02faa4f fix(menubar): keep the English weekday row two letters wide
veryShortWeekdaySymbols gives en "M T W T F S S": two ambiguous pairs, and
two duplicate ForEach ids. shortWeekdaySymbols clipped to two units keeps
"Mo Tu We" in English and reads 周一 周二 in zh-Hans, 19.9pt in a 30pt cell.
2026-09-12 16:12:11 -07:00

313 lines
11 KiB
Swift

import SwiftUI
struct PeriodSegmentedControl: View {
@Environment(AppStore.self) private var store
@State private var showingCalendar = false
var body: some View {
HStack(spacing: 1) {
ForEach(Period.allCases) { period in
let isActive = !store.isDayMode && store.selectedPeriod == period
Button {
store.switchTo(period: period)
} label: {
Text(period.displayLabel)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(isActive ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.shadow(color: .black.opacity(isActive ? 0.06 : 0), radius: 1, y: 0.5)
)
}
Button {
showingCalendar.toggle()
} label: {
Image(systemName: "calendar")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(store.isDayMode ? Theme.brandAccent : .secondary)
.frame(width: 28)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(store.isDayMode ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.shadow(color: .black.opacity(store.isDayMode ? 0.06 : 0), radius: 1, y: 0.5)
)
.popover(isPresented: $showingCalendar, arrowEdge: .bottom) {
CalendarPopover(isPresented: $showingCalendar)
.environment(store)
}
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(Color.secondary.opacity(0.08))
)
.padding(.horizontal, 12)
.padding(.top, 6)
.padding(.bottom, 10)
}
}
private struct CalendarPopover: View {
@Environment(AppStore.self) private var store
@Binding var isPresented: Bool
@State private var displayMonth = Date()
@State private var pending: Set<String> = []
private let calendar = Calendar.current
/// Monday-first, from the locale's own short weekday symbols (Sunday-indexed)
/// clipped to two units, so en keeps its existing `Mo Tu We` row and zh-Hans
/// reads `周一 周二`. `veryShortWeekdaySymbols` would give English a row of
/// `M T W T F S S` with two ambiguous pairs.
private let weekdays: [String] = {
let symbols = Calendar.current.shortWeekdaySymbols.map { String($0.prefix(2)) }
guard symbols.count == 7 else { return symbols }
return Array(symbols[1...]) + [symbols[0]]
}()
private let cellSize: CGFloat = 30
var body: some View {
VStack(spacing: 0) {
HStack {
Button { shiftMonth(-1) } label: {
Image(systemName: "chevron.left")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.secondary)
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Spacer()
Text(monthYearLabel)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
Button { shiftMonth(1) } label: {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(canGoForward ? .secondary : Color.secondary.opacity(0.3))
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!canGoForward)
}
.padding(.horizontal, 10)
.padding(.top, 10)
.padding(.bottom, 6)
HStack(spacing: 0) {
ForEach(weekdays, id: \.self) { day in
Text(day)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.tertiary)
.frame(width: cellSize, height: 16)
}
}
.padding(.bottom, 2)
LazyVGrid(columns: Array(repeating: GridItem(.fixed(cellSize), spacing: 0), count: 7), spacing: 2) {
ForEach(dayCells, id: \.id) { cell in
DayCellView(
cell: cell,
isSelected: pending.contains(cell.dateString),
isToday: cell.dateString == todayString,
isFuture: cell.dateString > todayString
) {
toggleDay(cell.dateString)
}
}
}
.padding(.horizontal, 6)
HStack(spacing: 8) {
if !pending.isEmpty {
Button(L("Clear")) {
pending = []
}
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.buttonStyle(.plain)
}
Spacer()
Text(selectionSummary)
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.lineLimit(1)
Spacer()
Button {
if !pending.isEmpty {
store.switchTo(days: pending)
} else {
store.switchTo(period: store.selectedPeriod)
}
isPresented = false
} label: {
Text(L("Done"))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(pending.isEmpty ? Color.secondary.opacity(0.3) : Theme.brandAccent)
)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 10)
}
.frame(width: cellSize * 7 + 24)
.onAppear {
pending = store.selectedDays
if let first = store.selectedDays.sorted().first,
let date = AppStore.dayFormatter.date(from: first) {
displayMonth = date
}
}
}
private var todayString: String {
AppStore.dayString(from: Date())
}
private var monthYearLabel: String {
let f = DateFormatter()
// Localized template, not a fixed pattern: zh-Hans wants "2026年9月".
f.setLocalizedDateFormatFromTemplate("MMMM yyyy")
return f.string(from: displayMonth)
}
private var canGoForward: Bool {
let nextMonth = calendar.date(byAdding: .month, value: 1, to: displayMonth) ?? displayMonth
return calendar.startOfDay(for: nextMonth) <= calendar.startOfDay(for: Date())
}
private var selectionSummary: String {
if pending.isEmpty { return L("Pick dates") }
if pending.count == 1 { return L("1 day") }
return L("%lld days", pending.count)
}
private func shiftMonth(_ delta: Int) {
if let next = calendar.date(byAdding: .month, value: delta, to: displayMonth) {
displayMonth = next
}
}
private func toggleDay(_ day: String) {
guard day <= todayString else { return }
if pending.contains(day) {
pending.remove(day)
} else {
pending.insert(day)
}
}
var dayCells: [DayCell] {
let comps = calendar.dateComponents([.year, .month], from: displayMonth)
guard let firstOfMonth = calendar.date(from: comps),
let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else { return [] }
var weekdayOfFirst = calendar.component(.weekday, from: firstOfMonth) - 2
if weekdayOfFirst < 0 { weekdayOfFirst += 7 }
var cells: [DayCell] = []
for offset in stride(from: -weekdayOfFirst, to: 0, by: 1) {
if let date = calendar.date(byAdding: .day, value: offset, to: firstOfMonth) {
let d = calendar.component(.day, from: date)
cells.append(DayCell(id: "prev-\(offset)", day: d, dateString: AppStore.dayString(from: date), isCurrentMonth: false))
}
}
for day in range {
if let date = calendar.date(byAdding: .day, value: day - 1, to: firstOfMonth) {
cells.append(DayCell(id: "cur-\(day)", day: day, dateString: AppStore.dayString(from: date), isCurrentMonth: true))
}
}
let remainder = (7 - cells.count % 7) % 7
if let lastOfMonth = calendar.date(byAdding: .day, value: range.count - 1, to: firstOfMonth) {
for i in 1...max(remainder, 1) {
if let date = calendar.date(byAdding: .day, value: i, to: lastOfMonth) {
let d = calendar.component(.day, from: date)
cells.append(DayCell(id: "next-\(i)", day: d, dateString: AppStore.dayString(from: date), isCurrentMonth: false))
}
}
}
if cells.count % 7 != 0 {
cells = Array(cells.prefix(cells.count - cells.count % 7))
}
return cells
}
}
private struct DayCell: Identifiable {
let id: String
let day: Int
let dateString: String
let isCurrentMonth: Bool
}
private struct DayCellView: View {
let cell: DayCell
let isSelected: Bool
let isToday: Bool
let isFuture: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text("\(cell.day)")
.font(.system(size: 11, weight: isToday ? .bold : .regular))
.foregroundStyle(foregroundColor)
.frame(width: 28, height: 28)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(backgroundColor)
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(isToday && !isSelected ? Theme.brandAccent.opacity(0.5) : .clear, lineWidth: 1)
)
}
.buttonStyle(.plain)
.disabled(isFuture)
}
private var foregroundColor: Color {
if isFuture { return Color.secondary.opacity(0.25) }
if isSelected { return .white }
if !cell.isCurrentMonth { return Color.secondary.opacity(0.4) }
if isToday { return Theme.brandAccent }
return .primary
}
private var backgroundColor: Color {
if isSelected { return Theme.brandAccent }
return .clear
}
}