From ba4765b8a2c884252ef4252c76ed5a63a5b6ee35 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Sat, 21 Feb 2026 13:51:41 +0100 Subject: [PATCH 1/4] Rebuild iOS app: calendar entries, overtime dashboard, settings tab, full CRUD - Replace 5-tab layout with 4 tabs: Dashboard, Timer, Entries, Settings - Dashboard: add Work Time Balance section using /client-targets API, showing per-client weekly progress bar, overtime/undertime label and expandable week breakdown - Time Entries: replace flat list with UICalendarView month grid; tap a day to see that day's entries; add filter sheet (date range, project, client); new TimeEntryDetailSheet for creating and editing entries; duration shown as Xh Ymin - Settings tab: user info header, navigation to Clients and Projects, logout button - ClientsListView: list with NavigationLink to ClientDetailView - ClientDetailView: inline client editing + full work time target CRUD (create, edit, delete target; add/delete balance corrections with date, hours, description) - ProjectsListView: grouped by client, NavigationLink to ProjectDetailView - ProjectDetailView: edit name, description, colour, client assignment - Add ClientTarget, WeekBalance, BalanceCorrection models and APIEndpoints for /client-targets routes - Update TimeInterval formatter: add formattedShortDuration (Xh Ymin / Xmin / <1min) used throughout app; keep formattedDuration for live timer display --- .../Core/Network/APIEndpoints.swift | 8 + .../Features/Clients/ClientsView.swift | 137 +--- .../Features/Dashboard/DashboardView.swift | 307 ++++++--- .../Dashboard/DashboardViewModel.swift | 48 +- .../Features/Projects/ProjectsView.swift | 181 +----- .../Features/Settings/ClientsListView.swift | 608 ++++++++++++++++++ .../Features/Settings/ProjectsListView.swift | 335 ++++++++++ .../Features/Settings/SettingsView.swift | 74 +++ .../TimeEntries/TimeEntriesView.swift | 479 +++++++++++--- .../TimeEntries/TimeEntriesViewModel.swift | 171 +++-- .../TimeEntries/TimeEntryDetailSheet.swift | 212 ++++++ .../TimeEntries/TimeEntryFormView.swift | 173 +---- .../TimeTracker/Models/ClientTarget.swift | 62 ++ .../Shared/Components/StatCard.swift | 30 +- .../TimeTrackerApp/TimeTrackerApp.swift | 32 +- 15 files changed, 2116 insertions(+), 741 deletions(-) create mode 100644 ios/TimeTracker/TimeTracker/Features/Settings/ClientsListView.swift create mode 100644 ios/TimeTracker/TimeTracker/Features/Settings/ProjectsListView.swift create mode 100644 ios/TimeTracker/TimeTracker/Features/Settings/SettingsView.swift create mode 100644 ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryDetailSheet.swift create mode 100644 ios/TimeTracker/TimeTracker/Models/ClientTarget.swift diff --git a/ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift b/ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift index accdc49..9368c2f 100644 --- a/ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift +++ b/ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift @@ -25,6 +25,14 @@ enum APIEndpoint { static let timer = "/timer" static let timerStart = "/timer/start" static let timerStop = "/timer/stop" + + // Client Targets + static let clientTargets = "/client-targets" + static func clientTarget(id: String) -> String { "/client-targets/\(id)" } + static func clientTargetCorrections(targetId: String) -> String { "/client-targets/\(targetId)/corrections" } + static func clientTargetCorrection(targetId: String, correctionId: String) -> String { + "/client-targets/\(targetId)/corrections/\(correctionId)" + } } struct APIEndpoints { diff --git a/ios/TimeTracker/TimeTracker/Features/Clients/ClientsView.swift b/ios/TimeTracker/TimeTracker/Features/Clients/ClientsView.swift index 9480d3f..aa21080 100644 --- a/ios/TimeTracker/TimeTracker/Features/Clients/ClientsView.swift +++ b/ios/TimeTracker/TimeTracker/Features/Clients/ClientsView.swift @@ -1,134 +1,3 @@ -import SwiftUI - -struct ClientsView: View { - @StateObject private var viewModel = ClientsViewModel() - @State private var showAddClient = false - @State private var clientToDelete: Client? - @State private var showDeleteConfirmation = false - - var body: some View { - NavigationStack { - Group { - if viewModel.isLoading && viewModel.clients.isEmpty { - LoadingView() - } else if let error = viewModel.error, viewModel.clients.isEmpty { - ErrorView(message: error) { - Task { await viewModel.loadClients() } - } - } else if viewModel.clients.isEmpty { - EmptyView( - icon: "person.2", - title: "No Clients", - message: "Create a client to organize your projects." - ) - } else { - clientsList - } - } - .navigationTitle("Clients") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - showAddClient = true - } label: { - Image(systemName: "plus") - } - } - } - .task { - await viewModel.loadClients() - } - .sheet(isPresented: $showAddClient) { - ClientFormView(onSave: { name, description in - Task { - await viewModel.createClient(name: name, description: description) - } - }) - } - } - } - - private var clientsList: some View { - List { - ForEach(viewModel.clients) { client in - ClientRow(client: client) - } - .onDelete { indexSet in - if let index = indexSet.first { - clientToDelete = viewModel.clients[index] - showDeleteConfirmation = true - } - } - } - .alert("Delete Client?", isPresented: $showDeleteConfirmation, presenting: clientToDelete) { client in - Button("Cancel", role: .cancel) {} - Button("Delete", role: .destructive) { - Task { - await viewModel.deleteClient(client) - } - } - } message: { client in - Text("This will permanently delete '\(client.name)' and all related projects and time entries. This action cannot be undone.") - } - .refreshable { - await viewModel.loadClients() - } - } -} - -struct ClientRow: View { - let client: Client - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(client.name) - .font(.headline) - if let description = client.description { - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - } -} - -struct ClientFormView: View { - @Environment(\.dismiss) private var dismiss - - let onSave: (String, String?) -> Void - - @State private var name = "" - @State private var description = "" - - var body: some View { - NavigationStack { - Form { - Section("Name") { - TextField("Client name", text: $name) - } - - Section("Description (Optional)") { - TextField("Description", text: $description, axis: .vertical) - .lineLimit(3...6) - } - } - .navigationTitle("New Client") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - onSave(name, description.isEmpty ? nil : description) - dismiss() - } - .disabled(name.isEmpty) - } - } - } - } -} +// ClientsView.swift — replaced by Features/Settings/ClientsListView.swift +// This file is intentionally empty; ClientsViewModel is no longer used directly. +import Foundation diff --git a/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardView.swift b/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardView.swift index 3ea5e63..f0dd8b5 100644 --- a/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardView.swift +++ b/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardView.swift @@ -2,94 +2,82 @@ import SwiftUI struct DashboardView: View { @StateObject private var viewModel = DashboardViewModel() - + var body: some View { NavigationStack { - ScrollView { - VStack(spacing: 24) { - // Active Timer Card - timerCard - - // Weekly Stats - if let stats = viewModel.statistics { - statsSection(stats) - } - - // Recent Entries - recentEntriesSection + Group { + if viewModel.isLoading && viewModel.statistics == nil && viewModel.recentEntries.isEmpty { + LoadingView() + } else { + scrollContent } - .padding() } .navigationTitle("Dashboard") - .refreshable { - await viewModel.loadData() - } - .task { - await viewModel.loadData() - } + .refreshable { await viewModel.loadData() } + .task { await viewModel.loadData() } } } - + + // MARK: - Main scroll content + + private var scrollContent: some View { + ScrollView { + VStack(spacing: 24) { + timerCard + if let stats = viewModel.statistics { weeklyStatsSection(stats) } + if !viewModel.clientTargets.isEmpty { workBalanceSection } + recentEntriesSection + } + .padding() + } + } + + // MARK: - Active Timer Card + private var timerCard: some View { - VStack(spacing: 16) { - if let timer = viewModel.activeTimer { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Timer Running") - .font(.subheadline) - .foregroundStyle(.secondary) - - Text(viewModel.elapsedTime.formattedDuration) - .font(.system(size: 32, weight: .medium, design: .monospaced)) - - if let project = timer.project { - ProjectColorBadge(color: project.color, name: project.name) - } - } - - Spacer() - - Image(systemName: "timer") - .font(.title) - .foregroundStyle(.green) - } - } else { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("No Active Timer") - .font(.subheadline) - .foregroundStyle(.secondary) - - Text("Start tracking to see your time") - .font(.headline) - } - - Spacer() - - Image(systemName: "timer") - .font(.title) + HStack { + VStack(alignment: .leading, spacing: 4) { + if let timer = viewModel.activeTimer { + Text("Timer Running") + .font(.subheadline) .foregroundStyle(.secondary) + Text(viewModel.elapsedTime.formattedDuration) + .font(.system(size: 32, weight: .medium, design: .monospaced)) + if let project = timer.project { + ProjectColorBadge(color: project.color, name: project.name) + } + } else { + Text("No Active Timer") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Start tracking to see your time") + .font(.headline) } } + Spacer() + Image(systemName: "timer") + .font(.title) + .foregroundStyle(viewModel.activeTimer != nil ? .green : .secondary) } .padding() .background(Color(.systemGray6)) .cornerRadius(12) } - - private func statsSection(_ stats: TimeStatistics) -> some View { + + // MARK: - Weekly Stats + + private func weeklyStatsSection(_ stats: TimeStatistics) -> some View { VStack(alignment: .leading, spacing: 12) { Text("This Week") .font(.headline) - + HStack(spacing: 12) { StatCard( title: "Hours Tracked", - value: TimeInterval(stats.totalSeconds).formattedHours, + value: TimeInterval(stats.totalSeconds).formattedShortDuration, icon: "clock.fill", color: .blue ) - StatCard( title: "Entries", value: "\(stats.entryCount)", @@ -97,13 +85,12 @@ struct DashboardView: View { color: .green ) } - + if !stats.byProject.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("By Project") .font(.subheadline) .foregroundStyle(.secondary) - ForEach(stats.byProject.prefix(5)) { projectStat in HStack { if let color = projectStat.projectColor { @@ -112,21 +99,39 @@ struct DashboardView: View { Text(projectStat.projectName) .font(.subheadline) Spacer() - Text(TimeInterval(projectStat.totalSeconds).formattedHours) + Text(TimeInterval(projectStat.totalSeconds).formattedShortDuration) .font(.subheadline) .foregroundStyle(.secondary) } } } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) } } } - + + // MARK: - Work Balance Section + + private var workBalanceSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Work Time Balance") + .font(.headline) + + ForEach(viewModel.clientTargets) { target in + WorkBalanceCard(target: target) + } + } + } + + // MARK: - Recent Entries + private var recentEntriesSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Recent Entries") .font(.headline) - + if viewModel.recentEntries.isEmpty { Text("No entries yet") .font(.subheadline) @@ -134,22 +139,27 @@ struct DashboardView: View { .frame(maxWidth: .infinity, alignment: .center) .padding() } else { - ForEach(viewModel.recentEntries) { entry in - HStack { - ProjectColorDot(color: entry.project.color, size: 10) - VStack(alignment: .leading, spacing: 2) { - Text(entry.project.name) + VStack(spacing: 0) { + ForEach(Array(viewModel.recentEntries.enumerated()), id: \.element.id) { index, entry in + HStack { + ProjectColorDot(color: entry.project.color, size: 10) + VStack(alignment: .leading, spacing: 2) { + Text(entry.project.name) + .font(.subheadline) + Text(formatDate(entry.startTime)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(entry.duration.formattedShortDuration) .font(.subheadline) - Text(formatDate(entry.startTime)) - .font(.caption) .foregroundStyle(.secondary) } - Spacer() - Text(entry.duration.formattedHours) - .font(.subheadline) - .foregroundStyle(.secondary) + .padding(.vertical, 8) + if index < viewModel.recentEntries.count - 1 { + Divider() + } } - .padding(.vertical, 4) } } } @@ -157,9 +167,144 @@ struct DashboardView: View { .background(Color(.systemGray6)) .cornerRadius(12) } - + private func formatDate(_ isoString: String) -> String { guard let date = Date.fromISO8601(isoString) else { return "" } return date.formattedDateTime() } } + +// MARK: - Work Balance Card + +struct WorkBalanceCard: View { + let target: ClientTarget + @State private var expanded = false + + private var totalBalance: TimeInterval { TimeInterval(target.totalBalanceSeconds) } + private var currentWeekTracked: TimeInterval { TimeInterval(target.currentWeekTrackedSeconds) } + private var currentWeekTarget: TimeInterval { TimeInterval(target.currentWeekTargetSeconds) } + + private var balanceColor: Color { + if target.totalBalanceSeconds >= 0 { return .green } + return .red + } + + private var balanceLabel: String { + let abs = abs(totalBalance) + return target.totalBalanceSeconds >= 0 + ? "+\(abs.formattedShortDuration) overtime" + : "-\(abs.formattedShortDuration) undertime" + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header row + HStack { + Text(target.clientName) + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + Text(balanceLabel) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(balanceColor) + } + + // This-week progress + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("This week") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text("\(currentWeekTracked.formattedShortDuration) / \(currentWeekTarget.formattedShortDuration)") + .font(.caption) + .foregroundStyle(.secondary) + } + if currentWeekTarget > 0 { + ProgressView(value: min(currentWeekTracked / currentWeekTarget, 1.0)) + .tint(currentWeekTracked >= currentWeekTarget ? .green : .blue) + } + } + + // Weekly breakdown (expandable) + if !target.weeks.isEmpty { + Button { + withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() } + } label: { + HStack(spacing: 4) { + Text(expanded ? "Hide weeks" : "Show weeks") + .font(.caption) + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(.caption2) + } + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + if expanded { + VStack(spacing: 6) { + ForEach(target.weeks.suffix(8).reversed()) { week in + WeekBalanceRow(week: week) + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + } +} + +// MARK: - Week Balance Row + +struct WeekBalanceRow: View { + let week: WeekBalance + + private var balance: TimeInterval { TimeInterval(week.balanceSeconds) } + private var tracked: TimeInterval { TimeInterval(week.trackedSeconds) } + private var target: TimeInterval { TimeInterval(week.targetSeconds) } + private var balanceColor: Color { week.balanceSeconds >= 0 ? .green : .red } + + var body: some View { + HStack { + Text(weekLabel) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text(tracked.formattedShortDuration) + .font(.caption) + Text("/") + .font(.caption) + .foregroundStyle(.secondary) + Text(target.formattedShortDuration) + .font(.caption) + .foregroundStyle(.secondary) + Text(balanceText) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(balanceColor) + .frame(width: 70, alignment: .trailing) + } + } + + private var weekLabel: String { + guard let date = parseDate(week.weekStart) else { return week.weekStart } + let formatter = DateFormatter() + formatter.dateFormat = "MMM d" + return formatter.string(from: date) + } + + private var balanceText: String { + let abs = Swift.abs(balance) + return week.balanceSeconds >= 0 ? "+\(abs.formattedShortDuration)" : "-\(abs.formattedShortDuration)" + } + + private func parseDate(_ string: String) -> Date? { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f.date(from: string) + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardViewModel.swift b/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardViewModel.swift index e4dd3e7..e973fb7 100644 --- a/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardViewModel.swift +++ b/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardViewModel.swift @@ -6,40 +6,42 @@ final class DashboardViewModel: ObservableObject { @Published var activeTimer: OngoingTimer? @Published var statistics: TimeStatistics? @Published var recentEntries: [TimeEntry] = [] + @Published var clientTargets: [ClientTarget] = [] @Published var isLoading = false @Published var error: String? @Published var elapsedTime: TimeInterval = 0 - + private let apiClient = APIClient() private let database = DatabaseService.shared private var timerTask: Task? - + init() { startElapsedTimeUpdater() } - + deinit { timerTask?.cancel() } - + func loadData() async { isLoading = true error = nil - + do { // Fetch active timer activeTimer = try await apiClient.request( endpoint: APIEndpoint.timer, authenticated: true ) - - // Get statistics for this week + + // Statistics for this week let calendar = Calendar.current let today = Date() - let startOfWeek = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))! + let startOfWeek = calendar.date( + from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today) + )! let endOfWeek = calendar.date(byAdding: .day, value: 6, to: startOfWeek)! - - let statsInput = StatisticsFiltersInput(startDate: startOfWeek, endDate: endOfWeek) + statistics = try await apiClient.request( endpoint: APIEndpoint.timeEntriesStatistics, queryItems: [ @@ -48,41 +50,43 @@ final class DashboardViewModel: ObservableObject { ], authenticated: true ) - - // Fetch recent entries + + // Recent entries (last 5) let entriesResponse: TimeEntryListResponse = try await apiClient.request( endpoint: APIEndpoint.timeEntries, - queryItems: [ - URLQueryItem(name: "limit", value: "5") - ], + queryItems: [URLQueryItem(name: "limit", value: "5")], authenticated: true ) recentEntries = entriesResponse.entries - + + // Client targets (for overtime/undertime) + clientTargets = try await apiClient.request( + endpoint: APIEndpoint.clientTargets, + authenticated: true + ) + if let timer = activeTimer { elapsedTime = timer.elapsedTime } - + isLoading = false } catch { isLoading = false self.error = error.localizedDescription - - // Try to load cached data + + // Fallback to cached timer if let cachedTimer = try? await database.getCachedTimer() { activeTimer = cachedTimer elapsedTime = cachedTimer.elapsedTime } } } - + private func startElapsedTimeUpdater() { timerTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) - guard let self = self, self.activeTimer != nil else { continue } - await MainActor.run { self.elapsedTime = self.activeTimer?.elapsedTime ?? 0 } diff --git a/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsView.swift b/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsView.swift index 4c13d6b..fe3f032 100644 --- a/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsView.swift +++ b/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsView.swift @@ -1,178 +1,3 @@ -import SwiftUI - -struct ProjectsView: View { - @StateObject private var viewModel = ProjectsViewModel() - @State private var showAddProject = false - @State private var projectToDelete: Project? - @State private var showDeleteConfirmation = false - - var body: some View { - NavigationStack { - Group { - if viewModel.isLoading && viewModel.projects.isEmpty { - LoadingView() - } else if let error = viewModel.error, viewModel.projects.isEmpty { - ErrorView(message: error) { - Task { await viewModel.loadData() } - } - } else if viewModel.projects.isEmpty { - EmptyView( - icon: "folder", - title: "No Projects", - message: "Create a project to start tracking time." - ) - } else { - projectsList - } - } - .navigationTitle("Projects") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - showAddProject = true - } label: { - Image(systemName: "plus") - } - } - } - .task { - await viewModel.loadData() - } - .sheet(isPresented: $showAddProject) { - ProjectFormView( - clients: viewModel.clients, - onSave: { name, description, color, clientId in - Task { - await viewModel.createProject( - name: name, - description: description, - color: color, - clientId: clientId - ) - } - } - ) - } - } - } - - private var projectsList: some View { - List { - ForEach(viewModel.projects) { project in - ProjectRow(project: project) - } - .onDelete { indexSet in - if let index = indexSet.first { - projectToDelete = viewModel.projects[index] - showDeleteConfirmation = true - } - } - } - .alert("Delete Project?", isPresented: $showDeleteConfirmation, presenting: projectToDelete) { project in - Button("Cancel", role: .cancel) {} - Button("Delete", role: .destructive) { - Task { - await viewModel.deleteProject(project) - } - } - } message: { project in - Text("This will permanently delete '\(project.name)' and all related time entries. This action cannot be undone.") - } - .refreshable { - await viewModel.loadData() - } - } -} - -struct ProjectRow: View { - let project: Project - - var body: some View { - HStack { - ProjectColorDot(color: project.color, size: 16) - - VStack(alignment: .leading, spacing: 2) { - Text(project.name) - .font(.headline) - Text(project.client.name) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - } -} - -struct ProjectFormView: View { - @Environment(\.dismiss) private var dismiss - - let clients: [Client] - let onSave: (String, String?, String?, String) -> Void - - @State private var name = "" - @State private var description = "" - @State private var selectedColor: String = "#3B82F6" - @State private var selectedClient: Client? - - private let colors = ["#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6", - "#06B6D4", "#3B82F6", "#6366F1", "#A855F7", "#EC4899"] - - var body: some View { - NavigationStack { - Form { - Section("Name") { - TextField("Project name", text: $name) - } - - Section("Description (Optional)") { - TextField("Description", text: $description, axis: .vertical) - .lineLimit(3...6) - } - - Section("Color") { - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 16) { - ForEach(colors, id: \.self) { color in - Circle() - .fill(Color(hex: color)) - .frame(width: 44, height: 44) - .overlay( - Circle() - .strokeBorder(Color.primary, lineWidth: selectedColor == color ? 3 : 0) - ) - .onTapGesture { - selectedColor = color - } - } - } - .padding(.vertical, 8) - } - - Section("Client") { - Picker("Client", selection: $selectedClient) { - Text("Select Client").tag(nil as Client?) - ForEach(clients) { client in - Text(client.name) - .tag(client as Client?) - } - } - } - } - .navigationTitle("New Project") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - guard let client = selectedClient else { return } - onSave(name, description.isEmpty ? nil : description, selectedColor, client.id) - dismiss() - } - .disabled(name.isEmpty || selectedClient == nil) - } - } - } - } -} +// ProjectsView.swift — replaced by Features/Settings/ProjectsListView.swift +// This file is intentionally empty; ProjectsViewModel is no longer used directly. +import Foundation diff --git a/ios/TimeTracker/TimeTracker/Features/Settings/ClientsListView.swift b/ios/TimeTracker/TimeTracker/Features/Settings/ClientsListView.swift new file mode 100644 index 0000000..9d2a9fc --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/Settings/ClientsListView.swift @@ -0,0 +1,608 @@ +import SwiftUI + +// MARK: - Clients List + +struct ClientsListView: View { + @State private var clients: [Client] = [] + @State private var isLoading = false + @State private var error: String? + @State private var showAddClient = false + @State private var clientToDelete: Client? + @State private var showDeleteConfirmation = false + + private let apiClient = APIClient() + + var body: some View { + Group { + if isLoading && clients.isEmpty { + LoadingView() + } else if let err = error, clients.isEmpty { + ErrorView(message: err) { Task { await loadClients() } } + } else if clients.isEmpty { + EmptyView(icon: "person.2", title: "No Clients", + message: "Create a client to organise your projects.") + } else { + List { + ForEach(clients) { client in + NavigationLink { + ClientDetailView(client: client, onUpdate: { Task { await loadClients() } }) + } label: { + ClientRow(client: client) + } + } + .onDelete { indexSet in + if let i = indexSet.first { + clientToDelete = clients[i] + showDeleteConfirmation = true + } + } + } + .refreshable { await loadClients() } + } + } + .navigationTitle("Clients") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showAddClient = true } label: { Image(systemName: "plus") } + } + } + .task { await loadClients() } + .sheet(isPresented: $showAddClient) { + ClientFormSheet(mode: .create) { Task { await loadClients() } } + } + .alert("Delete Client?", isPresented: $showDeleteConfirmation, presenting: clientToDelete) { client in + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { Task { await deleteClient(client) } } + } message: { client in + Text("Deleting '\(client.name)' will also delete all its projects and time entries. This cannot be undone.") + } + } + + private func loadClients() async { + isLoading = true; error = nil + do { + clients = try await apiClient.request(endpoint: APIEndpoint.clients, authenticated: true) + } catch { self.error = error.localizedDescription } + isLoading = false + } + + private func deleteClient(_ client: Client) async { + do { + try await apiClient.requestVoid(endpoint: APIEndpoint.client(id: client.id), + method: .delete, authenticated: true) + clients.removeAll { $0.id == client.id } + } catch { self.error = error.localizedDescription } + } +} + +struct ClientRow: View { + let client: Client + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(client.name).font(.headline) + if let desc = client.description { + Text(desc).font(.subheadline).foregroundStyle(.secondary).lineLimit(2) + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Client Detail / Edit + Target Management + +struct ClientDetailView: View { + let client: Client + let onUpdate: () -> Void + + // Edit client fields + @State private var name: String + @State private var clientDescription: String + @State private var isSavingClient = false + @State private var clientSaveError: String? + @State private var clientSaveSuccess = false + + // Client targets + @State private var target: ClientTarget? + @State private var isLoadingTarget = false + @State private var targetError: String? + + // New target form + @State private var showNewTargetForm = false + @State private var newWeeklyHours = 40.0 + @State private var newStartDate = Date().nextMonday() + @State private var isSavingTarget = false + + // Edit target inline + @State private var editingWeeklyHours: Double? + @State private var editingStartDate: Date? + @State private var isEditingTarget = false + + // Balance correction + @State private var showAddCorrection = false + @State private var correctionDate = Date() + @State private var correctionHours = 0.0 + @State private var correctionDescription = "" + @State private var isSavingCorrection = false + @State private var correctionToDelete: BalanceCorrection? + @State private var showDeleteCorrection = false + + private let apiClient = APIClient() + + init(client: Client, onUpdate: @escaping () -> Void) { + self.client = client + self.onUpdate = onUpdate + _name = State(initialValue: client.name) + _clientDescription = State(initialValue: client.description ?? "") + } + + var body: some View { + Form { + clientEditSection + targetSection + if let target { correctionsSection(target) } + } + .navigationTitle(client.name) + .navigationBarTitleDisplayMode(.inline) + .task { await loadTarget() } + .sheet(isPresented: $showAddCorrection) { + addCorrectionSheet + } + } + + // MARK: - Client edit + + private var clientEditSection: some View { + Section("Client Details") { + TextField("Name", text: $name) + TextField("Description (optional)", text: $clientDescription, axis: .vertical) + .lineLimit(2...4) + + if let err = clientSaveError { + Text(err).font(.caption).foregroundStyle(.red) + } + if clientSaveSuccess { + Label("Saved", systemImage: "checkmark.circle").foregroundStyle(.green).font(.caption) + } + + Button(isSavingClient ? "Saving…" : "Save Client Details") { + Task { await saveClient() } + } + .disabled(name.isEmpty || isSavingClient) + } + } + + private func saveClient() async { + isSavingClient = true; clientSaveError = nil; clientSaveSuccess = false + do { + let input = UpdateClientInput( + name: name, + description: clientDescription.isEmpty ? nil : clientDescription + ) + let _: Client = try await apiClient.request( + endpoint: APIEndpoint.client(id: client.id), + method: .put, + body: input, + authenticated: true + ) + clientSaveSuccess = true + onUpdate() + } catch { clientSaveError = error.localizedDescription } + isSavingClient = false + } + + // MARK: - Target section + + private var targetSection: some View { + Section { + if isLoadingTarget { + HStack { Spacer(); ProgressView(); Spacer() } + } else if let err = targetError { + Text(err).font(.caption).foregroundStyle(.red) + } else if let target { + // Show existing target + balance + targetSummaryRows(target) + if isEditingTarget { + targetEditRows(target) + } else { + Button("Edit Target") { startEditingTarget(target) } + } + } else { + // No target yet + if showNewTargetForm { + newTargetFormRows + } else { + Button("Set Up Work Time Target") { showNewTargetForm = true } + } + } + } header: { + Text("Work Time Target") + } + } + + private func targetSummaryRows(_ t: ClientTarget) -> some View { + Group { + HStack { + Text("Weekly hours") + Spacer() + Text("\(t.weeklyHours, specifier: "%.1f") h/week") + .foregroundStyle(.secondary) + } + HStack { + Text("Tracking since") + Spacer() + Text(formatDate(t.startDate)) + .foregroundStyle(.secondary) + } + HStack { + Text("This week") + Spacer() + Text("\(TimeInterval(t.currentWeekTrackedSeconds).formattedShortDuration) / \(TimeInterval(t.currentWeekTargetSeconds).formattedShortDuration)") + .foregroundStyle(.secondary) + } + HStack { + Text("Total balance") + Spacer() + let balance = TimeInterval(abs(t.totalBalanceSeconds)) + Text(t.totalBalanceSeconds >= 0 ? "+\(balance.formattedShortDuration)" : "-\(balance.formattedShortDuration)") + .fontWeight(.medium) + .foregroundStyle(t.totalBalanceSeconds >= 0 ? .green : .red) + } + } + } + + private func targetEditRows(_ t: ClientTarget) -> some View { + Group { + HStack { + Text("Weekly hours") + Spacer() + TextField("Hours", value: Binding( + get: { editingWeeklyHours ?? t.weeklyHours }, + set: { editingWeeklyHours = $0 } + ), format: .number) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 80) + } + + DatePicker("Start date (Monday)", + selection: Binding( + get: { editingStartDate ?? parseDate(t.startDate) ?? Date() }, + set: { editingStartDate = $0 } + ), + displayedComponents: .date) + + HStack { + Button("Cancel") { isEditingTarget = false; editingWeeklyHours = nil; editingStartDate = nil } + .foregroundStyle(.secondary) + Spacer() + Button(isSavingTarget ? "Saving…" : "Save Target") { + Task { await saveTarget(existingId: t.id) } + } + .disabled(isSavingTarget) + } + } + } + + private var newTargetFormRows: some View { + Group { + HStack { + Text("Weekly hours") + Spacer() + TextField("Hours", value: $newWeeklyHours, format: .number) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 80) + } + DatePicker("Start date (Monday)", selection: $newStartDate, displayedComponents: .date) + + HStack { + Button("Cancel") { showNewTargetForm = false } + .foregroundStyle(.secondary) + Spacer() + Button(isSavingTarget ? "Saving…" : "Create Target") { + Task { await createTarget() } + } + .disabled(newWeeklyHours <= 0 || isSavingTarget) + } + } + } + + private func startEditingTarget(_ t: ClientTarget) { + editingWeeklyHours = t.weeklyHours + editingStartDate = parseDate(t.startDate) + isEditingTarget = true + } + + // MARK: - Corrections section + + private func correctionsSection(_ t: ClientTarget) -> some View { + Section { + if t.corrections.isEmpty { + Text("No corrections") + .foregroundStyle(.secondary) + .font(.subheadline) + } else { + ForEach(t.corrections) { correction in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(formatDate(correction.date)) + .font(.subheadline) + if let desc = correction.description { + Text(desc).font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + Text(correction.hours >= 0 ? "+\(correction.hours, specifier: "%.1f")h" : "\(correction.hours, specifier: "%.1f")h") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(correction.hours >= 0 ? .green : .red) + } + } + .onDelete { indexSet in + if let i = indexSet.first { + correctionToDelete = t.corrections[i] + showDeleteCorrection = true + } + } + } + + Button("Add Correction") { showAddCorrection = true } + } header: { + Text("Balance Corrections") + } + .alert("Delete Correction?", isPresented: $showDeleteCorrection, presenting: correctionToDelete) { correction in + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + Task { await deleteCorrection(correction) } + } + } message: { correction in + Text("Remove the \(correction.hours >= 0 ? "+" : "")\(correction.hours, specifier: "%.1f")h correction on \(formatDate(correction.date))?") + } + } + + // MARK: - Add correction sheet + + private var addCorrectionSheet: some View { + NavigationStack { + Form { + Section("Date") { + DatePicker("Date", selection: $correctionDate, displayedComponents: .date) + } + Section("Hours adjustment") { + HStack { + TextField("Hours (positive = bonus, negative = penalty)", + value: $correctionHours, format: .number) + .keyboardType(.numbersAndPunctuation) + Text("h").foregroundStyle(.secondary) + } + Text("Positive values reduce the weekly target; negative values increase it.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Description (optional)") { + TextField("Note", text: $correctionDescription) + } + } + .navigationTitle("Add Correction") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showAddCorrection = false } + } + ToolbarItem(placement: .confirmationAction) { + Button(isSavingCorrection ? "Saving…" : "Add") { + Task { await addCorrection() } + } + .disabled(correctionHours == 0 || isSavingCorrection) + } + } + } + } + + // MARK: - API calls + + private func loadTarget() async { + isLoadingTarget = true; targetError = nil + do { + let allTargets: [ClientTarget] = try await apiClient.request( + endpoint: APIEndpoint.clientTargets, authenticated: true + ) + target = allTargets.first { $0.clientId == client.id } + } catch { targetError = error.localizedDescription } + isLoadingTarget = false + } + + private func createTarget() async { + isSavingTarget = true + do { + let input = CreateClientTargetInput( + clientId: client.id, + weeklyHours: newWeeklyHours, + startDate: newStartDate.iso8601FullDate + ) + let created: ClientTarget = try await apiClient.request( + endpoint: APIEndpoint.clientTargets, + method: .post, + body: input, + authenticated: true + ) + target = created + showNewTargetForm = false + } catch { targetError = error.localizedDescription } + isSavingTarget = false + } + + private func saveTarget(existingId: String) async { + isSavingTarget = true + do { + let input = UpdateClientTargetInput( + weeklyHours: editingWeeklyHours, + startDate: editingStartDate?.iso8601FullDate + ) + let _: ClientTarget = try await apiClient.request( + endpoint: APIEndpoint.clientTarget(id: existingId), + method: .put, + body: input, + authenticated: true + ) + isEditingTarget = false + editingWeeklyHours = nil + editingStartDate = nil + await loadTarget() // reload to get fresh balance + } catch { targetError = error.localizedDescription } + isSavingTarget = false + } + + private func addCorrection() async { + guard let t = target else { return } + isSavingCorrection = true + do { + let input = CreateBalanceCorrectionInput( + date: correctionDate.iso8601FullDate, + hours: correctionHours, + description: correctionDescription.isEmpty ? nil : correctionDescription + ) + try await apiClient.requestVoid( + endpoint: APIEndpoint.clientTargetCorrections(targetId: t.id), + method: .post, + body: input, + authenticated: true + ) + correctionHours = 0 + correctionDescription = "" + showAddCorrection = false + await loadTarget() + } catch { targetError = error.localizedDescription } + isSavingCorrection = false + } + + private func deleteCorrection(_ correction: BalanceCorrection) async { + guard let t = target else { return } + do { + try await apiClient.requestVoid( + endpoint: APIEndpoint.clientTargetCorrection(targetId: t.id, correctionId: correction.id), + method: .delete, + authenticated: true + ) + await loadTarget() + } catch { targetError = error.localizedDescription } + } + + // MARK: - Helpers + + private func formatDate(_ string: String) -> String { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + guard let d = f.date(from: string) else { return string } + let out = DateFormatter() + out.dateStyle = .medium + return out.string(from: d) + } + + private func parseDate(_ string: String) -> Date? { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f.date(from: string) + } +} + +// MARK: - Client Form Sheet (create / edit) + +struct ClientFormSheet: View { + enum Mode { + case create + case edit(Client) + } + + @Environment(\.dismiss) private var dismiss + + let mode: Mode + let onSave: () -> Void + + @State private var name = "" + @State private var description = "" + @State private var isSaving = false + @State private var error: String? + + private let apiClient = APIClient() + + init(mode: Mode, onSave: @escaping () -> Void) { + self.mode = mode + self.onSave = onSave + if case .edit(let client) = mode { + _name = State(initialValue: client.name) + _description = State(initialValue: client.description ?? "") + } + } + + var body: some View { + NavigationStack { + Form { + Section("Name") { + TextField("Client name", text: $name) + } + Section("Description (optional)") { + TextField("Description", text: $description, axis: .vertical) + .lineLimit(3...6) + } + if let error { + Section { Text(error).font(.caption).foregroundStyle(.red) } + } + } + .navigationTitle(isEditing ? "Edit Client" : "New Client") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Saving…" : "Save") { + Task { await save() } + } + .disabled(name.isEmpty || isSaving) + } + } + } + } + + private var isEditing: Bool { + if case .edit = mode { return true } + return false + } + + private func save() async { + isSaving = true; error = nil + do { + switch mode { + case .create: + let input = CreateClientInput(name: name, description: description.isEmpty ? nil : description) + let _: Client = try await apiClient.request( + endpoint: APIEndpoint.clients, method: .post, body: input, authenticated: true + ) + case .edit(let client): + let input = UpdateClientInput(name: name, description: description.isEmpty ? nil : description) + let _: Client = try await apiClient.request( + endpoint: APIEndpoint.client(id: client.id), method: .put, body: input, authenticated: true + ) + } + isSaving = false + dismiss() + onSave() + } catch { + isSaving = false + self.error = error.localizedDescription + } + } +} + +// MARK: - Date extension + +private extension Date { + func nextMonday() -> Date { + let cal = Calendar.current + var comps = DateComponents() + comps.weekday = 2 // Monday + return cal.nextDate(after: self, matching: comps, matchingPolicy: .nextTime) ?? self + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/Settings/ProjectsListView.swift b/ios/TimeTracker/TimeTracker/Features/Settings/ProjectsListView.swift new file mode 100644 index 0000000..c3fb70f --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/Settings/ProjectsListView.swift @@ -0,0 +1,335 @@ +import SwiftUI + +// MARK: - Projects List (under Settings) + +struct ProjectsListView: View { + @State private var projects: [Project] = [] + @State private var clients: [Client] = [] + @State private var isLoading = false + @State private var error: String? + @State private var showAddProject = false + @State private var projectToDelete: Project? + @State private var showDeleteConfirmation = false + + private let apiClient = APIClient() + + var body: some View { + Group { + if isLoading && projects.isEmpty { + LoadingView() + } else if let err = error, projects.isEmpty { + ErrorView(message: err) { Task { await loadData() } } + } else if projects.isEmpty { + EmptyView(icon: "folder", title: "No Projects", + message: "Create a project to start tracking time.") + } else { + projectList + } + } + .navigationTitle("Projects") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showAddProject = true } label: { Image(systemName: "plus") } + } + } + .task { await loadData() } + .sheet(isPresented: $showAddProject) { + ProjectFormSheet(mode: .create, clients: clients) { + Task { await loadData() } + } + } + .alert("Delete Project?", isPresented: $showDeleteConfirmation, presenting: projectToDelete) { project in + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { Task { await deleteProject(project) } } + } message: { project in + Text("Deleting '\(project.name)' will also delete all its time entries. This cannot be undone.") + } + } + + // Group projects by client + private var projectsByClient: [(clientName: String, projects: [Project])] { + let grouped = Dictionary(grouping: projects) { $0.client.name } + return grouped.sorted { $0.key < $1.key } + .map { (clientName: $0.key, projects: $0.value.sorted { $0.name < $1.name }) } + } + + private var projectList: some View { + List { + ForEach(projectsByClient, id: \.clientName) { group in + Section(group.clientName) { + ForEach(group.projects) { project in + NavigationLink { + ProjectDetailView(project: project, clients: clients) { + Task { await loadData() } + } + } label: { + ProjectListRow(project: project) + } + } + .onDelete { indexSet in + let deleteTargets = indexSet.map { group.projects[$0] } + if let first = deleteTargets.first { + projectToDelete = first + showDeleteConfirmation = true + } + } + } + } + } + .refreshable { await loadData() } + } + + private func loadData() async { + isLoading = true; error = nil + do { + async let fetchProjects: [Project] = apiClient.request(endpoint: APIEndpoint.projects, authenticated: true) + async let fetchClients: [Client] = apiClient.request(endpoint: APIEndpoint.clients, authenticated: true) + projects = try await fetchProjects + clients = try await fetchClients + } catch { self.error = error.localizedDescription } + isLoading = false + } + + private func deleteProject(_ project: Project) async { + do { + try await apiClient.requestVoid(endpoint: APIEndpoint.project(id: project.id), + method: .delete, authenticated: true) + projects.removeAll { $0.id == project.id } + } catch { self.error = error.localizedDescription } + } +} + +// MARK: - Project list row + +struct ProjectListRow: View { + let project: Project + var body: some View { + HStack(spacing: 12) { + ProjectColorDot(color: project.color, size: 14) + VStack(alignment: .leading, spacing: 2) { + Text(project.name).font(.headline) + if let desc = project.description { + Text(desc).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Project Detail / Edit + +struct ProjectDetailView: View { + let project: Project + let clients: [Client] + let onUpdate: () -> Void + + @State private var name: String + @State private var projectDescription: String + @State private var selectedColor: String + @State private var selectedClient: Client? + @State private var isSaving = false + @State private var saveError: String? + @State private var saveSuccess = false + + private let colorPalette = ["#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6", + "#06B6D4", "#3B82F6", "#6366F1", "#A855F7", "#EC4899"] + private let apiClient = APIClient() + + init(project: Project, clients: [Client], onUpdate: @escaping () -> Void) { + self.project = project + self.clients = clients + self.onUpdate = onUpdate + _name = State(initialValue: project.name) + _projectDescription = State(initialValue: project.description ?? "") + _selectedColor = State(initialValue: project.color ?? "#3B82F6") + _selectedClient = State(initialValue: clients.first { $0.id == project.clientId }) + } + + var body: some View { + Form { + Section("Name") { + TextField("Project name", text: $name) + } + + Section("Description (optional)") { + TextField("Description", text: $projectDescription, axis: .vertical) + .lineLimit(2...5) + } + + Section("Colour") { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 16) { + ForEach(colorPalette, id: \.self) { color in + Circle() + .fill(Color(hex: color)) + .frame(width: 44, height: 44) + .overlay( + Circle().strokeBorder( + Color.primary, + lineWidth: selectedColor == color ? 3 : 0 + ) + ) + .onTapGesture { selectedColor = color } + } + } + .padding(.vertical, 8) + } + + Section("Client") { + Picker("Client", selection: $selectedClient) { + Text("Select Client").tag(nil as Client?) + ForEach(clients) { client in + Text(client.name).tag(client as Client?) + } + } + .pickerStyle(.navigationLink) + } + + if let err = saveError { + Section { Text(err).font(.caption).foregroundStyle(.red) } + } + if saveSuccess { + Section { Label("Saved", systemImage: "checkmark.circle").foregroundStyle(.green) } + } + + Section { + Button(isSaving ? "Saving…" : "Save Project") { + Task { await save() } + } + .disabled(name.isEmpty || selectedClient == nil || isSaving) + } + } + .navigationTitle(project.name) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + // Ensure selectedClient resolves correctly once clients are available + if selectedClient == nil { + selectedClient = clients.first { $0.id == project.clientId } + } + } + } + + private func save() async { + guard let client = selectedClient else { return } + isSaving = true; saveError = nil; saveSuccess = false + do { + let input = UpdateProjectInput( + name: name, + description: projectDescription.isEmpty ? nil : projectDescription, + color: selectedColor, + clientId: client.id + ) + let _: Project = try await apiClient.request( + endpoint: APIEndpoint.project(id: project.id), + method: .put, + body: input, + authenticated: true + ) + saveSuccess = true + onUpdate() + } catch { saveError = error.localizedDescription } + isSaving = false + } +} + +// MARK: - Project Form Sheet (create) + +struct ProjectFormSheet: View { + enum Mode { case create } + + @Environment(\.dismiss) private var dismiss + + let mode: Mode + let clients: [Client] + let onSave: () -> Void + + @State private var name = "" + @State private var description = "" + @State private var selectedColor = "#3B82F6" + @State private var selectedClient: Client? + @State private var isSaving = false + @State private var error: String? + + private let colorPalette = ["#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6", + "#06B6D4", "#3B82F6", "#6366F1", "#A855F7", "#EC4899"] + private let apiClient = APIClient() + + var body: some View { + NavigationStack { + Form { + Section("Name") { + TextField("Project name", text: $name) + } + Section("Description (optional)") { + TextField("Description", text: $description, axis: .vertical) + .lineLimit(2...5) + } + Section("Colour") { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 16) { + ForEach(colorPalette, id: \.self) { color in + Circle() + .fill(Color(hex: color)) + .frame(width: 44, height: 44) + .overlay( + Circle().strokeBorder( + Color.primary, + lineWidth: selectedColor == color ? 3 : 0 + ) + ) + .onTapGesture { selectedColor = color } + } + } + .padding(.vertical, 8) + } + Section("Client") { + Picker("Client", selection: $selectedClient) { + Text("Select Client").tag(nil as Client?) + ForEach(clients) { client in + Text(client.name).tag(client as Client?) + } + } + .pickerStyle(.navigationLink) + } + if let error { + Section { Text(error).font(.caption).foregroundStyle(.red) } + } + } + .navigationTitle("New Project") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Saving…" : "Save") { + Task { await save() } + } + .disabled(name.isEmpty || selectedClient == nil || isSaving) + } + } + } + } + + private func save() async { + guard let client = selectedClient else { return } + isSaving = true; error = nil + do { + let input = CreateProjectInput( + name: name, + description: description.isEmpty ? nil : description, + color: selectedColor, + clientId: client.id + ) + let _: Project = try await apiClient.request( + endpoint: APIEndpoint.projects, method: .post, body: input, authenticated: true + ) + isSaving = false + dismiss() + onSave() + } catch { + isSaving = false + self.error = error.localizedDescription + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/Settings/SettingsView.swift b/ios/TimeTracker/TimeTracker/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..0c10cb7 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/Settings/SettingsView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var authManager: AuthManager + @State private var showLogoutConfirmation = false + + var body: some View { + NavigationStack { + List { + // User info header + if let user = authManager.currentUser { + Section { + HStack(spacing: 14) { + Circle() + .fill(Color.accentColor.opacity(0.15)) + .frame(width: 50, height: 50) + .overlay( + Text(user.username.prefix(1).uppercased()) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(.accentColor) + ) + VStack(alignment: .leading, spacing: 2) { + Text(user.fullName ?? user.username) + .font(.headline) + Text(user.email) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 6) + } + } + + // Navigation + Section("Data") { + NavigationLink { + ClientsListView() + } label: { + Label("Clients", systemImage: "person.2") + } + + NavigationLink { + ProjectsListView() + } label: { + Label("Projects", systemImage: "folder") + } + } + + // Logout + Section { + Button(role: .destructive) { + showLogoutConfirmation = true + } label: { + HStack { + Spacer() + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + Spacer() + } + } + } + } + .navigationTitle("Settings") + .alert("Sign Out?", isPresented: $showLogoutConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Sign Out", role: .destructive) { + Task { try? await authManager.logout() } + } + } message: { + Text("You will be signed out and need to sign in again to use the app.") + } + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift index 5a475d3..8b542c9 100644 --- a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift +++ b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift @@ -2,10 +2,13 @@ import SwiftUI struct TimeEntriesView: View { @StateObject private var viewModel = TimeEntriesViewModel() + @State private var selectedDay: Date? = Calendar.current.startOfDay(for: Date()) + @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 - + var body: some View { NavigationStack { Group { @@ -15,109 +18,413 @@ struct TimeEntriesView: View { ErrorView(message: error) { Task { await viewModel.loadEntries() } } - } else if viewModel.entries.isEmpty { - EmptyView( - icon: "clock", - title: "No Time Entries", - message: "Start tracking your time to see entries here." - ) } else { - entriesList + mainContent } } - .navigationTitle("Time Entries") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - showAddEntry = true - } label: { - Image(systemName: "plus") + .navigationTitle("Entries") + .toolbar { toolbarContent } + .task { await viewModel.loadEntries() } + .refreshable { await viewModel.loadEntries() } + .sheet(isPresented: $showFilterSheet) { + TimeEntriesFilterSheet(viewModel: viewModel) { + Task { await viewModel.loadEntries() } + } + } + .sheet(isPresented: $showAddEntry) { + TimeEntryDetailSheet(entry: nil) { + Task { await viewModel.loadEntries() } + } + } + .sheet(item: $entryToEdit) { entry in + TimeEntryDetailSheet(entry: entry) { + Task { await viewModel.loadEntries() } + } + } + .alert("Delete Entry?", isPresented: $showDeleteConfirmation, presenting: entryToDelete) { entry in + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + Task { await viewModel.deleteEntry(entry) } + } + } message: { entry in + Text("Delete the time entry for '\(entry.project.name)'? This cannot be undone.") + } + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .primaryAction) { + Button { showAddEntry = true } label: { Image(systemName: "plus") } + } + ToolbarItem(placement: .topBarLeading) { + Button { + Task { await viewModel.loadFilterSupportData() } + showFilterSheet = true + } label: { + Image(systemName: viewModel.activeFilters.isEmpty ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") + } + } + } + + // MARK: - Main content + + private var mainContent: some View { + ScrollView { + VStack(spacing: 0) { + // Month calendar + CalendarGridView( + daysWithEntries: viewModel.daysWithEntries, + selectedDay: $selectedDay + ) + .padding(.horizontal) + .padding(.top, 8) + + Divider().padding(.top, 8) + + // Day detail — entries for the selected day + if let day = selectedDay { + dayEntriesSection(for: day) + } else { + allEntriesSection + } + } + } + } + + // MARK: - Day entries section + + private func dayEntriesSection(for day: Date) -> some View { + let dayEntries = viewModel.entries(for: day) + return VStack(alignment: .leading, spacing: 0) { + // Section header + HStack { + Text(dayTitle(day)) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.primary) + Spacer() + Text(dayEntries.isEmpty ? "No entries" : "\(dayEntries.count) \(dayEntries.count == 1 ? "entry" : "entries")") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.vertical, 12) + + if dayEntries.isEmpty { + Text("No entries for this day") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 32) + } else { + ForEach(Array(dayEntries.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 < dayEntries.count - 1 { + Divider().padding(.leading, 56) } } } - .task { - await viewModel.loadEntries() - } - .sheet(isPresented: $showAddEntry) { - TimeEntryFormView(onSave: { - Task { await viewModel.loadEntries() } - }) + } + } + + // 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) + } } } } - - private var entriesList: some View { - List { - ForEach(viewModel.entries) { entry in - TimeEntryRow(entry: entry) - } - .onDelete { indexSet in - if let index = indexSet.first { - entryToDelete = viewModel.entries[index] - showDeleteConfirmation = true - } - } - } - .alert("Delete Time Entry?", isPresented: $showDeleteConfirmation, presenting: entryToDelete) { entry in - Button("Cancel", role: .cancel) {} - Button("Delete", role: .destructive) { - Task { - await viewModel.deleteEntry(entry) - } - } - } message: { entry in - Text("This will permanently delete the time entry for '\(entry.project.name)'. This action cannot be undone.") - } - .refreshable { - await viewModel.loadEntries() - } + + // MARK: - Helpers + + private func dayTitle(_ date: Date) -> String { + let cal = Calendar.current + if cal.isDateInToday(date) { return "Today" } + if cal.isDateInYesterday(date) { return "Yesterday" } + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, MMM d" + return formatter.string(from: date) } } -struct TimeEntryRow: View { +// MARK: - Entry Row + +struct EntryRow: View { let entry: TimeEntry - + var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - ProjectColorDot(color: entry.project.color) + HStack(alignment: .top, spacing: 12) { + // Color dot + ProjectColorDot(color: entry.project.color, size: 12) + .padding(.top, 4) + + VStack(alignment: .leading, spacing: 3) { Text(entry.project.name) - .font(.headline) - Spacer() - Text(entry.duration.formattedHours) .font(.subheadline) - .foregroundStyle(.secondary) - } - - HStack { - Text(formatDateRange(start: entry.startTime, end: entry.endTime)) - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - Text(entry.project.client.name) - .font(.caption) - .foregroundStyle(.secondary) - } - - if let description = entry.description { - Text(description) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) + .fontWeight(.medium) + + HStack(spacing: 6) { + Text(timeRange) + .font(.caption) + .foregroundStyle(.secondary) + Text("·") + .font(.caption) + .foregroundStyle(.secondary) + Text(entry.project.client.name) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let desc = entry.description, !desc.isEmpty { + Text(desc) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } } + + Spacer() + + Text(entry.duration.formattedShortDuration) + .font(.subheadline) + .foregroundStyle(.secondary) } - .padding(.vertical, 4) + .padding(.horizontal) + .padding(.vertical, 10) } - - private func formatDateRange(start: String, end: String) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "MMM d, HH:mm" - - guard let startDate = Date.fromISO8601(start), - let endDate = Date.fromISO8601(end) else { - return "" - } - - return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))" + + private var timeRange: String { + let fmt = DateFormatter() + fmt.dateFormat = "HH:mm" + let start = Date.fromISO8601(entry.startTime).map { fmt.string(from: $0) } ?? "" + let end = Date.fromISO8601(entry.endTime).map { fmt.string(from: $0) } ?? "" + return "\(start) – \(end)" + } +} + +// MARK: - Calendar Grid View (UICalendarView wrapper) + +struct CalendarGridView: UIViewRepresentable { + let daysWithEntries: Set + @Binding var selectedDay: Date? + + func makeUIView(context: Context) -> UICalendarView { + let view = UICalendarView() + view.calendar = .current + view.locale = .current + view.fontDesign = .rounded + view.delegate = context.coordinator + + let selection = UICalendarSelectionSingleDate(delegate: context.coordinator) + if let day = selectedDay { + selection.selectedDate = Calendar.current.dateComponents([.year, .month, .day], from: day) + } + view.selectionBehavior = selection + + // Show current month + let today = Date() + let comps = Calendar.current.dateComponents([.year, .month], from: today) + if let startOfMonth = Calendar.current.date(from: comps) { + view.visibleDateComponents = Calendar.current.dateComponents( + [.year, .month, .day], from: startOfMonth + ) + } + return view + } + + func updateUIView(_ uiView: UICalendarView, context: Context) { + // Reload all decorations when daysWithEntries changes + uiView.reloadDecorations(forDateComponents: Array(daysWithEntries.map { + Calendar.current.dateComponents([.year, .month, .day], from: $0) + }), animated: false) + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + final class Coordinator: NSObject, UICalendarViewDelegate, UICalendarSelectionSingleDateDelegate { + var parent: CalendarGridView + + init(parent: CalendarGridView) { + self.parent = parent + } + + // 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 } + } +} + +// MARK: - Filter Sheet + +struct TimeEntriesFilterSheet: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var viewModel: TimeEntriesViewModel + let onApply: () -> Void + + @State private var startDate: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date() + @State private var endDate: Date = Date() + @State private var useStartDate = false + @State private var useEndDate = false + @State private var selectedProjectId: String? + @State private var selectedProjectName: String? + @State private var selectedClientId: String? + @State private var selectedClientName: String? + + var body: some View { + NavigationStack { + Form { + Section("Date Range") { + Toggle("From", isOn: $useStartDate) + if useStartDate { + DatePicker("", selection: $startDate, displayedComponents: .date) + .labelsHidden() + } + Toggle("To", isOn: $useEndDate) + if useEndDate { + DatePicker("", selection: $endDate, displayedComponents: .date) + .labelsHidden() + } + } + + Section("Project") { + Picker("Project", selection: $selectedProjectId) { + Text("Any Project").tag(nil as String?) + ForEach(viewModel.projects) { project in + HStack { + ProjectColorDot(color: project.color, size: 10) + Text(project.name) + } + .tag(project.id as String?) + } + } + .pickerStyle(.navigationLink) + .onChange(of: selectedProjectId) { _, newId in + selectedProjectName = viewModel.projects.first { $0.id == newId }?.name + } + } + + Section("Client") { + Picker("Client", selection: $selectedClientId) { + Text("Any Client").tag(nil as String?) + ForEach(viewModel.clients) { client in + Text(client.name).tag(client.id as String?) + } + } + .pickerStyle(.navigationLink) + .onChange(of: selectedClientId) { _, newId in + selectedClientName = viewModel.clients.first { $0.id == newId }?.name + } + } + + Section { + Button("Clear All Filters", role: .destructive) { + useStartDate = false + useEndDate = false + selectedProjectId = nil + selectedClientId = nil + } + } + } + .navigationTitle("Filter Entries") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Apply") { applyAndDismiss() } + } + } + .onAppear { loadCurrentFilters() } + } + } + + private func loadCurrentFilters() { + let f = viewModel.activeFilters + if let s = f.startDate { startDate = s; useStartDate = true } + if let e = f.endDate { endDate = e; useEndDate = true } + selectedProjectId = f.projectId + selectedClientId = f.clientId + } + + private func applyAndDismiss() { + viewModel.activeFilters = TimeEntryActiveFilters( + startDate: useStartDate ? startDate : nil, + endDate: useEndDate ? endDate : nil, + projectId: selectedProjectId, + projectName: selectedProjectName, + clientId: selectedClientId, + clientName: selectedClientName + ) + dismiss() + onApply() } } diff --git a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesViewModel.swift b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesViewModel.swift index cac652a..039a320 100644 --- a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesViewModel.swift +++ b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesViewModel.swift @@ -1,45 +1,54 @@ import Foundation import SwiftUI +// MARK: - Active Filters (UI state) + +struct TimeEntryActiveFilters: Equatable { + var startDate: Date? + var endDate: Date? + var projectId: String? + var projectName: String? + var clientId: String? + var clientName: String? + + var isEmpty: Bool { + startDate == nil && endDate == nil && projectId == nil && clientId == nil + } +} + +// MARK: - ViewModel + @MainActor final class TimeEntriesViewModel: ObservableObject { + // All loaded entries (flat list, accumulated across pages) @Published var entries: [TimeEntry] = [] @Published var pagination: Pagination? @Published var isLoading = false + @Published var isLoadingMore = false @Published var error: String? - @Published var filters = TimeEntryFilters() - + + // Active filters driving the current fetch + @Published var activeFilters = TimeEntryActiveFilters() + + // Projects and clients needed for filter sheet pickers + @Published var projects: [Project] = [] + @Published var clients: [Client] = [] + private let apiClient = APIClient() - - func loadEntries() async { + + // MARK: - Fetch + + func loadEntries(resetPage: Bool = true) async { + if resetPage { entries = [] } isLoading = true error = nil - + do { - var queryItems: [URLQueryItem] = [] - - if let startDate = filters.startDate { - queryItems.append(URLQueryItem(name: "startDate", value: startDate)) - } - if let endDate = filters.endDate { - queryItems.append(URLQueryItem(name: "endDate", value: endDate)) - } - if let projectId = filters.projectId { - queryItems.append(URLQueryItem(name: "projectId", value: projectId)) - } - if let page = filters.page { - queryItems.append(URLQueryItem(name: "page", value: String(page))) - } - if let limit = filters.limit { - queryItems.append(URLQueryItem(name: "limit", value: String(limit))) - } - let response: TimeEntryListResponse = try await apiClient.request( endpoint: APIEndpoint.timeEntries, - queryItems: queryItems.isEmpty ? nil : queryItems, + queryItems: buildQueryItems(page: 1), authenticated: true ) - entries = response.entries pagination = response.pagination isLoading = false @@ -48,23 +57,30 @@ final class TimeEntriesViewModel: ObservableObject { self.error = error.localizedDescription } } - - func nextPage() async { - guard let pagination = pagination, + + func loadMoreIfNeeded(currentEntry entry: TimeEntry) async { + guard let pagination, !isLoadingMore, pagination.page < pagination.totalPages else { return } - - filters.page = pagination.page + 1 - await loadEntries() + // Trigger when the last entry in the list becomes visible + guard entries.last?.id == entry.id else { return } + + isLoadingMore = true + let nextPage = pagination.page + 1 + + do { + let response: TimeEntryListResponse = try await apiClient.request( + endpoint: APIEndpoint.timeEntries, + queryItems: buildQueryItems(page: nextPage), + authenticated: true + ) + entries.append(contentsOf: response.entries) + self.pagination = response.pagination + } catch { + self.error = error.localizedDescription + } + isLoadingMore = false } - - func previousPage() async { - guard let pagination = pagination, - pagination.page > 1 else { return } - - filters.page = pagination.page - 1 - await loadEntries() - } - + func deleteEntry(_ entry: TimeEntry) async { do { try await apiClient.requestVoid( @@ -72,10 +88,83 @@ final class TimeEntriesViewModel: ObservableObject { method: .delete, authenticated: true ) - entries.removeAll { $0.id == entry.id } } catch { self.error = error.localizedDescription } } + + // MARK: - Supporting data for filter sheet + + func loadFilterSupportData() async { + async let fetchProjects: [Project] = apiClient.request( + endpoint: APIEndpoint.projects, + authenticated: true + ) + async let fetchClients: [Client] = apiClient.request( + endpoint: APIEndpoint.clients, + authenticated: true + ) + projects = (try? await fetchProjects) ?? [] + clients = (try? await fetchClients) ?? [] + } + + // MARK: - Entries grouped by calendar day + + var entriesByDay: [(date: Date, entries: [TimeEntry])] { + let calendar = Calendar.current + let grouped = Dictionary(grouping: entries) { entry -> Date in + guard let d = Date.fromISO8601(entry.startTime) else { return Date() } + return calendar.startOfDay(for: d) + } + return grouped + .sorted { $0.key > $1.key } + .map { (date: $0.key, entries: $0.value.sorted { + (Date.fromISO8601($0.startTime) ?? Date()) > (Date.fromISO8601($1.startTime) ?? Date()) + }) } + } + + /// All calendar days that have at least one entry (for dot decorations) + var daysWithEntries: Set { + let calendar = Calendar.current + return Set(entries.compactMap { entry in + guard let d = Date.fromISO8601(entry.startTime) else { return nil } + return calendar.startOfDay(for: d) + }) + } + + /// Entries for a specific calendar day + func entries(for day: Date) -> [TimeEntry] { + let calendar = Calendar.current + return entries.filter { entry in + guard let d = Date.fromISO8601(entry.startTime) else { return false } + return calendar.isDate(d, inSameDayAs: day) + }.sorted { + (Date.fromISO8601($0.startTime) ?? Date()) < (Date.fromISO8601($1.startTime) ?? Date()) + } + } + + // MARK: - Helpers + + private func buildQueryItems(page: Int) -> [URLQueryItem] { + var items: [URLQueryItem] = [ + URLQueryItem(name: "page", value: "\(page)"), + URLQueryItem(name: "limit", value: "100") + ] + if let start = activeFilters.startDate { + items.append(URLQueryItem(name: "startDate", value: start.iso8601String)) + } + if let end = activeFilters.endDate { + // Push to end-of-day so the full day is included + let endOfDay = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: end) ?? end + items.append(URLQueryItem(name: "endDate", value: endOfDay.iso8601String)) + } + if let pid = activeFilters.projectId { + items.append(URLQueryItem(name: "projectId", value: pid)) + } + if let cid = activeFilters.clientId { + items.append(URLQueryItem(name: "clientId", value: cid)) + } + return items + } } diff --git a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryDetailSheet.swift b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryDetailSheet.swift new file mode 100644 index 0000000..cfa700d --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryDetailSheet.swift @@ -0,0 +1,212 @@ +import SwiftUI + +/// Detail/edit sheet for a single time entry. Used both for creating new entries +/// (pass `entry: nil`) and editing existing ones. +struct TimeEntryDetailSheet: View { + @Environment(\.dismiss) private var dismiss + + // Pass an existing entry to edit it; pass nil to create a new one. + let entry: TimeEntry? + let onSave: () -> Void + + // Form state + @State private var startDateTime = Date() + @State private var endDateTime = Date() + @State private var description = "" + @State private var selectedProject: Project? + + // Supporting data + @State private var projects: [Project] = [] + @State private var isLoading = false + @State private var isSaving = false + @State private var error: String? + + private let apiClient = APIClient() + + init(entry: TimeEntry? = nil, onSave: @escaping () -> Void) { + self.entry = entry + self.onSave = onSave + } + + // MARK: - Body + + var body: some View { + NavigationStack { + Form { + projectSection + timeSection + descriptionSection + if let error { errorSection(error) } + } + .navigationTitle(entry == nil ? "New Entry" : "Edit Entry") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if isSaving { + ProgressView().controlSize(.small) + } else { + Button("Save") { Task { await save() } } + .disabled(selectedProject == nil || endDateTime <= startDateTime) + } + } + } + .task { + await loadProjects() + populateFromEntry() + } + .overlay { if isLoading { LoadingView() } } + } + } + + // MARK: - Sections + + private var projectSection: some View { + Section("Project") { + Picker("Project", selection: $selectedProject) { + Text("Select Project").tag(nil as Project?) + ForEach(projects) { project in + HStack { + ProjectColorDot(color: project.color, size: 10) + Text(project.name) + Text("· \(project.client.name)") + .foregroundStyle(.secondary) + } + .tag(project as Project?) + } + } + .pickerStyle(.navigationLink) + } + } + + private var timeSection: some View { + Section { + DatePicker("Start", selection: $startDateTime) + DatePicker("End", selection: $endDateTime, in: startDateTime...) + if endDateTime > startDateTime { + HStack { + Text("Duration") + .foregroundStyle(.secondary) + Spacer() + Text(endDateTime.timeIntervalSince(startDateTime).formattedShortDuration) + .foregroundStyle(.secondary) + } + } + } header: { + Text("Time") + } + } + + private var descriptionSection: some View { + Section("Description") { + TextField("Optional notes…", text: $description, axis: .vertical) + .lineLimit(3...8) + } + } + + private func errorSection(_ message: String) -> some View { + Section { + Text(message) + .font(.caption) + .foregroundStyle(.red) + } + } + + // MARK: - Data loading + + private func loadProjects() async { + isLoading = true + do { + projects = try await apiClient.request( + endpoint: APIEndpoint.projects, + authenticated: true + ) + // Re-apply project selection now that the list is populated + matchProjectAfterLoad() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + private func populateFromEntry() { + guard let entry else { + // Default: now rounded to minute, 1-hour window + let now = Date().roundedToMinute() + startDateTime = now + endDateTime = now.addingTimeInterval(3600) + return + } + startDateTime = Date.fromISO8601(entry.startTime) ?? Date() + endDateTime = Date.fromISO8601(entry.endTime) ?? Date() + description = entry.description ?? "" + // Pre-select the project once projects are loaded + if !projects.isEmpty { + selectedProject = projects.first { $0.id == entry.projectId } + } + } + + // Called after projects load — re-apply the project selection if it wasn't + // set yet (projects may have loaded after populateFromEntry ran). + private func matchProjectAfterLoad() { + guard let entry, selectedProject == nil else { return } + selectedProject = projects.first { $0.id == entry.projectId } + } + + // MARK: - Save + + private func save() async { + guard let project = selectedProject else { return } + isSaving = true + error = nil + + do { + if let existingEntry = entry { + let input = UpdateTimeEntryInput( + startTime: startDateTime.iso8601String, + endTime: endDateTime.iso8601String, + description: description.isEmpty ? nil : description, + projectId: project.id + ) + try await apiClient.requestVoid( + endpoint: APIEndpoint.timeEntry(id: existingEntry.id), + method: .put, + body: input, + authenticated: true + ) + } else { + let input = CreateTimeEntryInput( + startTime: startDateTime, + endTime: endDateTime, + description: description.isEmpty ? nil : description, + projectId: project.id + ) + try await apiClient.requestVoid( + endpoint: APIEndpoint.timeEntries, + method: .post, + body: input, + authenticated: true + ) + } + isSaving = false + dismiss() + onSave() + } catch { + isSaving = false + self.error = error.localizedDescription + } + } +} + +// MARK: - Date rounding helper + +private extension Date { + func roundedToMinute() -> Date { + let cal = Calendar.current + var comps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: self) + comps.second = 0 + return cal.date(from: comps) ?? self + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryFormView.swift b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryFormView.swift index f89aaf7..e24916c 100644 --- a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryFormView.swift +++ b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryFormView.swift @@ -1,171 +1,2 @@ -import SwiftUI - -struct TimeEntryFormView: View { - @Environment(\.dismiss) private var dismiss - - let entry: TimeEntry? - let onSave: () -> Void - - @State private var startDate = Date() - @State private var startTime = Date() - @State private var endDate = Date() - @State private var endTime = Date() - @State private var description = "" - @State private var selectedProject: Project? - @State private var projects: [Project] = [] - @State private var isLoading = false - @State private var error: String? - - private let apiClient = APIClient() - - init(entry: TimeEntry? = nil, onSave: @escaping () -> Void) { - self.entry = entry - self.onSave = onSave - } - - var body: some View { - NavigationStack { - Form { - Section("Project") { - Picker("Project", selection: $selectedProject) { - Text("Select Project").tag(nil as Project?) - ForEach(projects) { project in - HStack { - ProjectColorDot(color: project.color) - Text(project.name) - } - .tag(project as Project?) - } - } - } - - Section("Start Time") { - DatePicker("Date", selection: $startDate, displayedComponents: .date) - DatePicker("Time", selection: $startTime, displayedComponents: .hourAndMinute) - } - - Section("End Time") { - DatePicker("Date", selection: $endDate, displayedComponents: .date) - DatePicker("Time", selection: $endTime, displayedComponents: .hourAndMinute) - } - - Section("Description (Optional)") { - TextField("Description", text: $description, axis: .vertical) - .lineLimit(3...6) - } - } - .navigationTitle(entry == nil ? "New Entry" : "Edit Entry") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - save() - } - .disabled(selectedProject == nil || isLoading) - } - } - .task { - await loadProjects() - if let entry = entry { - await loadEntry(entry) - } - } - } - } - - private func loadProjects() async { - do { - projects = try await apiClient.request( - endpoint: APIEndpoint.projects, - authenticated: true - ) - } catch { - self.error = error.localizedDescription - } - } - - private func loadEntry(_ entry: TimeEntry) async { - let startFormatter = DateFormatter() - startFormatter.dateFormat = "yyyy-MM-dd" - let timeFormatter = DateFormatter() - timeFormatter.dateFormat = "HH:mm" - - if let startDateObj = Date.fromISO8601(entry.startTime) { - startDate = startDateObj - startTime = startDateObj - } - - if let endDateObj = Date.fromISO8601(entry.endTime) { - endDate = endDateObj - endTime = endDateObj - } - - description = entry.description ?? "" - selectedProject = projects.first { $0.id == entry.projectId } - } - - private func save() { - guard let project = selectedProject else { return } - - isLoading = true - - let calendar = Calendar.current - let startDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: startTime), - minute: calendar.component(.minute, from: startTime), - second: 0, - of: startDate) ?? startDate - let endDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: endTime), - minute: calendar.component(.minute, from: endTime), - second: 0, - of: endDate) ?? endDate - - Task { - do { - if let existingEntry = entry { - let input = UpdateTimeEntryInput( - startTime: startDateTime.iso8601String, - endTime: endDateTime.iso8601String, - description: description.isEmpty ? nil : description, - projectId: project.id - ) - try await apiClient.requestVoid( - endpoint: APIEndpoint.timeEntry(id: existingEntry.id), - method: .put, - body: input, - authenticated: true - ) - } else { - let input = CreateTimeEntryInput( - startTime: startDateTime, - endTime: endDateTime, - description: description.isEmpty ? nil : description, - projectId: project.id - ) - try await apiClient.requestVoid( - endpoint: APIEndpoint.timeEntries, - method: .post, - body: input, - authenticated: true - ) - } - - await MainActor.run { - isLoading = false - dismiss() - onSave() - } - } catch { - let errorMessage = error.localizedDescription - await MainActor.run { - isLoading = false - self.error = errorMessage - } - } - } - } -} +// TimeEntryFormView.swift — replaced by TimeEntryDetailSheet.swift +import Foundation diff --git a/ios/TimeTracker/TimeTracker/Models/ClientTarget.swift b/ios/TimeTracker/TimeTracker/Models/ClientTarget.swift new file mode 100644 index 0000000..3432f89 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Models/ClientTarget.swift @@ -0,0 +1,62 @@ +import Foundation + +// MARK: - Client Target + +struct ClientTarget: Codable, Identifiable, Equatable { + let id: String + let clientId: String + let clientName: String + let userId: String + let weeklyHours: Double + let startDate: String // "YYYY-MM-DD" + let createdAt: String + let updatedAt: String + let corrections: [BalanceCorrection] + + // Computed balance fields returned by the API + let totalBalanceSeconds: Int + let currentWeekTrackedSeconds: Int + let currentWeekTargetSeconds: Int + let weeks: [WeekBalance] +} + +// MARK: - Week Balance + +struct WeekBalance: Codable, Identifiable, Equatable { + var id: String { weekStart } + let weekStart: String // "YYYY-MM-DD" + let weekEnd: String + let trackedSeconds: Int + let targetSeconds: Int + let correctionHours: Double + let balanceSeconds: Int +} + +// MARK: - Balance Correction + +struct BalanceCorrection: Codable, Identifiable, Equatable { + let id: String + let date: String // "YYYY-MM-DD" + let hours: Double + let description: String? + let createdAt: String +} + +// MARK: - Input Types + +struct CreateClientTargetInput: Codable { + let clientId: String + let weeklyHours: Double + let startDate: String // "YYYY-MM-DD", must be a Monday +} + +struct UpdateClientTargetInput: Codable { + let weeklyHours: Double? + let startDate: String? +} + +struct CreateBalanceCorrectionInput: Codable { + let date: String // "YYYY-MM-DD" + let hours: Double + let description: String? +} diff --git a/ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift b/ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift index 70ccfdb..445c318 100644 --- a/ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift +++ b/ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift @@ -29,18 +29,38 @@ struct StatCard: View { } extension TimeInterval { + /// Formats as a clock string used for the live timer display: "1:23:45" or "05:30". var formattedDuration: String { - let hours = Int(self) / 3600 - let minutes = (Int(self) % 3600) / 60 - let seconds = Int(self) % 60 - + let totalSeconds = Int(self) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) } else { return String(format: "%02d:%02d", minutes, seconds) } } - + + /// Human-readable duration used in lists and cards: "3h 48min", "45min", "< 1min". + var formattedShortDuration: String { + let totalSeconds = Int(self) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + + if hours > 0 && minutes > 0 { + return "\(hours)h \(minutes)min" + } else if hours > 0 { + return "\(hours)h" + } else if minutes > 0 { + return "\(minutes)min" + } else { + return "< 1min" + } + } + + /// Formats as hours with one decimal place, e.g. "3.8h". Used by the widget. var formattedHours: String { let hours = self / 3600 return String(format: "%.1fh", hours) diff --git a/ios/TimeTracker/TimeTracker/TimeTrackerApp/TimeTrackerApp.swift b/ios/TimeTracker/TimeTracker/TimeTrackerApp/TimeTrackerApp.swift index 57b7518..0038a05 100644 --- a/ios/TimeTracker/TimeTracker/TimeTrackerApp/TimeTrackerApp.swift +++ b/ios/TimeTracker/TimeTracker/TimeTrackerApp/TimeTrackerApp.swift @@ -31,38 +31,24 @@ struct RootView: View { struct MainTabView: View { @State private var selectedTab = 0 - + var body: some View { TabView(selection: $selectedTab) { DashboardView() - .tabItem { - Label("Dashboard", systemImage: "chart.bar") - } + .tabItem { Label("Dashboard", systemImage: "chart.bar") } .tag(0) - + TimerView() - .tabItem { - Label("Timer", systemImage: "timer") - } + .tabItem { Label("Timer", systemImage: "timer") } .tag(1) - + TimeEntriesView() - .tabItem { - Label("Entries", systemImage: "clock") - } + .tabItem { Label("Entries", systemImage: "calendar") } .tag(2) - - ProjectsView() - .tabItem { - Label("Projects", systemImage: "folder") - } + + SettingsView() + .tabItem { Label("Settings", systemImage: "gearshape") } .tag(3) - - ClientsView() - .tabItem { - Label("Clients", systemImage: "person.2") - } - .tag(4) } } } From ef38578596d06d3c51d2757bf088b35f8aa39130 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Sat, 21 Feb 2026 13:57:31 +0100 Subject: [PATCH 2/4] =?UTF-8?q?Fix=20.accentColor=20ShapeStyle=20compile?= =?UTF-8?q?=20error=20=E2=80=94=20use=20Color.accentColor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TimeTracker/Features/Settings/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/TimeTracker/TimeTracker/Features/Settings/SettingsView.swift b/ios/TimeTracker/TimeTracker/Features/Settings/SettingsView.swift index 0c10cb7..6e8a4fc 100644 --- a/ios/TimeTracker/TimeTracker/Features/Settings/SettingsView.swift +++ b/ios/TimeTracker/TimeTracker/Features/Settings/SettingsView.swift @@ -18,7 +18,7 @@ struct SettingsView: View { Text(user.username.prefix(1).uppercased()) .font(.title3) .fontWeight(.semibold) - .foregroundStyle(.accentColor) + .foregroundStyle(Color.accentColor) ) VStack(alignment: .leading, spacing: 2) { Text(user.fullName ?? user.username) From 30d5139ad82a1ba43b542e789275d436ae8fa8ea Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Sat, 21 Feb 2026 14:03:56 +0100 Subject: [PATCH 3/4] update --- ios/TimeTracker/TimeTracker/TimeTracker.entitlements | 2 +- .../TimeTrackerWidget/TimeTrackerWidget.entitlements | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/TimeTracker/TimeTracker/TimeTracker.entitlements b/ios/TimeTracker/TimeTracker/TimeTracker.entitlements index dc77e23..fadbb8d 100644 --- a/ios/TimeTracker/TimeTracker/TimeTracker.entitlements +++ b/ios/TimeTracker/TimeTracker/TimeTracker.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.simonfranken.timetracker + group.com.simonfranken.timetracker.app diff --git a/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.entitlements b/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.entitlements index dc77e23..fadbb8d 100644 --- a/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.entitlements +++ b/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.simonfranken.timetracker + group.com.simonfranken.timetracker.app From b613fe4eddc27c8445361bbe81c3fff675923166 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Sat, 21 Feb 2026 18:01:02 +0100 Subject: [PATCH 4/4] Replace full-month UICalendarView with compact week strip in time entries --- .../TimeEntries/TimeEntriesView.swift | 229 ++++++++++++------ 1 file changed, 150 insertions(+), 79 deletions(-) diff --git a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift index 8b542c9..fdf252f 100644 --- a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift +++ b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift @@ -3,12 +3,26 @@ 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 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 { @@ -72,19 +86,22 @@ struct TimeEntriesView: View { // MARK: - Main content private var mainContent: some View { - ScrollView { - VStack(spacing: 0) { - // Month calendar - CalendarGridView( - daysWithEntries: viewModel.daysWithEntries, - selectedDay: $selectedDay - ) - .padding(.horizontal) - .padding(.top, 8) + 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().padding(.top, 8) + Divider() - // Day detail — entries for the selected day + ScrollView { if let day = selectedDay { dayEntriesSection(for: day) } else { @@ -242,83 +259,137 @@ struct EntryRow: View { } } -// MARK: - Calendar Grid View (UICalendarView wrapper) +// MARK: - Week Strip View -struct CalendarGridView: UIViewRepresentable { - let daysWithEntries: Set +struct WeekStripView: View { + let weekDays: [Date] @Binding var selectedDay: Date? + let daysWithEntries: Set + let onSwipeLeft: () -> Void + let onSwipeRight: () -> Void - func makeUIView(context: Context) -> UICalendarView { - let view = UICalendarView() - view.calendar = .current - view.locale = .current - view.fontDesign = .rounded - view.delegate = context.coordinator + @GestureState private var dragOffset: CGFloat = 0 - let selection = UICalendarSelectionSingleDate(delegate: context.coordinator) - if let day = selectedDay { - selection.selectedDate = Calendar.current.dateComponents([.year, .month, .day], from: day) - } - view.selectionBehavior = selection + private let cal = Calendar.current - // Show current month - let today = Date() - let comps = Calendar.current.dateComponents([.year, .month], from: today) - if let startOfMonth = Calendar.current.date(from: comps) { - view.visibleDateComponents = Calendar.current.dateComponents( - [.year, .month, .day], from: startOfMonth - ) - } - return view + 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) } - func updateUIView(_ uiView: UICalendarView, context: Context) { - // Reload all decorations when daysWithEntries changes - uiView.reloadDecorations(forDateComponents: Array(daysWithEntries.map { - Calendar.current.dateComponents([.year, .month, .day], from: $0) - }), animated: false) - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - final class Coordinator: NSObject, UICalendarViewDelegate, UICalendarSelectionSingleDateDelegate { - var parent: CalendarGridView - - init(parent: CalendarGridView) { - self.parent = parent - } - - // 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 + 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) + } } - 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 - } - } + .padding(.horizontal, 8) + .padding(.top, 6) - func dateSelection(_ selection: UICalendarSelectionSingleDate, - canSelectDate dateComponents: DateComponents?) -> Bool { true } + // 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) } }