feature/ios-time-entries-rework #2

Merged
simonfranken merged 5 commits from feature/ios-time-entries-rework into main 2026-02-23 16:58:45 +00:00
Showing only changes of commit b971569983 - Show all commits

View File

@@ -2,27 +2,17 @@ import SwiftUI
struct TimeEntriesView: View {
@StateObject private var viewModel = TimeEntriesViewModel()
@State private var selectedDay: Date? = Calendar.current.startOfDay(for: Date())
@State private var visibleWeekStart: Date = Self.mondayOfWeek(containing: Date())
@State private var selectedDate: Date = Calendar.current.startOfDay(for: Date())
// For infinite paging, we use an offset from 'today'
@State private var dayOffset: Int = 0
@State private var showFilterSheet = false
@State private var showAddEntry = false
@State private var entryToEdit: TimeEntry?
@State private var entryToDelete: TimeEntry?
@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 {
NavigationStack {
Group {
@@ -37,6 +27,7 @@ struct TimeEntriesView: View {
}
}
.navigationTitle("Entries")
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent }
.task { await viewModel.loadEntries() }
.refreshable { await viewModel.loadEntries() }
@@ -81,34 +72,49 @@ struct TimeEntriesView: View {
Image(systemName: viewModel.activeFilters.isEmpty ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill")
}
}
// Place the DatePicker in the principal placement (center of nav bar)
ToolbarItem(placement: .principal) {
DatePicker(
"",
selection: Binding(
get: {
Calendar.current.date(byAdding: .day, value: dayOffset, to: Calendar.current.startOfDay(for: Date())) ?? Date()
},
set: { newDate in
let today = Calendar.current.startOfDay(for: Date())
let normalizedNewDate = Calendar.current.startOfDay(for: newDate)
let components = Calendar.current.dateComponents([.day], from: today, to: normalizedNewDate)
if let dayDifference = components.day {
dayOffset = dayDifference
}
}
),
displayedComponents: .date
)
.datePickerStyle(.compact)
.labelsHidden()
.environment(\.locale, Locale.current) // Ensure correct start of week
}
}
// MARK: - Main content
private var mainContent: some View {
VStack(spacing: 0) {
WeekStripView(
weekDays: visibleWeekDays,
selectedDay: $selectedDay,
daysWithEntries: viewModel.daysWithEntries,
onSwipeLeft: {
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: visibleWeekStart) ?? visibleWeekStart
},
onSwipeRight: {
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: visibleWeekStart) ?? visibleWeekStart
}
)
Divider()
// We use a wide range of offsets to simulate infinite paging.
// -1000 days is roughly 2.7 years in the past.
// 1000 days is roughly 2.7 years in the future.
TabView(selection: $dayOffset) {
ForEach(-1000...1000, id: \.self) { offset in
let dayForPage = Calendar.current.date(byAdding: .day, value: offset, to: Calendar.current.startOfDay(for: Date())) ?? Date()
ScrollView {
if let day = selectedDay {
dayEntriesSection(for: day)
} else {
allEntriesSection
}
dayEntriesSection(for: dayForPage)
}
.tag(offset) // Important: tag must match the selection type (Int)
}
}
.tabViewStyle(.page(indexDisplayMode: .never)) // Native swipe gesture
}
// MARK: - Day entries section
@@ -116,7 +122,8 @@ struct TimeEntriesView: View {
private func dayEntriesSection(for day: Date) -> some View {
let dayEntries = viewModel.entries(for: day)
return VStack(alignment: .leading, spacing: 0) {
// Section header
// Optional: A small summary header for the day
HStack {
Text(dayTitle(day))
.font(.subheadline)
@@ -155,41 +162,7 @@ struct TimeEntriesView: View {
}
}
}
}
// MARK: - All entries (no day selected) grouped by day
private var allEntriesSection: some View {
LazyVStack(alignment: .leading, pinnedViews: .sectionHeaders) {
ForEach(viewModel.entriesByDay, id: \.date) { group in
Section {
ForEach(Array(group.entries.enumerated()), id: \.element.id) { index, entry in
EntryRow(entry: entry)
.contentShape(Rectangle())
.onTapGesture { entryToEdit = entry }
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
entryToDelete = entry
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
}
if index < group.entries.count - 1 {
Divider().padding(.leading, 56)
}
}
} header: {
Text(dayTitle(group.date))
.font(.subheadline)
.fontWeight(.semibold)
.padding(.horizontal)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.regularMaterial)
}
}
}
.padding(.bottom, 40) // Give some breathing room at the bottom of the scroll
}
// MARK: - Helpers
@@ -198,6 +171,7 @@ struct TimeEntriesView: View {
let cal = Calendar.current
if cal.isDateInToday(date) { return "Today" }
if cal.isDateInYesterday(date) { return "Yesterday" }
if cal.isDateInTomorrow(date) { return "Tomorrow" }
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMM d"
return formatter.string(from: date)
@@ -259,139 +233,6 @@ struct EntryRow: View {
}
}
// MARK: - Week Strip View
struct WeekStripView: View {
let weekDays: [Date]
@Binding var selectedDay: Date?
let daysWithEntries: Set<Date>
let onSwipeLeft: () -> Void
let onSwipeRight: () -> Void
@GestureState private var dragOffset: CGFloat = 0
private let cal = Calendar.current
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)
}
var body: some View {
VStack(spacing: 4) {
// Month / year header with navigation arrows
HStack {
Button { onSwipeRight() } label: {
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()
}
}
)
}
}
// MARK: - Day Cell
private struct DayCell: View {
let day: Date
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)
}
Text(Self.dayFormatter.string(from: day))
.font(.callout.weight(isToday || isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? .white : (isToday ? Color.accentColor : .primary))
}
.frame(width: 32, height: 32)
// Dot indicator
Circle()
.fill(hasDot ? Color.accentColor.opacity(isSelected ? 0 : 0.7) : Color.clear)
.frame(width: 4, height: 4)
}
.padding(.vertical, 4)
}
}
// MARK: - Filter Sheet