Compare commits
14 Commits
ddb0926dba
...
7dd3873148
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dd3873148 | |||
| 850f12e09d | |||
| 74999ce265 | |||
| 0c0fbf42ef | |||
| 0d116c8c26 | |||
| 25b7371d08 | |||
|
|
c99bdf56e6 | ||
|
|
15abfe0511 | ||
|
|
544b86c948 | ||
|
|
b971569983 | ||
| b613fe4edd | |||
| 30d5139ad8 | |||
| ef38578596 | |||
| ba4765b8a2 |
@@ -1,40 +1,23 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 48 416 416" width="100%" height="100%">
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<defs>
|
<!-- Generated by Pixelmator Pro 4.0 -->
|
||||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
<svg width="416" height="416" viewBox="0 0 416 416" xmlns="http://www.w3.org/2000/svg">
|
||||||
<stop offset="0%" stop-color="#818CF8" />
|
<linearGradient id="linearGradient1" x1="0" y1="0" x2="416" y2="416" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="100%" stop-color="#4F46E5" />
|
<stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
|
||||||
|
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
<path id="Path" fill="url(#linearGradient1)" stroke="none" d="M 96 0 L 320 0 C 373.019348 0 416 42.980652 416 96 L 416 320 C 416 373.019348 373.019348 416 320 416 L 96 416 C 42.980667 416 0 373.019348 0 320 L 0 96 C 0 42.980652 42.980667 0 96 0 Z"/>
|
||||||
|
<g id="Group">
|
||||||
<!-- App Icon Background -->
|
<path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 208 48 L 208 92"/>
|
||||||
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" />
|
<path id="path2" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 176 40 L 240 40"/>
|
||||||
|
<path id="path3" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 304 128 L 328 104"/>
|
||||||
<!-- Inner Icon Group -->
|
<path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 314 90 L 342 118"/>
|
||||||
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
<path id="path5" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 340 224 C 340 296.901581 280.901581 356 208 356 C 135.098419 356 76 296.901581 76 224 C 76 151.098419 135.098419 92 208 92 C 280.901581 92 340 151.098419 340 224 Z"/>
|
||||||
<!-- Stopwatch Top Button -->
|
<path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 136 L 208 224"/>
|
||||||
<path d="M256 96 v44" stroke-width="28" />
|
<path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 224 L 256 256"/>
|
||||||
<path d="M224 88 h64" stroke-width="24" />
|
<g id="g1" opacity="0.6">
|
||||||
|
<path id="path8" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 208 308 L 208 324"/>
|
||||||
<!-- Stopwatch Side Button -->
|
<path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 124 224 L 140 224"/>
|
||||||
<path d="M352 176 l 24 -24" stroke-width="24" />
|
<path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 292 224 L 276 224"/>
|
||||||
<!-- Cap for side button -->
|
</g>
|
||||||
<path d="M362 138 l 28 28" stroke-width="24" />
|
|
||||||
|
|
||||||
<!-- Outer Ring -->
|
|
||||||
<circle cx="256" cy="272" r="132" stroke-width="28" />
|
|
||||||
|
|
||||||
<!-- Clock Hands -->
|
|
||||||
<!-- Minute Hand -->
|
|
||||||
<path d="M256 184 v 88" stroke-width="24" />
|
|
||||||
<!-- Hour Hand -->
|
|
||||||
<path d="M256 272 l 48 32" stroke-width="24" />
|
|
||||||
|
|
||||||
<!-- Dial Tick Marks -->
|
|
||||||
<g stroke-width="12" opacity="0.6">
|
|
||||||
<line x1="256" y1="172" x2="256" y2="188" />
|
|
||||||
<line x1="256" y1="356" x2="256" y2="372" />
|
|
||||||
<line x1="172" y1="272" x2="188" y2="272" />
|
|
||||||
<line x1="340" y1="272" x2="324" y2="272" />
|
|
||||||
</g>
|
</g>
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,43 +1,33 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<defs>
|
<!-- Generated by Pixelmator Pro 4.0 -->
|
||||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
<stop offset="0%" stop-color="#818CF8" />
|
<linearGradient id="linearGradient1" x1="48" y1="48" x2="464" y2="464" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="100%" stop-color="#4F46E5" />
|
<stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
|
||||||
|
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
<filter id="filter1" x="0" y="0" width="512" height="512" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#4F46E5" flood-opacity="0.4" />
|
<feGaussianBlur stdDeviation="16"/>
|
||||||
|
<feOffset dx="0" dy="12" result="offsetblur"/>
|
||||||
|
<feFlood flood-color="#4f46e5" flood-opacity="0.4"/>
|
||||||
|
<feComposite in2="offsetblur" operator="in"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
</defs>
|
<path id="Path" fill="url(#linearGradient1)" stroke="none" filter="url(#filter1)" d="M 144 48 L 368 48 C 421.019348 48 464 90.980652 464 144 L 464 368 C 464 421.019348 421.019348 464 368 464 L 144 464 C 90.980667 464 48 421.019348 48 368 L 48 144 C 48 90.980652 90.980667 48 144 48 Z"/>
|
||||||
|
<g id="Group">
|
||||||
<!-- App Icon Background -->
|
<path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 256 96 L 256 140"/>
|
||||||
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" filter="url(#shadow)" />
|
<path id="path2" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 224 88 L 288 88"/>
|
||||||
|
<path id="path3" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 352 176 L 376 152"/>
|
||||||
<!-- Inner Icon Group -->
|
<path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 362 138 L 390 166"/>
|
||||||
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
<path id="path5" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 388 272 C 388 344.901581 328.901581 404 256 404 C 183.098419 404 124 344.901581 124 272 C 124 199.098419 183.098419 140 256 140 C 328.901581 140 388 199.098419 388 272 Z"/>
|
||||||
<!-- Stopwatch Top Button -->
|
<path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 184 L 256 272"/>
|
||||||
<path d="M256 96 v44" stroke-width="28" />
|
<path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 272 L 304 304"/>
|
||||||
<path d="M224 88 h64" stroke-width="24" />
|
<g id="g1" opacity="0.6">
|
||||||
|
<path id="path8" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 256 356 L 256 372"/>
|
||||||
<!-- Stopwatch Side Button -->
|
<path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 172 272 L 188 272"/>
|
||||||
<path d="M352 176 l 24 -24" stroke-width="24" />
|
<path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 340 272 L 324 272"/>
|
||||||
<!-- Cap for side button -->
|
</g>
|
||||||
<path d="M362 138 l 28 28" stroke-width="24" />
|
|
||||||
|
|
||||||
<!-- Outer Ring -->
|
|
||||||
<circle cx="256" cy="272" r="132" stroke-width="28" />
|
|
||||||
|
|
||||||
<!-- Clock Hands -->
|
|
||||||
<!-- Minute Hand -->
|
|
||||||
<path d="M256 184 v 88" stroke-width="24" />
|
|
||||||
<!-- Hour Hand -->
|
|
||||||
<path d="M256 272 l 48 32" stroke-width="24" />
|
|
||||||
|
|
||||||
<!-- Dial Tick Marks -->
|
|
||||||
<g stroke-width="12" opacity="0.6">
|
|
||||||
<line x1="256" y1="172" x2="256" y2="188" />
|
|
||||||
<line x1="256" y1="356" x2="256" y2="372" />
|
|
||||||
<line x1="172" y1="272" x2="188" y2="272" />
|
|
||||||
<line x1="340" y1="272" x2="324" y2="272" />
|
|
||||||
</g>
|
</g>
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -25,6 +25,14 @@ enum APIEndpoint {
|
|||||||
static let timer = "/timer"
|
static let timer = "/timer"
|
||||||
static let timerStart = "/timer/start"
|
static let timerStart = "/timer/start"
|
||||||
static let timerStop = "/timer/stop"
|
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 {
|
struct APIEndpoints {
|
||||||
|
|||||||
@@ -1,134 +1,3 @@
|
|||||||
import SwiftUI
|
// ClientsView.swift — replaced by Features/Settings/ClientsListView.swift
|
||||||
|
// This file is intentionally empty; ClientsViewModel is no longer used directly.
|
||||||
struct ClientsView: View {
|
import Foundation
|
||||||
@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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,94 +2,82 @@ import SwiftUI
|
|||||||
|
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@StateObject private var viewModel = DashboardViewModel()
|
@StateObject private var viewModel = DashboardViewModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
Group {
|
||||||
VStack(spacing: 24) {
|
if viewModel.isLoading && viewModel.statistics == nil && viewModel.recentEntries.isEmpty {
|
||||||
// Active Timer Card
|
LoadingView()
|
||||||
timerCard
|
} else {
|
||||||
|
scrollContent
|
||||||
// Weekly Stats
|
|
||||||
if let stats = viewModel.statistics {
|
|
||||||
statsSection(stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recent Entries
|
|
||||||
recentEntriesSection
|
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Dashboard")
|
.navigationTitle("Dashboard")
|
||||||
.refreshable {
|
.refreshable { await viewModel.loadData() }
|
||||||
await viewModel.loadData()
|
.task { 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 {
|
private var timerCard: some View {
|
||||||
VStack(spacing: 16) {
|
HStack {
|
||||||
if let timer = viewModel.activeTimer {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
if let timer = viewModel.activeTimer {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Text("Timer Running")
|
||||||
Text("Timer Running")
|
.font(.subheadline)
|
||||||
.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)
|
|
||||||
.foregroundStyle(.secondary)
|
.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()
|
.padding()
|
||||||
.background(Color(.systemGray6))
|
.background(Color(.systemGray6))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func statsSection(_ stats: TimeStatistics) -> some View {
|
// MARK: - Weekly Stats
|
||||||
|
|
||||||
|
private func weeklyStatsSection(_ stats: TimeStatistics) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("This Week")
|
Text("This Week")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
StatCard(
|
StatCard(
|
||||||
title: "Hours Tracked",
|
title: "Hours Tracked",
|
||||||
value: TimeInterval(stats.totalSeconds).formattedHours,
|
value: TimeInterval(stats.totalSeconds).formattedShortDuration,
|
||||||
icon: "clock.fill",
|
icon: "clock.fill",
|
||||||
color: .blue
|
color: .blue
|
||||||
)
|
)
|
||||||
|
|
||||||
StatCard(
|
StatCard(
|
||||||
title: "Entries",
|
title: "Entries",
|
||||||
value: "\(stats.entryCount)",
|
value: "\(stats.entryCount)",
|
||||||
@@ -97,13 +85,12 @@ struct DashboardView: View {
|
|||||||
color: .green
|
color: .green
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !stats.byProject.isEmpty {
|
if !stats.byProject.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("By Project")
|
Text("By Project")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
ForEach(stats.byProject.prefix(5)) { projectStat in
|
ForEach(stats.byProject.prefix(5)) { projectStat in
|
||||||
HStack {
|
HStack {
|
||||||
if let color = projectStat.projectColor {
|
if let color = projectStat.projectColor {
|
||||||
@@ -112,21 +99,39 @@ struct DashboardView: View {
|
|||||||
Text(projectStat.projectName)
|
Text(projectStat.projectName)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(TimeInterval(projectStat.totalSeconds).formattedHours)
|
Text(TimeInterval(projectStat.totalSeconds).formattedShortDuration)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.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 {
|
private var recentEntriesSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("Recent Entries")
|
Text("Recent Entries")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
if viewModel.recentEntries.isEmpty {
|
if viewModel.recentEntries.isEmpty {
|
||||||
Text("No entries yet")
|
Text("No entries yet")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@@ -134,22 +139,27 @@ struct DashboardView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else {
|
||||||
ForEach(viewModel.recentEntries) { entry in
|
VStack(spacing: 0) {
|
||||||
HStack {
|
ForEach(Array(viewModel.recentEntries.enumerated()), id: \.element.id) { index, entry in
|
||||||
ProjectColorDot(color: entry.project.color, size: 10)
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
ProjectColorDot(color: entry.project.color, size: 10)
|
||||||
Text(entry.project.name)
|
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)
|
.font(.subheadline)
|
||||||
Text(formatDate(entry.startTime))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
.padding(.vertical, 8)
|
||||||
Text(entry.duration.formattedHours)
|
if index < viewModel.recentEntries.count - 1 {
|
||||||
.font(.subheadline)
|
Divider()
|
||||||
.foregroundStyle(.secondary)
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,9 +167,144 @@ struct DashboardView: View {
|
|||||||
.background(Color(.systemGray6))
|
.background(Color(.systemGray6))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatDate(_ isoString: String) -> String {
|
private func formatDate(_ isoString: String) -> String {
|
||||||
guard let date = Date.fromISO8601(isoString) else { return "" }
|
guard let date = Date.fromISO8601(isoString) else { return "" }
|
||||||
return date.formattedDateTime()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,40 +6,42 @@ final class DashboardViewModel: ObservableObject {
|
|||||||
@Published var activeTimer: OngoingTimer?
|
@Published var activeTimer: OngoingTimer?
|
||||||
@Published var statistics: TimeStatistics?
|
@Published var statistics: TimeStatistics?
|
||||||
@Published var recentEntries: [TimeEntry] = []
|
@Published var recentEntries: [TimeEntry] = []
|
||||||
|
@Published var clientTargets: [ClientTarget] = []
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var error: String?
|
@Published var error: String?
|
||||||
@Published var elapsedTime: TimeInterval = 0
|
@Published var elapsedTime: TimeInterval = 0
|
||||||
|
|
||||||
private let apiClient = APIClient()
|
private let apiClient = APIClient()
|
||||||
private let database = DatabaseService.shared
|
private let database = DatabaseService.shared
|
||||||
private var timerTask: Task<Void, Never>?
|
private var timerTask: Task<Void, Never>?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
startElapsedTimeUpdater()
|
startElapsedTimeUpdater()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
timerTask?.cancel()
|
timerTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadData() async {
|
func loadData() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Fetch active timer
|
// Fetch active timer
|
||||||
activeTimer = try await apiClient.request(
|
activeTimer = try await apiClient.request(
|
||||||
endpoint: APIEndpoint.timer,
|
endpoint: APIEndpoint.timer,
|
||||||
authenticated: true
|
authenticated: true
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get statistics for this week
|
// Statistics for this week
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
let today = Date()
|
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 endOfWeek = calendar.date(byAdding: .day, value: 6, to: startOfWeek)!
|
||||||
|
|
||||||
let statsInput = StatisticsFiltersInput(startDate: startOfWeek, endDate: endOfWeek)
|
|
||||||
statistics = try await apiClient.request(
|
statistics = try await apiClient.request(
|
||||||
endpoint: APIEndpoint.timeEntriesStatistics,
|
endpoint: APIEndpoint.timeEntriesStatistics,
|
||||||
queryItems: [
|
queryItems: [
|
||||||
@@ -48,41 +50,43 @@ final class DashboardViewModel: ObservableObject {
|
|||||||
],
|
],
|
||||||
authenticated: true
|
authenticated: true
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch recent entries
|
// Recent entries (last 5)
|
||||||
let entriesResponse: TimeEntryListResponse = try await apiClient.request(
|
let entriesResponse: TimeEntryListResponse = try await apiClient.request(
|
||||||
endpoint: APIEndpoint.timeEntries,
|
endpoint: APIEndpoint.timeEntries,
|
||||||
queryItems: [
|
queryItems: [URLQueryItem(name: "limit", value: "5")],
|
||||||
URLQueryItem(name: "limit", value: "5")
|
|
||||||
],
|
|
||||||
authenticated: true
|
authenticated: true
|
||||||
)
|
)
|
||||||
recentEntries = entriesResponse.entries
|
recentEntries = entriesResponse.entries
|
||||||
|
|
||||||
|
// Client targets (for overtime/undertime)
|
||||||
|
clientTargets = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.clientTargets,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
if let timer = activeTimer {
|
if let timer = activeTimer {
|
||||||
elapsedTime = timer.elapsedTime
|
elapsedTime = timer.elapsedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
} catch {
|
} catch {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
|
||||||
// Try to load cached data
|
// Fallback to cached timer
|
||||||
if let cachedTimer = try? await database.getCachedTimer() {
|
if let cachedTimer = try? await database.getCachedTimer() {
|
||||||
activeTimer = cachedTimer
|
activeTimer = cachedTimer
|
||||||
elapsedTime = cachedTimer.elapsedTime
|
elapsedTime = cachedTimer.elapsedTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startElapsedTimeUpdater() {
|
private func startElapsedTimeUpdater() {
|
||||||
timerTask = Task { [weak self] in
|
timerTask = Task { [weak self] in
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
|
||||||
guard let self = self, self.activeTimer != nil else { continue }
|
guard let self = self, self.activeTimer != nil else { continue }
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.elapsedTime = self.activeTimer?.elapsedTime ?? 0
|
self.elapsedTime = self.activeTimer?.elapsedTime ?? 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,178 +1,3 @@
|
|||||||
import SwiftUI
|
// ProjectsView.swift — replaced by Features/Settings/ProjectsListView.swift
|
||||||
|
// This file is intentionally empty; ProjectsViewModel is no longer used directly.
|
||||||
struct ProjectsView: View {
|
import Foundation
|
||||||
@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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,19 @@ import SwiftUI
|
|||||||
|
|
||||||
struct TimeEntriesView: View {
|
struct TimeEntriesView: View {
|
||||||
@StateObject private var viewModel = TimeEntriesViewModel()
|
@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 showAddEntry = false
|
||||||
|
@State private var entryToEdit: TimeEntry?
|
||||||
@State private var entryToDelete: TimeEntry?
|
@State private var entryToDelete: TimeEntry?
|
||||||
@State private var showDeleteConfirmation = false
|
@State private var showDeleteConfirmation = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
@@ -15,109 +24,338 @@ struct TimeEntriesView: View {
|
|||||||
ErrorView(message: error) {
|
ErrorView(message: error) {
|
||||||
Task { await viewModel.loadEntries() }
|
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 {
|
} else {
|
||||||
entriesList
|
mainContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Time Entries")
|
.navigationTitle("Entries")
|
||||||
.toolbar {
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
ToolbarItem(placement: .primaryAction) {
|
.toolbar { toolbarContent }
|
||||||
Button {
|
.task { await viewModel.loadEntries() }
|
||||||
showAddEntry = true
|
.refreshable { await viewModel.loadEntries() }
|
||||||
} label: {
|
.sheet(isPresented: $showFilterSheet) {
|
||||||
Image(systemName: "plus")
|
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 {
|
// MARK: - Helpers
|
||||||
List {
|
|
||||||
ForEach(viewModel.entries) { entry in
|
private func dayTitle(_ date: Date) -> String {
|
||||||
TimeEntryRow(entry: entry)
|
let cal = Calendar.current
|
||||||
}
|
if cal.isDateInToday(date) { return "Today" }
|
||||||
.onDelete { indexSet in
|
if cal.isDateInYesterday(date) { return "Yesterday" }
|
||||||
if let index = indexSet.first {
|
if cal.isDateInTomorrow(date) { return "Tomorrow" }
|
||||||
entryToDelete = viewModel.entries[index]
|
let formatter = DateFormatter()
|
||||||
showDeleteConfirmation = true
|
formatter.dateFormat = "EEEE, MMM d"
|
||||||
}
|
return formatter.string(from: date)
|
||||||
}
|
|
||||||
}
|
|
||||||
.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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TimeEntryRow: View {
|
// MARK: - Entry Row
|
||||||
|
|
||||||
|
struct EntryRow: View {
|
||||||
let entry: TimeEntry
|
let entry: TimeEntry
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
HStack {
|
// Color dot
|
||||||
ProjectColorDot(color: entry.project.color)
|
ProjectColorDot(color: entry.project.color, size: 12)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(entry.project.name)
|
Text(entry.project.name)
|
||||||
.font(.headline)
|
|
||||||
Spacer()
|
|
||||||
Text(entry.duration.formattedHours)
|
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.fontWeight(.medium)
|
||||||
}
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
HStack {
|
Text(timeRange)
|
||||||
Text(formatDateRange(start: entry.startTime, end: entry.endTime))
|
.font(.caption)
|
||||||
.font(.subheadline)
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
Text("·")
|
||||||
Spacer()
|
.font(.caption)
|
||||||
Text(entry.project.client.name)
|
.foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
Text(entry.project.client.name)
|
||||||
.foregroundStyle(.secondary)
|
.font(.caption)
|
||||||
}
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
if let description = entry.description {
|
|
||||||
Text(description)
|
if let desc = entry.description, !desc.isEmpty {
|
||||||
.font(.caption)
|
Text(desc)
|
||||||
.foregroundStyle(.secondary)
|
.font(.caption)
|
||||||
.lineLimit(2)
|
.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 {
|
private var timeRange: String {
|
||||||
let formatter = DateFormatter()
|
let fmt = DateFormatter()
|
||||||
formatter.dateFormat = "MMM d, HH:mm"
|
fmt.dateFormat = "HH:mm"
|
||||||
|
let start = Date.fromISO8601(entry.startTime).map { fmt.string(from: $0) } ?? ""
|
||||||
guard let startDate = Date.fromISO8601(start),
|
let end = Date.fromISO8601(entry.endTime).map { fmt.string(from: $0) } ?? ""
|
||||||
let endDate = Date.fromISO8601(end) else {
|
return "\(start) – \(end)"
|
||||||
return ""
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))"
|
|
||||||
|
// 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,54 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
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
|
@MainActor
|
||||||
final class TimeEntriesViewModel: ObservableObject {
|
final class TimeEntriesViewModel: ObservableObject {
|
||||||
|
// All loaded entries (flat list, accumulated across pages)
|
||||||
@Published var entries: [TimeEntry] = []
|
@Published var entries: [TimeEntry] = []
|
||||||
@Published var pagination: Pagination?
|
@Published var pagination: Pagination?
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
|
@Published var isLoadingMore = false
|
||||||
@Published var error: String?
|
@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()
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
func loadEntries() async {
|
// MARK: - Fetch
|
||||||
|
|
||||||
|
func loadEntries(resetPage: Bool = true) async {
|
||||||
|
if resetPage { entries = [] }
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
|
|
||||||
do {
|
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(
|
let response: TimeEntryListResponse = try await apiClient.request(
|
||||||
endpoint: APIEndpoint.timeEntries,
|
endpoint: APIEndpoint.timeEntries,
|
||||||
queryItems: queryItems.isEmpty ? nil : queryItems,
|
queryItems: buildQueryItems(page: 1),
|
||||||
authenticated: true
|
authenticated: true
|
||||||
)
|
)
|
||||||
|
|
||||||
entries = response.entries
|
entries = response.entries
|
||||||
pagination = response.pagination
|
pagination = response.pagination
|
||||||
isLoading = false
|
isLoading = false
|
||||||
@@ -48,23 +57,30 @@ final class TimeEntriesViewModel: ObservableObject {
|
|||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextPage() async {
|
func loadMoreIfNeeded(currentEntry entry: TimeEntry) async {
|
||||||
guard let pagination = pagination,
|
guard let pagination, !isLoadingMore,
|
||||||
pagination.page < pagination.totalPages else { return }
|
pagination.page < pagination.totalPages else { return }
|
||||||
|
// Trigger when the last entry in the list becomes visible
|
||||||
filters.page = pagination.page + 1
|
guard entries.last?.id == entry.id else { return }
|
||||||
await loadEntries()
|
|
||||||
|
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 {
|
func deleteEntry(_ entry: TimeEntry) async {
|
||||||
do {
|
do {
|
||||||
try await apiClient.requestVoid(
|
try await apiClient.requestVoid(
|
||||||
@@ -72,10 +88,83 @@ final class TimeEntriesViewModel: ObservableObject {
|
|||||||
method: .delete,
|
method: .delete,
|
||||||
authenticated: true
|
authenticated: true
|
||||||
)
|
)
|
||||||
|
|
||||||
entries.removeAll { $0.id == entry.id }
|
entries.removeAll { $0.id == entry.id }
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
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<Date> {
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,171 +1,2 @@
|
|||||||
import SwiftUI
|
// TimeEntryFormView.swift — replaced by TimeEntryDetailSheet.swift
|
||||||
|
import Foundation
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
62
ios/TimeTracker/TimeTracker/Models/ClientTarget.swift
Normal file
62
ios/TimeTracker/TimeTracker/Models/ClientTarget.swift
Normal file
@@ -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?
|
||||||
|
}
|
||||||
@@ -1,6 +1,29 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "app_icon.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 918 KiB |
@@ -29,18 +29,38 @@ struct StatCard: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension TimeInterval {
|
extension TimeInterval {
|
||||||
|
/// Formats as a clock string used for the live timer display: "1:23:45" or "05:30".
|
||||||
var formattedDuration: String {
|
var formattedDuration: String {
|
||||||
let hours = Int(self) / 3600
|
let totalSeconds = Int(self)
|
||||||
let minutes = (Int(self) % 3600) / 60
|
let hours = totalSeconds / 3600
|
||||||
let seconds = Int(self) % 60
|
let minutes = (totalSeconds % 3600) / 60
|
||||||
|
let seconds = totalSeconds % 60
|
||||||
|
|
||||||
if hours > 0 {
|
if hours > 0 {
|
||||||
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||||
} else {
|
} else {
|
||||||
return String(format: "%02d:%02d", minutes, seconds)
|
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 {
|
var formattedHours: String {
|
||||||
let hours = self / 3600
|
let hours = self / 3600
|
||||||
return String(format: "%.1fh", hours)
|
return String(format: "%.1fh", hours)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.simonfranken.timetracker</string>
|
<string>group.com.simonfranken.timetracker.app</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -31,38 +31,24 @@ struct RootView: View {
|
|||||||
|
|
||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
DashboardView()
|
DashboardView()
|
||||||
.tabItem {
|
.tabItem { Label("Dashboard", systemImage: "chart.bar") }
|
||||||
Label("Dashboard", systemImage: "chart.bar")
|
|
||||||
}
|
|
||||||
.tag(0)
|
.tag(0)
|
||||||
|
|
||||||
TimerView()
|
TimerView()
|
||||||
.tabItem {
|
.tabItem { Label("Timer", systemImage: "timer") }
|
||||||
Label("Timer", systemImage: "timer")
|
|
||||||
}
|
|
||||||
.tag(1)
|
.tag(1)
|
||||||
|
|
||||||
TimeEntriesView()
|
TimeEntriesView()
|
||||||
.tabItem {
|
.tabItem { Label("Entries", systemImage: "calendar") }
|
||||||
Label("Entries", systemImage: "clock")
|
|
||||||
}
|
|
||||||
.tag(2)
|
.tag(2)
|
||||||
|
|
||||||
ProjectsView()
|
SettingsView()
|
||||||
.tabItem {
|
.tabItem { Label("Settings", systemImage: "gearshape") }
|
||||||
Label("Projects", systemImage: "folder")
|
|
||||||
}
|
|
||||||
.tag(3)
|
.tag(3)
|
||||||
|
|
||||||
ClientsView()
|
|
||||||
.tabItem {
|
|
||||||
Label("Clients", systemImage: "person.2")
|
|
||||||
}
|
|
||||||
.tag(4)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.simonfranken.timetracker</string>
|
<string>group.com.simonfranken.timetracker.app</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Reference in New Issue
Block a user