diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
index 3ea957c..f4bbc7d 100644
--- a/frontend/public/favicon.svg
+++ b/frontend/public/favicon.svg
@@ -1,40 +1,23 @@
-
diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg
index 2a4bf1b..df78114 100644
--- a/frontend/public/icon.svg
+++ b/frontend/public/icon.svg
@@ -1,43 +1,33 @@
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
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..6e8a4fc
--- /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(Color.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..f9de6cb 100644
--- a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift
+++ b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift
@@ -2,10 +2,19 @@ import SwiftUI
struct TimeEntriesView: View {
@StateObject private var viewModel = TimeEntriesViewModel()
+
+ // dayOffset is the source of truth: 0 = today, -1 = yesterday, etc.
+ @State private var dayOffset: Int = 0
+ // tabSelection is always snapped back to 1 (middle) after each swipe.
+ // Pages are: 0 = dayOffset-1, 1 = dayOffset, 2 = dayOffset+1
+ @State private var tabSelection: Int = 1
+
+ @State private var showFilterSheet = false
@State private var showAddEntry = false
+ @State private var entryToEdit: TimeEntry?
@State private var entryToDelete: TimeEntry?
@State private var showDeleteConfirmation = false
-
+
var body: some View {
NavigationStack {
Group {
@@ -15,109 +24,338 @@ 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")
+ .navigationBarTitleDisplayMode(.inline)
+ .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")
+ }
+ }
+
+ // Place the DatePicker in the principal placement (center of nav bar)
+ ToolbarItem(placement: .principal) {
+ DatePicker(
+ "",
+ selection: Binding(
+ get: {
+ Calendar.current.date(byAdding: .day, value: dayOffset, to: Calendar.current.startOfDay(for: Date())) ?? Date()
+ },
+ set: { newDate in
+ let today = Calendar.current.startOfDay(for: Date())
+ let normalizedNewDate = Calendar.current.startOfDay(for: newDate)
+ let components = Calendar.current.dateComponents([.day], from: today, to: normalizedNewDate)
+ if let dayDifference = components.day {
+ dayOffset = dayDifference
+ }
+ }
+ ),
+ displayedComponents: .date
+ )
+ .datePickerStyle(.compact)
+ .labelsHidden()
+ .environment(\.locale, Locale.current) // Ensure correct start of week
+ }
+ }
+
+ // MARK: - Main content
+
+ private var mainContent: some View {
+ // Only 3 pages exist at any time: previous, current, next.
+ // After each swipe settles, we reset tabSelection to 1 and shift
+ // dayOffset, so the carousel appears infinite while staying cheap.
+ TabView(selection: $tabSelection) {
+ ForEach(0..<3, id: \.self) { page in
+ let offset = dayOffset + (page - 1)
+ let day = Calendar.current.date(
+ byAdding: .day,
+ value: offset,
+ to: Calendar.current.startOfDay(for: Date())
+ ) ?? Date()
+
+ ScrollView {
+ dayEntriesSection(for: day)
+ }
+ .tag(page)
+ }
+ }
+ .tabViewStyle(.page(indexDisplayMode: .never))
+ .onChange(of: tabSelection) { _, newPage in
+ guard newPage != 1 else { return }
+ // Shift the logical day offset by how many pages we moved.
+ dayOffset += newPage - 1
+ // Snap back to the middle page without animation so the
+ // surrounding pages are refreshed invisibly.
+ var tx = Transaction()
+ tx.disablesAnimations = true
+ withTransaction(tx) {
+ tabSelection = 1
+ }
+ }
+ }
+
+ // MARK: - Day entries section
+
+ private func dayEntriesSection(for day: Date) -> some View {
+ let dayEntries = viewModel.entries(for: day)
+ return VStack(alignment: .leading, spacing: 0) {
+
+ // Optional: A small summary header for the day
+ 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() }
- })
- }
}
+ .padding(.bottom, 40) // Give some breathing room at the bottom of the scroll
}
-
- 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" }
+ if cal.isDateInTomorrow(date) { return "Tomorrow" }
+ 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: - 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/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
index 13613e3..ddd0a37 100644
--- a/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,6 +1,29 @@
{
"images" : [
{
+ "filename" : "app_icon.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
diff --git a/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/AppIcon.appiconset/app_icon.png b/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/AppIcon.appiconset/app_icon.png
new file mode 100644
index 0000000..90987de
Binary files /dev/null and b/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/AppIcon.appiconset/app_icon.png differ
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/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/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)
}
}
}
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