feat(ios): replace week strip with native DatePicker and TabView paging
This commit is contained in:
@@ -2,27 +2,17 @@ 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 selectedDate: Date = Calendar.current.startOfDay(for: Date())
|
||||||
@State private var visibleWeekStart: Date = Self.mondayOfWeek(containing: Date())
|
|
||||||
|
// For infinite paging, we use an offset from 'today'
|
||||||
|
@State private var dayOffset: Int = 0
|
||||||
|
|
||||||
@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 {
|
||||||
@@ -37,6 +27,7 @@ struct TimeEntriesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Entries")
|
.navigationTitle("Entries")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { toolbarContent }
|
.toolbar { toolbarContent }
|
||||||
.task { await viewModel.loadEntries() }
|
.task { await viewModel.loadEntries() }
|
||||||
.refreshable { 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")
|
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
|
// MARK: - Main content
|
||||||
|
|
||||||
private var mainContent: some View {
|
private var mainContent: some View {
|
||||||
VStack(spacing: 0) {
|
// We use a wide range of offsets to simulate infinite paging.
|
||||||
WeekStripView(
|
// -1000 days is roughly 2.7 years in the past.
|
||||||
weekDays: visibleWeekDays,
|
// 1000 days is roughly 2.7 years in the future.
|
||||||
selectedDay: $selectedDay,
|
TabView(selection: $dayOffset) {
|
||||||
daysWithEntries: viewModel.daysWithEntries,
|
ForEach(-1000...1000, id: \.self) { offset in
|
||||||
onSwipeLeft: {
|
let dayForPage = Calendar.current.date(byAdding: .day, value: offset, to: Calendar.current.startOfDay(for: Date())) ?? Date()
|
||||||
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: visibleWeekStart) ?? visibleWeekStart
|
|
||||||
},
|
ScrollView {
|
||||||
onSwipeRight: {
|
dayEntriesSection(for: dayForPage)
|
||||||
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: visibleWeekStart) ?? visibleWeekStart
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
ScrollView {
|
|
||||||
if let day = selectedDay {
|
|
||||||
dayEntriesSection(for: day)
|
|
||||||
} else {
|
|
||||||
allEntriesSection
|
|
||||||
}
|
}
|
||||||
|
.tag(offset) // Important: tag must match the selection type (Int)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never)) // Native swipe gesture
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Day entries section
|
// MARK: - Day entries section
|
||||||
@@ -116,7 +122,8 @@ struct TimeEntriesView: View {
|
|||||||
private func dayEntriesSection(for day: Date) -> some View {
|
private func dayEntriesSection(for day: Date) -> some View {
|
||||||
let dayEntries = viewModel.entries(for: day)
|
let dayEntries = viewModel.entries(for: day)
|
||||||
return VStack(alignment: .leading, spacing: 0) {
|
return VStack(alignment: .leading, spacing: 0) {
|
||||||
// Section header
|
|
||||||
|
// Optional: A small summary header for the day
|
||||||
HStack {
|
HStack {
|
||||||
Text(dayTitle(day))
|
Text(dayTitle(day))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@@ -155,41 +162,7 @@ struct TimeEntriesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.padding(.bottom, 40) // Give some breathing room at the bottom of the scroll
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
@@ -198,6 +171,7 @@ struct TimeEntriesView: View {
|
|||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
if cal.isDateInToday(date) { return "Today" }
|
if cal.isDateInToday(date) { return "Today" }
|
||||||
if cal.isDateInYesterday(date) { return "Yesterday" }
|
if cal.isDateInYesterday(date) { return "Yesterday" }
|
||||||
|
if cal.isDateInTomorrow(date) { return "Tomorrow" }
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "EEEE, MMM d"
|
formatter.dateFormat = "EEEE, MMM d"
|
||||||
return formatter.string(from: date)
|
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
|
// MARK: - Filter Sheet
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user