mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-24 23:59:36 +00:00
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.
313 lines
11 KiB
Swift
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
|
|
}
|
|
}
|
|
|