6 Commits

Author SHA1 Message Date
850f12e09d Merge pull request 'feature/ios-time-entries-rework' (#2) from feature/ios-time-entries-rework into main
Reviewed-on: #2
2026-02-23 16:58:44 +00:00
0c0fbf42ef updates icons 2026-02-23 17:57:25 +01:00
0d116c8c26 Merge branch 'ios-rebuild' into feature/ios-time-entries-rework 2026-02-23 17:29:21 +01:00
simon.franken
c99bdf56e6 Merge branch 'ios-rebuild' into feature/ios-time-entries-rework 2026-02-23 10:12:12 +01:00
simon.franken
544b86c948 fix(ios): replace 2001-page TabView with 3-page recycling carousel
Eliminates the eager instantiation of 2001 view bodies by keeping only
three pages (previous, current, next) alive at all times. After each
swipe settles, dayOffset is shifted and tabSelection is silently reset
to the middle page, preserving the native paging animation.
2026-02-23 10:09:51 +01:00
simon.franken
b971569983 feat(ios): replace week strip with native DatePicker and TabView paging 2026-02-23 10:02:24 +01: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%"> <?xml version="1.0" encoding="UTF-8"?>
<defs> <!-- Generated by Pixelmator Pro 4.0 -->
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%"> <svg width="416" height="416" viewBox="0 0 416 416" xmlns="http://www.w3.org/2000/svg">
<stop offset="0%" stop-color="#818CF8" /> <linearGradient id="linearGradient1" x1="0" y1="0" x2="416" y2="416" gradientUnits="userSpaceOnUse">
<stop offset="100%" stop-color="#4F46E5" /> <stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
</linearGradient> </linearGradient>
</defs> <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">
<!-- App Icon Background --> <path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 208 48 L 208 92"/>
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" /> <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"/>
<!-- Inner Icon Group --> <path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 314 90 L 342 118"/>
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round"> <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"/>
<!-- Stopwatch Top Button --> <path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 136 L 208 224"/>
<path d="M256 96 v44" stroke-width="28" /> <path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 224 L 256 256"/>
<path d="M224 88 h64" stroke-width="24" /> <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"/>
<!-- Stopwatch Side Button --> <path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 124 224 L 140 224"/>
<path d="M352 176 l 24 -24" stroke-width="24" /> <path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 292 224 L 276 224"/>
<!-- 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" />
</g> </g>
</g> </g>
</svg> </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%"> <?xml version="1.0" encoding="UTF-8"?>
<defs> <!-- Generated by Pixelmator Pro 4.0 -->
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%"> <svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<stop offset="0%" stop-color="#818CF8" /> <linearGradient id="linearGradient1" x1="48" y1="48" x2="464" y2="464" gradientUnits="userSpaceOnUse">
<stop offset="100%" stop-color="#4F46E5" /> <stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
</linearGradient> </linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%"> <filter id="filter1" x="0" y="0" width="512" height="512" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#4F46E5" flood-opacity="0.4" /> <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> </filter>
</defs> <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">
<!-- App Icon Background --> <path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 256 96 L 256 140"/>
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" filter="url(#shadow)" /> <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"/>
<!-- Inner Icon Group --> <path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 362 138 L 390 166"/>
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round"> <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"/>
<!-- Stopwatch Top Button --> <path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 184 L 256 272"/>
<path d="M256 96 v44" stroke-width="28" /> <path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 272 L 304 304"/>
<path d="M224 88 h64" stroke-width="24" /> <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"/>
<!-- Stopwatch Side Button --> <path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 172 272 L 188 272"/>
<path d="M352 176 l 24 -24" stroke-width="24" /> <path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 340 272 L 324 272"/>
<!-- 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" />
</g> </g>
</g> </g>
</svg> </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 { 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 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 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 +29,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,32 +74,64 @@ 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) { // Only 3 pages exist at any time: previous, current, next.
WeekStripView( // After each swipe settles, we reset tabSelection to 1 and shift
weekDays: visibleWeekDays, // dayOffset, so the carousel appears infinite while staying cheap.
selectedDay: $selectedDay, TabView(selection: $tabSelection) {
daysWithEntries: viewModel.daysWithEntries, ForEach(0..<3, id: \.self) { page in
onSwipeLeft: { let offset = dayOffset + (page - 1)
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: visibleWeekStart) ?? visibleWeekStart let day = Calendar.current.date(
}, byAdding: .day,
onSwipeRight: { value: offset,
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: visibleWeekStart) ?? visibleWeekStart to: Calendar.current.startOfDay(for: Date())
} ) ?? Date()
)
Divider()
ScrollView { ScrollView {
if let day = selectedDay {
dayEntriesSection(for: day) 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 { 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 +181,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 +190,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 +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 // MARK: - Filter Sheet

View File

@@ -1,6 +1,29 @@
{ {
"images" : [ "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", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB