Replace full-month UICalendarView with compact week strip in time entries

This commit is contained in:
2026-02-21 18:01:02 +01:00
parent 30d5139ad8
commit b613fe4edd

View File

@@ -3,12 +3,26 @@ import SwiftUI
struct TimeEntriesView: View { struct TimeEntriesView: View {
@StateObject private var viewModel = TimeEntriesViewModel() @StateObject private var viewModel = TimeEntriesViewModel()
@State private var selectedDay: Date? = Calendar.current.startOfDay(for: Date()) @State private var selectedDay: Date? = Calendar.current.startOfDay(for: Date())
@State private var visibleWeekStart: Date = Self.mondayOfWeek(containing: Date())
@State private var showFilterSheet = false @State private var showFilterSheet = false
@State private var showAddEntry = false @State private var showAddEntry = false
@State private var entryToEdit: TimeEntry? @State private var entryToEdit: TimeEntry?
@State private var entryToDelete: TimeEntry? @State private var entryToDelete: TimeEntry?
@State private var showDeleteConfirmation = false @State private var showDeleteConfirmation = false
private static func mondayOfWeek(containing date: Date) -> Date {
var cal = Calendar.current
cal.firstWeekday = 2 // Monday
let comps = cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)
return cal.date(from: comps) ?? Calendar.current.startOfDay(for: date)
}
private var visibleWeekDays: [Date] {
(0..<7).compactMap {
Calendar.current.date(byAdding: .day, value: $0, to: visibleWeekStart)
}
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { Group {
@@ -72,19 +86,22 @@ struct TimeEntriesView: View {
// MARK: - Main content // MARK: - Main content
private var mainContent: some View { private var mainContent: some View {
ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
// Month calendar WeekStripView(
CalendarGridView( weekDays: visibleWeekDays,
selectedDay: $selectedDay,
daysWithEntries: viewModel.daysWithEntries, daysWithEntries: viewModel.daysWithEntries,
selectedDay: $selectedDay onSwipeLeft: {
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: visibleWeekStart) ?? visibleWeekStart
},
onSwipeRight: {
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: visibleWeekStart) ?? visibleWeekStart
}
) )
.padding(.horizontal)
.padding(.top, 8)
Divider().padding(.top, 8) Divider()
// Day detail entries for the selected day ScrollView {
if let day = selectedDay { if let day = selectedDay {
dayEntriesSection(for: day) dayEntriesSection(for: day)
} else { } else {
@@ -242,83 +259,137 @@ struct EntryRow: View {
} }
} }
// MARK: - Calendar Grid View (UICalendarView wrapper) // MARK: - Week Strip View
struct CalendarGridView: UIViewRepresentable { struct WeekStripView: View {
let daysWithEntries: Set<Date> let weekDays: [Date]
@Binding var selectedDay: Date? @Binding var selectedDay: Date?
let daysWithEntries: Set<Date>
let onSwipeLeft: () -> Void
let onSwipeRight: () -> Void
func makeUIView(context: Context) -> UICalendarView { @GestureState private var dragOffset: CGFloat = 0
let view = UICalendarView()
view.calendar = .current
view.locale = .current
view.fontDesign = .rounded
view.delegate = context.coordinator
let selection = UICalendarSelectionSingleDate(delegate: context.coordinator) private let cal = Calendar.current
if let day = selectedDay {
selection.selectedDate = Calendar.current.dateComponents([.year, .month, .day], from: day) private var monthYearLabel: String {
// Show the month/year of the majority of days in the strip
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
let midWeek = weekDays.count >= 4 ? weekDays[3] : (weekDays.first ?? Date())
return formatter.string(from: midWeek)
} }
view.selectionBehavior = selection
// Show current month var body: some View {
let today = Date() VStack(spacing: 4) {
let comps = Calendar.current.dateComponents([.year, .month], from: today) // Month / year header with navigation arrows
if let startOfMonth = Calendar.current.date(from: comps) { HStack {
view.visibleDateComponents = Calendar.current.dateComponents( Button { onSwipeRight() } label: {
[.year, .month, .day], from: startOfMonth Image(systemName: "chevron.left")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 32, height: 32)
}
Spacer()
Text(monthYearLabel)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Spacer()
Button { onSwipeLeft() } label: {
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 32, height: 32)
}
}
.padding(.horizontal, 8)
.padding(.top, 6)
// Day cells
HStack(spacing: 0) {
ForEach(weekDays, id: \.self) { day in
DayCell(
day: day,
isSelected: selectedDay.map { cal.isDate($0, inSameDayAs: day) } ?? false,
isToday: cal.isDateInToday(day),
hasDot: daysWithEntries.contains(cal.startOfDay(for: day))
)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.onTapGesture {
let normalized = cal.startOfDay(for: day)
if let current = selectedDay, cal.isDate(current, inSameDayAs: normalized) {
selectedDay = nil
} else {
selectedDay = normalized
}
}
}
}
.padding(.bottom, 6)
}
.gesture(
DragGesture(minimumDistance: 40, coordinateSpace: .local)
.onEnded { value in
if value.translation.width < -40 {
onSwipeLeft()
} else if value.translation.width > 40 {
onSwipeRight()
}
}
) )
} }
return view
} }
func updateUIView(_ uiView: UICalendarView, context: Context) { // MARK: - Day Cell
// Reload all decorations when daysWithEntries changes
uiView.reloadDecorations(forDateComponents: Array(daysWithEntries.map { private struct DayCell: View {
Calendar.current.dateComponents([.year, .month, .day], from: $0) let day: Date
}), animated: false) let isSelected: Bool
let isToday: Bool
let hasDot: Bool
private static let weekdayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEEEE" // Single letter: M T W T F S S
return f
}()
private static let dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "d"
return f
}()
var body: some View {
VStack(spacing: 3) {
Text(Self.weekdayFormatter.string(from: day))
.font(.caption2)
.foregroundStyle(.secondary)
ZStack {
if isSelected {
Circle()
.fill(Color.accentColor)
.frame(width: 32, height: 32)
} else if isToday {
Circle()
.strokeBorder(Color.accentColor, lineWidth: 1.5)
.frame(width: 32, height: 32)
} }
func makeCoordinator() -> Coordinator { Text(Self.dayFormatter.string(from: day))
Coordinator(parent: self) .font(.callout.weight(isToday || isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? .white : (isToday ? Color.accentColor : .primary))
} }
.frame(width: 32, height: 32)
final class Coordinator: NSObject, UICalendarViewDelegate, UICalendarSelectionSingleDateDelegate { // Dot indicator
var parent: CalendarGridView Circle()
.fill(hasDot ? Color.accentColor.opacity(isSelected ? 0 : 0.7) : Color.clear)
init(parent: CalendarGridView) { .frame(width: 4, height: 4)
self.parent = parent
} }
.padding(.vertical, 4)
// Dot decorations for days that have entries
func calendarView(_ calendarView: UICalendarView,
decorationFor dateComponents: DateComponents) -> UICalendarView.Decoration? {
guard let date = Calendar.current.date(from: dateComponents) else { return nil }
let normalized = Calendar.current.startOfDay(for: date)
guard parent.daysWithEntries.contains(normalized) else { return nil }
return .default(color: .systemBlue, size: .small)
}
// Date selection
func dateSelection(_ selection: UICalendarSelectionSingleDate,
didSelectDate dateComponents: DateComponents?) {
guard let comps = dateComponents,
let date = Calendar.current.date(from: comps) else {
parent.selectedDay = nil
return
}
let normalized = Calendar.current.startOfDay(for: date)
// Tap same day again to deselect
if let current = parent.selectedDay, Calendar.current.isDate(current, inSameDayAs: normalized) {
parent.selectedDay = nil
selection.selectedDate = nil
} else {
parent.selectedDay = normalized
}
}
func dateSelection(_ selection: UICalendarSelectionSingleDate,
canSelectDate dateComponents: DateComponents?) -> Bool { true }
} }
} }