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
5 changed files with 134 additions and 278 deletions

View File

@@ -1,40 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 48 416 416" width="100%" height="100%">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#818CF8" />
<stop offset="100%" stop-color="#4F46E5" />
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 4.0 -->
<svg width="416" height="416" viewBox="0 0 416 416" xmlns="http://www.w3.org/2000/svg">
<linearGradient id="linearGradient1" x1="0" y1="0" x2="416" y2="416" gradientUnits="userSpaceOnUse">
<stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
</linearGradient>
</defs>
<!-- App Icon Background -->
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" />
<!-- Inner Icon Group -->
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<!-- Stopwatch Top Button -->
<path d="M256 96 v44" stroke-width="28" />
<path d="M224 88 h64" stroke-width="24" />
<!-- Stopwatch Side Button -->
<path d="M352 176 l 24 -24" stroke-width="24" />
<!-- Cap for side button -->
<path d="M362 138 l 28 28" stroke-width="24" />
<!-- Outer Ring -->
<circle cx="256" cy="272" r="132" stroke-width="28" />
<!-- Clock Hands -->
<!-- Minute Hand -->
<path d="M256 184 v 88" stroke-width="24" />
<!-- Hour Hand -->
<path d="M256 272 l 48 32" stroke-width="24" />
<!-- Dial Tick Marks -->
<g stroke-width="12" opacity="0.6">
<line x1="256" y1="172" x2="256" y2="188" />
<line x1="256" y1="356" x2="256" y2="372" />
<line x1="172" y1="272" x2="188" y2="272" />
<line x1="340" y1="272" x2="324" y2="272" />
<path id="Path" fill="url(#linearGradient1)" stroke="none" d="M 96 0 L 320 0 C 373.019348 0 416 42.980652 416 96 L 416 320 C 416 373.019348 373.019348 416 320 416 L 96 416 C 42.980667 416 0 373.019348 0 320 L 0 96 C 0 42.980652 42.980667 0 96 0 Z"/>
<g id="Group">
<path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 208 48 L 208 92"/>
<path id="path2" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 176 40 L 240 40"/>
<path id="path3" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 304 128 L 328 104"/>
<path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 314 90 L 342 118"/>
<path id="path5" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 340 224 C 340 296.901581 280.901581 356 208 356 C 135.098419 356 76 296.901581 76 224 C 76 151.098419 135.098419 92 208 92 C 280.901581 92 340 151.098419 340 224 Z"/>
<path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 136 L 208 224"/>
<path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 224 L 256 256"/>
<g id="g1" opacity="0.6">
<path id="path8" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 208 308 L 208 324"/>
<path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 124 224 L 140 224"/>
<path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 292 224 L 276 224"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,43 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#818CF8" />
<stop offset="100%" stop-color="#4F46E5" />
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 4.0 -->
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<linearGradient id="linearGradient1" x1="48" y1="48" x2="464" y2="464" gradientUnits="userSpaceOnUse">
<stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#4F46E5" flood-opacity="0.4" />
<filter id="filter1" x="0" y="0" width="512" height="512" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="16"/>
<feOffset dx="0" dy="12" result="offsetblur"/>
<feFlood flood-color="#4f46e5" flood-opacity="0.4"/>
<feComposite in2="offsetblur" operator="in"/>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- App Icon Background -->
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" filter="url(#shadow)" />
<!-- Inner Icon Group -->
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<!-- Stopwatch Top Button -->
<path d="M256 96 v44" stroke-width="28" />
<path d="M224 88 h64" stroke-width="24" />
<!-- Stopwatch Side Button -->
<path d="M352 176 l 24 -24" stroke-width="24" />
<!-- Cap for side button -->
<path d="M362 138 l 28 28" stroke-width="24" />
<!-- Outer Ring -->
<circle cx="256" cy="272" r="132" stroke-width="28" />
<!-- Clock Hands -->
<!-- Minute Hand -->
<path d="M256 184 v 88" stroke-width="24" />
<!-- Hour Hand -->
<path d="M256 272 l 48 32" stroke-width="24" />
<!-- Dial Tick Marks -->
<g stroke-width="12" opacity="0.6">
<line x1="256" y1="172" x2="256" y2="188" />
<line x1="256" y1="356" x2="256" y2="372" />
<line x1="172" y1="272" x2="188" y2="272" />
<line x1="340" y1="272" x2="324" y2="272" />
<path id="Path" fill="url(#linearGradient1)" stroke="none" filter="url(#filter1)" d="M 144 48 L 368 48 C 421.019348 48 464 90.980652 464 144 L 464 368 C 464 421.019348 421.019348 464 368 464 L 144 464 C 90.980667 464 48 421.019348 48 368 L 48 144 C 48 90.980652 90.980667 48 144 48 Z"/>
<g id="Group">
<path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 256 96 L 256 140"/>
<path id="path2" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 224 88 L 288 88"/>
<path id="path3" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 352 176 L 376 152"/>
<path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 362 138 L 390 166"/>
<path id="path5" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 388 272 C 388 344.901581 328.901581 404 256 404 C 183.098419 404 124 344.901581 124 272 C 124 199.098419 183.098419 140 256 140 C 328.901581 140 388 199.098419 388 272 Z"/>
<path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 184 L 256 272"/>
<path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 272 L 304 304"/>
<g id="g1" opacity="0.6">
<path id="path8" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 256 356 L 256 372"/>
<path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 172 272 L 188 272"/>
<path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 340 272 L 324 272"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -2,27 +2,19 @@ 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())
// dayOffset is the source of truth: 0 = today, -1 = yesterday, etc.
@State private var dayOffset: Int = 0
// tabSelection is always snapped back to 1 (middle) after each swipe.
// Pages are: 0 = dayOffset-1, 1 = dayOffset, 2 = dayOffset+1
@State private var tabSelection: Int = 1
@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 +29,7 @@ struct TimeEntriesView: View {
}
}
.navigationTitle("Entries")
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent }
.task { await viewModel.loadEntries() }
.refreshable { await viewModel.loadEntries() }
@@ -81,32 +74,64 @@ 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
}
)
// Only 3 pages exist at any time: previous, current, next.
// After each swipe settles, we reset tabSelection to 1 and shift
// dayOffset, so the carousel appears infinite while staying cheap.
TabView(selection: $tabSelection) {
ForEach(0..<3, id: \.self) { page in
let offset = dayOffset + (page - 1)
let day = Calendar.current.date(
byAdding: .day,
value: offset,
to: Calendar.current.startOfDay(for: Date())
) ?? Date()
Divider()
ScrollView {
if let day = selectedDay {
ScrollView {
dayEntriesSection(for: day)
} else {
allEntriesSection
}
.tag(page)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: tabSelection) { _, newPage in
guard newPage != 1 else { return }
// Shift the logical day offset by how many pages we moved.
dayOffset += newPage - 1
// Snap back to the middle page without animation so the
// surrounding pages are refreshed invisibly.
var tx = Transaction()
tx.disablesAnimations = true
withTransaction(tx) {
tabSelection = 1
}
}
}
@@ -116,7 +141,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 +181,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 +190,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 +252,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

View File

@@ -1,6 +1,29 @@
{
"images" : [
{
"filename" : "app_icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB