diff --git a/ios/TimeTracker/README.md b/ios/TimeTracker/README.md new file mode 100644 index 0000000..16bd562 --- /dev/null +++ b/ios/TimeTracker/README.md @@ -0,0 +1,89 @@ +# TimeTracker iOS App + +## Setup Instructions + +### Prerequisites + +1. **XcodeGen** - Required to generate the Xcode project + ```bash + # On macOS: + brew install xcodegen + + # Or via npm: + npm install -g xcodegen + ``` + +2. **Xcode** - For building the iOS app (macOS only) + +### Project Generation + +After installing XcodeGen, generate the project: + +```bash +cd ios/TimeTracker +xcodegen generate +``` + +This will create `TimeTracker.xcodeproj` in the `ios/TimeTracker` directory. + +### Configuration + +Before building, configure the API base URL: + +1. Open `TimeTracker.xcodeproj` in Xcode +2. Select the TimeTracker target +3. Go to Info.plist +4. Add or modify `API_BASE_URL` with your backend URL: + - For development: `http://localhost:3001` + - For production: Your actual API URL + +### Building + +Open the project in Xcode and build: + +```bash +open ios/TimeTracker/TimeTracker.xcodeproj +``` + +Then select your target device/simulator and press Cmd+B to build. + +### Authentication Setup + +1. Configure your OIDC provider settings in the backend +2. The iOS app uses ASWebAuthenticationSession for OAuth +3. The callback URL scheme is `timetracker://oauth/callback` + +### App Groups + +For the widget to work with the main app, configure the App Group: +- Identifier: `group.com.timetracker.app` +- This is already configured in the project.yml + +### Dependencies + +The project uses Swift Package Manager for dependencies: +- [SQLite.swift](https://github.com/stephencelis/SQLite.swift) - Local database +- [KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess) - Secure storage + +## Project Structure + +``` +TimeTracker/ +├── TimeTrackerApp/ # App entry point +├── Core/ +│ ├── Network/ # API client +│ ├── Auth/ # Authentication +│ └── Persistence/ # SQLite + sync +├── Features/ +│ ├── Auth/ # Login +│ ├── Timer/ # Timer (core feature) +│ ├── TimeEntries/ # Time entries CRUD +│ ├── Projects/ # Projects CRUD +│ ├── Clients/ # Clients CRUD +│ └── Dashboard/ # Dashboard +├── Models/ # Data models +├── Shared/ # Extensions & components +└── Resources/ # Assets + +TimeTrackerWidget/ # iOS Widget Extension +``` diff --git a/ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift b/ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift new file mode 100644 index 0000000..fe0bdbb --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift @@ -0,0 +1,91 @@ +import Foundation +import KeychainAccess + +@MainActor +final class AuthManager: ObservableObject { + static let shared = AuthManager() + + @Published private(set) var isAuthenticated = false + @Published private(set) var currentUser: User? + + private let keychain: Keychain + private let apiClient = APIClient() + + var accessToken: String? { + get { try? keychain.get(AppConstants.KeychainKeys.accessToken) } + set { + if let value = newValue { + try? keychain.set(value, key: AppConstants.KeychainKeys.accessToken) + } else { + try? keychain.remove(AppConstants.KeychainKeys.accessToken) + } + } + } + + var idToken: String? { + get { try? keychain.get(AppConstants.KeychainKeys.idToken) } + set { + if let value = newValue { + try? keychain.set(value, key: AppConstants.KeychainKeys.idToken) + } else { + try? keychain.remove(AppConstants.KeychainKeys.idToken) + } + } + } + + private init() { + self.keychain = Keychain(service: "com.timetracker.app") + .accessibility(.whenUnlockedThisDeviceOnly) + } + + func checkAuthState() async { + guard accessToken != nil else { + isAuthenticated = false + return + } + + do { + let user: User = try await apiClient.request( + endpoint: APIEndpoint.me, + authenticated: true + ) + currentUser = user + isAuthenticated = true + } catch { + clearAuth() + } + } + + func fetchCurrentUser() async throws -> User { + let user: User = try await apiClient.request( + endpoint: APIEndpoint.me, + authenticated: true + ) + currentUser = user + return user + } + + func logout() async throws { + try await apiClient.requestVoid( + endpoint: APIEndpoint.logout, + method: .post, + authenticated: true + ) + clearAuth() + } + + func clearAuth() { + accessToken = nil + idToken = nil + currentUser = nil + isAuthenticated = false + } + + var loginURL: URL { + APIEndpoints.url(for: APIEndpoint.login) + } + + var callbackURL: String { + AppConfig.authCallbackURL + } +} diff --git a/ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift b/ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift new file mode 100644 index 0000000..50be985 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift @@ -0,0 +1,141 @@ +import Foundation +import AuthenticationServices +import CryptoKit + +final class AuthService: NSObject { + static let shared = AuthService() + + private var authSession: ASWebAuthenticationSession? + private var presentationAnchor: ASPresentationAnchor? + + private override init() { + super.init() + } + + func login(presentationAnchor: ASPresentationAnchor) async throws -> URL? { + self.presentationAnchor = presentationAnchor + + let codeVerifier = generateCodeVerifier() + let codeChallenge = generateCodeChallenge(from: codeVerifier) + + let session = UUID().uuidString + UserDefaults.standard.set(codeVerifier, forKey: "oidc_code_verifier_\(session)") + + var components = URLComponents( + url: AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.login), + resolvingAgainstBaseURL: true + ) + + components?.queryItems = [ + URLQueryItem(name: "session", value: session), + URLQueryItem(name: "code_challenge", value: codeChallenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + URLQueryItem(name: "redirect_uri", value: AppConfig.authCallbackURL) + ] + + guard let authURL = components?.url else { + throw AuthError.invalidURL + } + + let callbackScheme = URL(string: AppConfig.authCallbackURL)?.scheme ?? "timetracker" + + let webAuthSession = ASWebAuthenticationSession( + url: authURL, + callbackURLScheme: callbackScheme + ) { [weak self] callbackURL, error in + if let error = error { + if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin { + throw AuthError.cancelled + } + throw AuthError.failed(error.localizedDescription) + } + + guard let callbackURL = callbackURL else { + throw AuthError.noCallback + } + + self?.handleCallback(url: callbackURL, session: session) + } + + webAuthSession.presentationContextProvider = self + webAuthSession.prefersEphemeralWebBrowserSession = false + + self.authSession = webAuthSession + + return await withCheckedContinuation { continuation in + webAuthSession.start { started in + if !started { + continuation.resume(returning: nil) + } + } + } + } + + private func handleCallback(url: URL, session: String) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { + return + } + + let codeVerifier = UserDefaults.standard.string(forKey: "oidc_code_verifier_\(session)") + + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .authCallbackReceived, + object: nil, + userInfo: [ + "code": code, + "codeVerifier": codeVerifier ?? "" + ] + ) + } + } + + private func generateCodeVerifier() -> String { + var buffer = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) + return Data(buffer).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private func generateCodeChallenge(from verifier: String) -> String { + guard let data = verifier.data(using: .ascii) else { return "" } + let hash = SHA256.hash(data: data) + return Data(hash).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +extension AuthService: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + presentationAnchor ?? ASPresentationAnchor() + } +} + +enum AuthError: LocalizedError { + case invalidURL + case cancelled + case noCallback + case failed(String) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid authentication URL" + case .cancelled: + return "Login was cancelled" + case .noCallback: + return "No callback received" + case .failed(let message): + return "Authentication failed: \(message)" + } + } +} + +extension Notification.Name { + static let authCallbackReceived = Notification.Name("authCallbackReceived") +} diff --git a/ios/TimeTracker/TimeTracker/Core/Constants.swift b/ios/TimeTracker/TimeTracker/Core/Constants.swift new file mode 100644 index 0000000..123180b --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Core/Constants.swift @@ -0,0 +1,32 @@ +import Foundation + +enum AppConstants { + static let appGroupIdentifier = "group.com.timetracker.app" + static let authCallbackScheme = "timetracker" + static let authCallbackHost = "oauth" + + enum UserDefaultsKeys { + static let hasSeenOnboarding = "hasSeenOnboarding" + static let cachedTimer = "cachedTimer" + static let lastSyncDate = "lastSyncDate" + } + + enum KeychainKeys { + static let accessToken = "accessToken" + static let idToken = "idToken" + } +} + +struct AppConfig { + static var apiBaseURL: URL { + if let url = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String, + let baseURL = URL(string: url) { + return baseURL + } + return URL(string: "http://localhost:3001")! + } + + static var authCallbackURL: String { + "\(AppConstants.authCallbackScheme)://\(AppConstants.authCallbackHost)/callback" + } +} diff --git a/ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift b/ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift new file mode 100644 index 0000000..c342917 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift @@ -0,0 +1,160 @@ +import Foundation + +actor APIClient { + private let session: URLSession + private let decoder: JSONDecoder + private let encoder: JSONEncoder + + init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 60 + self.session = URLSession(configuration: config) + + self.decoder = JSONDecoder() + self.encoder = JSONEncoder() + } + + func request( + endpoint: String, + method: HTTPMethod = .get, + body: Encodable? = nil, + queryItems: [URLQueryItem]? = nil, + authenticated: Bool = true + ) async throws -> T { + var urlComponents = URLComponents( + url: APIEndpoints.url(for: endpoint), + resolvingAgainstBaseURL: true + ) + + if let queryItems = queryItems, !queryItems.isEmpty { + urlComponents?.queryItems = queryItems + } + + guard let url = urlComponents?.url else { + throw NetworkError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + if authenticated { + guard let token = AuthManager.shared.accessToken else { + throw NetworkError.unauthorized + } + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + if let body = body { + request.httpBody = try encoder.encode(body) + } + + do { + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.invalidResponse + } + + if httpResponse.statusCode == 401 { + await MainActor.run { + AuthManager.shared.clearAuth() + } + throw NetworkError.unauthorized + } + + guard (200...299).contains(httpResponse.statusCode) else { + let message = try? decoder.decode(ErrorResponse.self, from: data).error + throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: message) + } + + if data.isEmpty { + let empty: T? = nil + return try decoder.decode(T.self, from: "{}".data(using: .utf8)!) + } + + return try decoder.decode(T.self, from: data) + } catch let error as NetworkError { + throw error + } catch let error as DecodingError { + throw NetworkError.decodingError(error) + } catch { + throw NetworkError.networkError(error) + } + } + + func requestVoid( + endpoint: String, + method: HTTPMethod = .get, + body: Encodable? = nil, + queryItems: [URLQueryItem]? = nil, + authenticated: Bool = true + ) async throws { + var urlComponents = URLComponents( + url: APIEndpoints.url(for: endpoint), + resolvingAgainstBaseURL: true + ) + + if let queryItems = queryItems, !queryItems.isEmpty { + urlComponents?.queryItems = queryItems + } + + guard let url = urlComponents?.url else { + throw NetworkError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + if authenticated { + guard let token = AuthManager.shared.accessToken else { + throw NetworkError.unauthorized + } + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + if let body = body { + request.httpBody = try encoder.encode(body) + } + + do { + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.invalidResponse + } + + if httpResponse.statusCode == 401 { + await MainActor.run { + AuthManager.shared.clearAuth() + } + throw NetworkError.unauthorized + } + + guard (200...299).contains(httpResponse.statusCode) else { + let message = try? decoder.decode(ErrorResponse.self, from: data).error + throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: message) + } + } catch let error as NetworkError { + throw error + } catch { + throw NetworkError.networkError(error) + } + } +} + +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case delete = "DELETE" + case patch = "PATCH" +} + +struct ErrorResponse: Codable { + let error: String? +} diff --git a/ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift b/ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift new file mode 100644 index 0000000..c4d80bb --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift @@ -0,0 +1,33 @@ +import Foundation + +enum APIEndpoint { + // Auth + static let login = "/auth/login" + static let callback = "/auth/callback" + static let logout = "/auth/logout" + static let me = "/auth/me" + + // Clients + static let clients = "/api/clients" + static func client(id: String) -> String { "/api/clients/\(id)" } + + // Projects + static let projects = "/api/projects" + static func project(id: String) -> String { "/api/projects/\(id)" } + + // Time Entries + static let timeEntries = "/api/time-entries" + static let timeEntriesStatistics = "/api/time-entries/statistics" + static func timeEntry(id: String) -> String { "/api/time-entries/\(id)" } + + // Timer + static let timer = "/api/timer" + static let timerStart = "/api/timer/start" + static let timerStop = "/api/timer/stop" +} + +struct APIEndpoints { + static func url(for endpoint: String) -> URL { + AppConfig.apiBaseURL.appendingPathComponent(endpoint) + } +} diff --git a/ios/TimeTracker/TimeTracker/Core/Network/NetworkError.swift b/ios/TimeTracker/TimeTracker/Core/Network/NetworkError.swift new file mode 100644 index 0000000..93c713b --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Core/Network/NetworkError.swift @@ -0,0 +1,30 @@ +import Foundation + +enum NetworkError: LocalizedError { + case invalidURL + case invalidResponse + case httpError(statusCode: Int, message: String?) + case decodingError(Error) + case networkError(Error) + case unauthorized + case offline + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .invalidResponse: + return "Invalid response from server" + case .httpError(let statusCode, let message): + return message ?? "HTTP Error: \(statusCode)" + case .decodingError(let error): + return "Failed to decode response: \(error.localizedDescription)" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .unauthorized: + return "Unauthorized. Please log in again." + case .offline: + return "No internet connection" + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Core/Persistence/DatabaseService.swift b/ios/TimeTracker/TimeTracker/Core/Persistence/DatabaseService.swift new file mode 100644 index 0000000..781a501 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Core/Persistence/DatabaseService.swift @@ -0,0 +1,276 @@ +import Foundation +import SQLite + +actor DatabaseService { + static let shared = DatabaseService() + + private var db: Connection? + + private let clients = Table("clients") + private let projects = Table("projects") + private let timeEntries = Table("time_entries") + private let pendingSync = Table("pending_sync") + + // Clients columns + private let id = SQLite.Expression("id") + private let name = SQLite.Expression("name") + private let description = SQLite.Expression("description") + private let createdAt = SQLite.Expression("created_at") + private let updatedAt = SQLite.Expression("updated_at") + + // Projects columns + private let projectClientId = SQLite.Expression("client_id") + private let clientName = SQLite.Expression("client_name") + private let color = SQLite.Expression("color") + + // Time entries columns + private let startTime = SQLite.Expression("start_time") + private let endTime = SQLite.Expression("end_time") + private let projectId = SQLite.Expression("project_id") + private let projectName = SQLite.Expression("project_name") + private let projectColor = SQLite.Expression("project_color") + private let entryDescription = SQLite.Expression("description") + + // Pending sync columns + private let syncId = SQLite.Expression("id") + private let syncType = SQLite.Expression("type") + private let syncAction = SQLite.Expression("action") + private let syncPayload = SQLite.Expression("payload") + private let syncCreatedAt = SQLite.Expression("created_at") + + private init() { + setupDatabase() + } + + private func setupDatabase() { + do { + let fileManager = FileManager.default + let appGroupURL = fileManager.containerURL( + forSecurityApplicationGroupIdentifier: AppConstants.appGroupIdentifier + ) + let dbURL = appGroupURL?.appendingPathComponent("timetracker.sqlite3") + ?? URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("timetracker.sqlite3") + + db = try Connection(dbURL.path) + + try createTables() + } catch { + print("Database setup error: \(error)") + } + } + + private func createTables() throws { + guard let db = db else { return } + + try db.run(clients.create(ifNotExists: true) { t in + t.column(id, primaryKey: true) + t.column(name) + t.column(description) + t.column(createdAt) + t.column(updatedAt) + }) + + try db.run(projects.create(ifNotExists: true) { t in + t.column(id, primaryKey: true) + t.column(name) + t.column(description) + t.column(color) + t.column(projectClientId) + t.column(clientName) + t.column(createdAt) + t.column(updatedAt) + }) + + try db.run(timeEntries.create(ifNotExists: true) { t in + t.column(id, primaryKey: true) + t.column(startTime) + t.column(endTime) + t.column(entryDescription) + t.column(projectId) + t.column(projectName) + t.column(projectColor) + t.column(createdAt) + t.column(updatedAt) + }) + + try db.run(pendingSync.create(ifNotExists: true) { t in + t.column(syncId, primaryKey: true) + t.column(syncType) + t.column(syncAction) + t.column(syncPayload) + t.column(syncCreatedAt) + }) + } + + // MARK: - Clients + + func saveClients(_ clientList: [Client]) throws { + guard let db = db else { return } + + try db.run(clients.delete()) + + for client in clientList { + try db.run(clients.insert( + id <- client.id, + name <- client.name, + description <- client.description, + createdAt <- client.createdAt, + updatedAt <- client.updatedAt + )) + } + } + + func fetchClients() throws -> [Client] { + guard let db = db else { return [] } + + return try db.prepare(clients).map { row in + Client( + id: row[id], + name: row[name], + description: row[description], + createdAt: row[createdAt], + updatedAt: row[updatedAt] + ) + } + } + + // MARK: - Projects + + func saveProjects(_ projectList: [Project]) throws { + guard let db = db else { return } + + try db.run(projects.delete()) + + for project in projectList { + try db.run(projects.insert( + id <- project.id, + name <- project.name, + description <- project.description, + color <- project.color, + projectClientId <- project.clientId, + clientName <- project.client.name, + createdAt <- project.createdAt, + updatedAt <- project.updatedAt + )) + } + } + + func fetchProjects() throws -> [Project] { + guard let db = db else { return [] } + + return try db.prepare(projects).map { row in + let client = ClientReference(id: row[projectClientId], name: row[clientName]) + let projectRef = ProjectReference(id: row[id], name: row[name], color: row[color], client: client) + + return Project( + id: row[id], + name: row[name], + description: row[description], + color: row[color], + clientId: row[projectClientId], + client: client, + createdAt: row[createdAt], + updatedAt: row[updatedAt] + ) + } + } + + // MARK: - Time Entries + + func saveTimeEntries(_ entries: [TimeEntry]) throws { + guard let db = db else { return } + + try db.run(timeEntries.delete()) + + for entry in entries { + try db.run(timeEntries.insert( + id <- entry.id, + startTime <- entry.startTime, + endTime <- entry.endTime, + entryDescription <- entry.description, + projectId <- entry.projectId, + projectName <- entry.project.name, + projectColor <- entry.project.color, + createdAt <- entry.createdAt, + updatedAt <- entry.updatedAt + )) + } + } + + func fetchTimeEntries() throws -> [TimeEntry] { + guard let db = db else { return [] } + + return try db.prepare(timeEntries).map { row in + let client = ClientReference(id: "", name: "") + let projectRef = ProjectReference( + id: row[projectId], + name: row[projectName], + color: row[projectColor], + client: client + ) + + return TimeEntry( + id: row[id], + startTime: row[startTime], + endTime: row[endTime], + description: row[entryDescription], + projectId: row[projectId], + project: projectRef, + createdAt: row[createdAt], + updatedAt: row[updatedAt] + ) + } + } + + // MARK: - Pending Sync + + func addPendingSync(type: String, action: String, payload: String) throws { + guard let db = db else { return } + + try db.run(pendingSync.insert( + syncId <- UUID().uuidString, + syncType <- type, + syncAction <- action, + syncPayload <- payload, + syncCreatedAt <- ISO8601DateFormatter().string(from: Date()) + )) + } + + func fetchPendingSync() throws -> [(id: String, type: String, action: String, payload: String)] { + guard let db = db else { return [] } + + return try db.prepare(pendingSync).map { row in + (row[syncId], row[syncType], row[syncAction], row[syncPayload]) + } + } + + func removePendingSync(id: String) throws { + guard let db = db else { return } + + try db.run(pendingSync.filter(syncId == id).delete()) + } + + // MARK: - Timer Cache + + func cacheTimer(_ timer: OngoingTimer?) throws { + guard let db = db else { return } + + let encoder = JSONEncoder() + + if let timer = timer { + let data = try encoder.encode(timer) + UserDefaults(suiteName: AppConstants.appGroupIdentifier)?.set(data, forKey: AppConstants.UserDefaultsKeys.cachedTimer) + } else { + UserDefaults(suiteName: AppConstants.appGroupIdentifier)?.removeObject(forKey: AppConstants.UserDefaultsKeys.cachedTimer) + } + } + + func getCachedTimer() throws -> OngoingTimer? { + let data = UserDefaults(suiteName: AppConstants.appGroupIdentifier)?.data(forKey: AppConstants.UserDefaultsKeys.cachedTimer) + + guard let data = data else { return nil } + + let decoder = JSONDecoder() + return try decoder.decode(OngoingTimer.self, from: data) + } +} diff --git a/ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift b/ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift new file mode 100644 index 0000000..f655a18 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift @@ -0,0 +1,163 @@ +import Foundation +import Network + +@MainActor +final class SyncManager: ObservableObject { + static let shared = SyncManager() + + @Published private(set) var isOnline = true + @Published private(set) var isSyncing = false + + private let monitor = NWPathMonitor() + private let monitorQueue = DispatchQueue(label: "com.timetracker.networkmonitor") + private let apiClient = APIClient() + private let database = DatabaseService.shared + + private init() { + startNetworkMonitoring() + } + + private func startNetworkMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + DispatchQueue.main.async { + self?.isOnline = path.status == .satisfied + if path.status == .satisfied { + self?.syncPendingChanges() + } + } + } + monitor.start(queue: monitorQueue) + } + + func syncPendingChanges() async { + guard isOnline, !isSyncing else { return } + + isSyncing = true + + do { + let pending = try await database.fetchPendingSync() + + for item in pending { + do { + try await processPendingItem(item) + try await database.removePendingSync(id: item.id) + } catch { + print("Failed to sync item \(item.id): \(error)") + } + } + } catch { + print("Failed to fetch pending sync: \(error)") + } + + isSyncing = false + } + + private func processPendingItem(_ item: (id: String, type: String, action: String, payload: String)) async throws { + let decoder = JSONDecoder() + let encoder = JSONEncoder() + + switch item.type { + case "timeEntry": + let data = item.payload.data(using: .utf8)! + + switch item.action { + case "create": + let input = try decoder.decode(CreateTimeEntryInput.self, from: data) + _ = try await apiClient.request( + endpoint: APIEndpoint.timeEntries, + method: .post, + body: input, + authenticated: true + ) + case "update": + struct UpdateRequest: Codable { + let id: String + let input: UpdateTimeEntryInput + } + let request = try decoder.decode(UpdateRequest.self, from: data) + _ = try await apiClient.request( + endpoint: APIEndpoint.timeEntry(id: request.id), + method: .put, + body: request.input, + authenticated: true + ) + case "delete": + _ = try await apiClient.requestVoid( + endpoint: APIEndpoint.timeEntry(id: item.id), + method: .delete, + authenticated: true + ) + default: + break + } + case "client": + let data = item.payload.data(using: .utf8)! + + switch item.action { + case "create": + let input = try decoder.decode(CreateClientInput.self, from: data) + _ = try await apiClient.request( + endpoint: APIEndpoint.clients, + method: .post, + body: input, + authenticated: true + ) + case "update": + struct UpdateRequest: Codable { + let id: String + let input: UpdateClientInput + } + let request = try decoder.decode(UpdateRequest.self, from: data) + _ = try await apiClient.request( + endpoint: APIEndpoint.client(id: request.id), + method: .put, + body: request.input, + authenticated: true + ) + case "delete": + _ = try await apiClient.requestVoid( + endpoint: APIEndpoint.client(id: item.id), + method: .delete, + authenticated: true + ) + default: + break + } + case "project": + let data = item.payload.data(using: .utf8)! + + switch item.action { + case "create": + let input = try decoder.decode(CreateProjectInput.self, from: data) + _ = try await apiClient.request( + endpoint: APIEndpoint.projects, + method: .post, + body: input, + authenticated: true + ) + case "update": + struct UpdateRequest: Codable { + let id: String + let input: UpdateProjectInput + } + let request = try decoder.decode(UpdateRequest.self, from: data) + _ = try await apiClient.request( + endpoint: APIEndpoint.project(id: request.id), + method: .put, + body: request.input, + authenticated: true + ) + case "delete": + _ = try await apiClient.requestVoid( + endpoint: APIEndpoint.project(id: item.id), + method: .delete, + authenticated: true + ) + default: + break + } + default: + break + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift b/ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift new file mode 100644 index 0000000..d3b4fc8 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift @@ -0,0 +1,87 @@ +import SwiftUI +import AuthenticationServices + +struct LoginView: View { + @EnvironmentObject var authManager: AuthManager + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "timer") + .font(.system(size: 64)) + .foregroundStyle(.accent) + + Text("TimeTracker") + .font(.largeTitle) + .fontWeight(.bold) + + Text("Track your time spent on projects") + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + + if let error = errorMessage { + Text(error) + .font(.subheadline) + .foregroundStyle(.red) + .padding(.horizontal) + } + + Button { + login() + } label: { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(maxWidth: .infinity) + } else { + Text("Sign In") + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isLoading) + .padding(.horizontal, 40) + + Spacer() + .frame(height: 40) + } + .padding() + .onReceive(NotificationCenter.default.publisher(for: .authCallbackReceived)) { notification in + handleAuthCallback(notification.userInfo) + } + } + + private func login() { + isLoading = true + errorMessage = nil + + Task { + do { + let authService = AuthService.shared + await authService.login(presentationAnchor: nil as! ASPresentationAnchor) + } catch { + await MainActor.run { + isLoading = false + errorMessage = error.localizedDescription + } + } + } + } + + private func handleAuthCallback(_ userInfo: [AnyHashable: Any]?) { + // Handle OAuth callback + // In practice, this would exchange the code for tokens + Task { + await authManager.checkAuthState() + await MainActor.run { + isLoading = false + } + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/Clients/ClientsView.swift b/ios/TimeTracker/TimeTracker/Features/Clients/ClientsView.swift new file mode 100644 index 0000000..3eaedff --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/Clients/ClientsView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct ClientsView: View { + @StateObject private var viewModel = ClientsViewModel() + @State private var showAddClient = 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 + Task { + for index in indexSet { + await viewModel.deleteClient(viewModel.clients[index]) + } + } + } + } + .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) + } + } + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/Clients/ClientsViewModel.swift b/ios/TimeTracker/TimeTracker/Features/Clients/ClientsViewModel.swift new file mode 100644 index 0000000..d8f7510 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/Clients/ClientsViewModel.swift @@ -0,0 +1,87 @@ +import Foundation +import SwiftUI + +@MainActor +final class ClientsViewModel: ObservableObject { + @Published var clients: [Client] = [] + @Published var isLoading = false + @Published var error: String? + + private let apiClient = APIClient() + private let database = DatabaseService.shared + + func loadClients() async { + isLoading = true + error = nil + + do { + let response: ClientListResponse = try await apiClient.request( + endpoint: APIEndpoint.clients, + authenticated: true + ) + clients = response.clients + + try await database.saveClients(clients) + + isLoading = false + } catch { + isLoading = false + self.error = error.localizedDescription + + // Load from cache + clients = (try? await database.fetchClients()) ?? [] + } + } + + func createClient(name: String, description: String?) async { + isLoading = true + + do { + let input = CreateClientInput(name: name, description: description) + _ = try await apiClient.request( + endpoint: APIEndpoint.clients, + method: .post, + body: input, + authenticated: true + ) + + await loadClients() + } catch { + isLoading = false + self.error = error.localizedDescription + } + } + + func updateClient(id: String, name: String, description: String?) async { + isLoading = true + + do { + let input = UpdateClientInput(name: name, description: description) + _ = try await apiClient.request( + endpoint: APIEndpoint.client(id: id), + method: .put, + body: input, + authenticated: true + ) + + await loadClients() + } catch { + isLoading = false + self.error = error.localizedDescription + } + } + + 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 + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardView.swift b/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardView.swift new file mode 100644 index 0000000..3ea5e63 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardView.swift @@ -0,0 +1,165 @@ +import SwiftUI + +struct DashboardView: View { + @StateObject private var viewModel = DashboardViewModel() + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 24) { + // Active Timer Card + timerCard + + // Weekly Stats + if let stats = viewModel.statistics { + statsSection(stats) + } + + // Recent Entries + recentEntriesSection + } + .padding() + } + .navigationTitle("Dashboard") + .refreshable { + await viewModel.loadData() + } + .task { + await viewModel.loadData() + } + } + } + + private var timerCard: some View { + VStack(spacing: 16) { + if let timer = viewModel.activeTimer { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Timer Running") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(viewModel.elapsedTime.formattedDuration) + .font(.system(size: 32, weight: .medium, design: .monospaced)) + + if let project = timer.project { + ProjectColorBadge(color: project.color, name: project.name) + } + } + + Spacer() + + Image(systemName: "timer") + .font(.title) + .foregroundStyle(.green) + } + } else { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("No Active Timer") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text("Start tracking to see your time") + .font(.headline) + } + + Spacer() + + Image(systemName: "timer") + .font(.title) + .foregroundStyle(.secondary) + } + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + } + + private func statsSection(_ stats: TimeStatistics) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("This Week") + .font(.headline) + + HStack(spacing: 12) { + StatCard( + title: "Hours Tracked", + value: TimeInterval(stats.totalSeconds).formattedHours, + icon: "clock.fill", + color: .blue + ) + + StatCard( + title: "Entries", + value: "\(stats.entryCount)", + icon: "list.bullet", + color: .green + ) + } + + if !stats.byProject.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("By Project") + .font(.subheadline) + .foregroundStyle(.secondary) + + ForEach(stats.byProject.prefix(5)) { projectStat in + HStack { + if let color = projectStat.projectColor { + ProjectColorDot(color: color) + } + Text(projectStat.projectName) + .font(.subheadline) + Spacer() + Text(TimeInterval(projectStat.totalSeconds).formattedHours) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } + } + } + + private var recentEntriesSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Recent Entries") + .font(.headline) + + if viewModel.recentEntries.isEmpty { + Text("No entries yet") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else { + ForEach(viewModel.recentEntries) { entry in + HStack { + ProjectColorDot(color: entry.project.color, size: 10) + VStack(alignment: .leading, spacing: 2) { + Text(entry.project.name) + .font(.subheadline) + Text(formatDate(entry.startTime)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(entry.duration.formattedHours) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + } + + private func formatDate(_ isoString: String) -> String { + guard let date = Date.fromISO8601(isoString) else { return "" } + return date.formattedDateTime() + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardViewModel.swift b/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardViewModel.swift new file mode 100644 index 0000000..e4dd3e7 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardViewModel.swift @@ -0,0 +1,92 @@ +import Foundation +import SwiftUI + +@MainActor +final class DashboardViewModel: ObservableObject { + @Published var activeTimer: OngoingTimer? + @Published var statistics: TimeStatistics? + @Published var recentEntries: [TimeEntry] = [] + @Published var isLoading = false + @Published var error: String? + @Published var elapsedTime: TimeInterval = 0 + + private let apiClient = APIClient() + private let database = DatabaseService.shared + private var timerTask: Task? + + init() { + startElapsedTimeUpdater() + } + + deinit { + timerTask?.cancel() + } + + func loadData() async { + isLoading = true + error = nil + + do { + // Fetch active timer + activeTimer = try await apiClient.request( + endpoint: APIEndpoint.timer, + authenticated: true + ) + + // Get statistics for this week + let calendar = Calendar.current + let today = Date() + let startOfWeek = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))! + let endOfWeek = calendar.date(byAdding: .day, value: 6, to: startOfWeek)! + + let statsInput = StatisticsFiltersInput(startDate: startOfWeek, endDate: endOfWeek) + statistics = try await apiClient.request( + endpoint: APIEndpoint.timeEntriesStatistics, + queryItems: [ + URLQueryItem(name: "startDate", value: startOfWeek.iso8601FullDate), + URLQueryItem(name: "endDate", value: endOfWeek.iso8601FullDate) + ], + authenticated: true + ) + + // Fetch recent entries + let entriesResponse: TimeEntryListResponse = try await apiClient.request( + endpoint: APIEndpoint.timeEntries, + queryItems: [ + URLQueryItem(name: "limit", value: "5") + ], + authenticated: true + ) + recentEntries = entriesResponse.entries + + if let timer = activeTimer { + elapsedTime = timer.elapsedTime + } + + isLoading = false + } catch { + isLoading = false + self.error = error.localizedDescription + + // Try to load cached data + if let cachedTimer = try? await database.getCachedTimer() { + activeTimer = cachedTimer + elapsedTime = cachedTimer.elapsedTime + } + } + } + + private func startElapsedTimeUpdater() { + timerTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + + guard let self = self, self.activeTimer != nil else { continue } + + await MainActor.run { + self.elapsedTime = self.activeTimer?.elapsedTime ?? 0 + } + } + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsView.swift b/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsView.swift new file mode 100644 index 0000000..37bdfac --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsView.swift @@ -0,0 +1,167 @@ +import SwiftUI + +struct ProjectsView: View { + @StateObject private var viewModel = ProjectsViewModel() + @State private var showAddProject = 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 + Task { + for index in indexSet { + await viewModel.deleteProject(viewModel.projects[index]) + } + } + } + } + .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) + } + } + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsViewModel.swift b/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsViewModel.swift new file mode 100644 index 0000000..cf59d55 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsViewModel.swift @@ -0,0 +1,104 @@ +import Foundation +import SwiftUI + +@MainActor +final class ProjectsViewModel: ObservableObject { + @Published var projects: [Project] = [] + @Published var clients: [Client] = [] + @Published var isLoading = false + @Published var error: String? + + private let apiClient = APIClient() + private let database = DatabaseService.shared + + func loadData() async { + isLoading = true + error = nil + + do { + let clientsResponse: ClientListResponse = try await apiClient.request( + endpoint: APIEndpoint.clients, + authenticated: true + ) + clients = clientsResponse.clients + + let projectsResponse: ProjectListResponse = try await apiClient.request( + endpoint: APIEndpoint.projects, + authenticated: true + ) + projects = projectsResponse.projects + + try await database.saveProjects(projects) + + isLoading = false + } catch { + isLoading = false + self.error = error.localizedDescription + + // Load from cache + projects = (try? await database.fetchProjects()) ?? [] + } + } + + func createProject(name: String, description: String?, color: String?, clientId: String) async { + isLoading = true + + do { + let input = CreateProjectInput( + name: name, + description: description, + color: color, + clientId: clientId + ) + _ = try await apiClient.request( + endpoint: APIEndpoint.projects, + method: .post, + body: input, + authenticated: true + ) + + await loadData() + } catch { + isLoading = false + self.error = error.localizedDescription + } + } + + func updateProject(id: String, name: String, description: String?, color: String?, clientId: String) async { + isLoading = true + + do { + let input = UpdateProjectInput( + name: name, + description: description, + color: color, + clientId: clientId + ) + _ = try await apiClient.request( + endpoint: APIEndpoint.project(id: id), + method: .put, + body: input, + authenticated: true + ) + + await loadData() + } catch { + isLoading = false + self.error = error.localizedDescription + } + } + + 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 + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift new file mode 100644 index 0000000..56623e5 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift @@ -0,0 +1,112 @@ +import SwiftUI + +struct TimeEntriesView: View { + @StateObject private var viewModel = TimeEntriesViewModel() + @State private var showAddEntry = false + + var body: some View { + NavigationStack { + Group { + if viewModel.isLoading && viewModel.entries.isEmpty { + LoadingView() + } else if let error = viewModel.error, viewModel.entries.isEmpty { + ErrorView(message: error) { + Task { await viewModel.loadEntries() } + } + } else if viewModel.entries.isEmpty { + EmptyView( + icon: "clock", + title: "No Time Entries", + message: "Start tracking your time to see entries here." + ) + } else { + entriesList + } + } + .navigationTitle("Time Entries") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showAddEntry = true + } label: { + Image(systemName: "plus") + } + } + } + .task { + await viewModel.loadEntries() + } + .sheet(isPresented: $showAddEntry) { + TimeEntryFormView(onSave: { + Task { await viewModel.loadEntries() } + }) + } + } + } + + private var entriesList: some View { + List { + ForEach(viewModel.entries) { entry in + TimeEntryRow(entry: entry) + } + .onDelete { indexSet in + Task { + for index in indexSet { + await viewModel.deleteEntry(viewModel.entries[index]) + } + } + } + } + .refreshable { + await viewModel.loadEntries() + } + } +} + +struct TimeEntryRow: View { + let entry: TimeEntry + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + ProjectColorDot(color: entry.project.color) + Text(entry.project.name) + .font(.headline) + Spacer() + Text(entry.duration.formattedHours) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + HStack { + Text(formatDateRange(entry.startTime, entry.endTime)) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Text(entry.project.client.name) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let description = entry.description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 4) + } + + private func formatDateRange(start: String, end: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, HH:mm" + + guard let startDate = Date.fromISO8601(start), + let endDate = Date.fromISO8601(end) else { + return "" + } + + return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))" + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesViewModel.swift b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesViewModel.swift new file mode 100644 index 0000000..cac652a --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesViewModel.swift @@ -0,0 +1,81 @@ +import Foundation +import SwiftUI + +@MainActor +final class TimeEntriesViewModel: ObservableObject { + @Published var entries: [TimeEntry] = [] + @Published var pagination: Pagination? + @Published var isLoading = false + @Published var error: String? + @Published var filters = TimeEntryFilters() + + private let apiClient = APIClient() + + func loadEntries() async { + isLoading = true + error = nil + + do { + var queryItems: [URLQueryItem] = [] + + if let startDate = filters.startDate { + queryItems.append(URLQueryItem(name: "startDate", value: startDate)) + } + if let endDate = filters.endDate { + queryItems.append(URLQueryItem(name: "endDate", value: endDate)) + } + if let projectId = filters.projectId { + queryItems.append(URLQueryItem(name: "projectId", value: projectId)) + } + if let page = filters.page { + queryItems.append(URLQueryItem(name: "page", value: String(page))) + } + if let limit = filters.limit { + queryItems.append(URLQueryItem(name: "limit", value: String(limit))) + } + + let response: TimeEntryListResponse = try await apiClient.request( + endpoint: APIEndpoint.timeEntries, + queryItems: queryItems.isEmpty ? nil : queryItems, + authenticated: true + ) + + entries = response.entries + pagination = response.pagination + isLoading = false + } catch { + isLoading = false + self.error = error.localizedDescription + } + } + + func nextPage() async { + guard let pagination = pagination, + pagination.page < pagination.totalPages else { return } + + filters.page = pagination.page + 1 + await loadEntries() + } + + func previousPage() async { + guard let pagination = pagination, + pagination.page > 1 else { return } + + filters.page = pagination.page - 1 + await loadEntries() + } + + func deleteEntry(_ entry: TimeEntry) async { + do { + try await apiClient.requestVoid( + endpoint: APIEndpoint.timeEntry(id: entry.id), + method: .delete, + authenticated: true + ) + + entries.removeAll { $0.id == entry.id } + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryFormView.swift b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryFormView.swift new file mode 100644 index 0000000..81394ca --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryFormView.swift @@ -0,0 +1,171 @@ +import SwiftUI + +struct TimeEntryFormView: View { + @Environment(\.dismiss) private var dismiss + + let entry: TimeEntry? + let onSave: () -> Void + + @State private var startDate = Date() + @State private var startTime = Date() + @State private var endDate = Date() + @State private var endTime = Date() + @State private var description = "" + @State private var selectedProject: Project? + @State private var projects: [Project] = [] + @State private var isLoading = false + @State private var error: String? + + private let apiClient = APIClient() + + init(entry: TimeEntry? = nil, onSave: @escaping () -> Void) { + self.entry = entry + self.onSave = onSave + } + + var body: some View { + NavigationStack { + Form { + Section("Project") { + Picker("Project", selection: $selectedProject) { + Text("Select Project").tag(nil as Project?) + ForEach(projects) { project in + HStack { + ProjectColorDot(color: project.color) + Text(project.name) + } + .tag(project as Project?) + } + } + } + + Section("Start Time") { + DatePicker("Date", selection: $startDate, displayedComponents: .date) + DatePicker("Time", selection: $startTime, displayedComponents: .hourAndMinute) + } + + Section("End Time") { + DatePicker("Date", selection: $endDate, displayedComponents: .date) + DatePicker("Time", selection: $endTime, displayedComponents: .hourAndMinute) + } + + Section("Description (Optional)") { + TextField("Description", text: $description, axis: .vertical) + .lineLimit(3...6) + } + } + .navigationTitle(entry == nil ? "New Entry" : "Edit Entry") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + save() + } + .disabled(selectedProject == nil || isLoading) + } + } + .task { + await loadProjects() + if let entry = entry { + await loadEntry(entry) + } + } + } + } + + private func loadProjects() async { + do { + let response: ProjectListResponse = try await apiClient.request( + endpoint: APIEndpoint.projects, + authenticated: true + ) + projects = response.projects + } 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, + on: startDate) ?? startDate + let endDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: endTime), + minute: calendar.component(.minute, from: endTime), + second: 0, + on: 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.request( + 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.request( + endpoint: APIEndpoint.timeEntries, + method: .post, + body: input, + authenticated: true + ) + } + + await MainActor.run { + isLoading = false + dismiss() + onSave() + } + } catch { + await MainActor.run { + isLoading = false + error = error.localizedDescription + } + } + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift b/ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift new file mode 100644 index 0000000..a00b469 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift @@ -0,0 +1,175 @@ +import SwiftUI + +struct TimerView: View { + @StateObject private var viewModel = TimerViewModel() + @State private var showProjectPicker = false + + var body: some View { + NavigationStack { + Group { + if viewModel.isLoading && viewModel.activeTimer == nil { + LoadingView() + } else if let error = viewModel.error, viewModel.activeTimer == nil { + ErrorView(message: error) { + Task { await viewModel.loadData() } + } + } else { + timerContent + } + } + .navigationTitle("Timer") + .task { + await viewModel.loadData() + } + .sheet(isPresented: $showProjectPicker) { + ProjectPickerSheet( + projects: viewModel.projects, + selectedProject: viewModel.selectedProject + ) { project in + Task { + await viewModel.updateProject(project) + } + } + } + } + } + + private var timerContent: some View { + VStack(spacing: 32) { + Spacer() + + // Timer Display + VStack(spacing: 8) { + Text(viewModel.elapsedTime.formattedDuration) + .font(.system(size: 64, weight: .light, design: .monospaced)) + .foregroundStyle(viewModel.activeTimer != nil ? .primary : .secondary) + + if let project = viewModel.selectedProject ?? viewModel.activeTimer?.project { + ProjectColorBadge( + color: project.color, + name: project.name + ) + } else { + Text("No project selected") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + Spacer() + + // Controls + VStack(spacing: 16) { + if viewModel.activeTimer == nil { + Button { + showProjectPicker = true + } label: { + HStack { + Image(systemName: "folder") + Text(viewModel.selectedProject?.name ?? "Select Project") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button { + Task { await viewModel.startTimer() } + } label: { + HStack { + Image(systemName: "play.fill") + Text("Start Timer") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } else { + Button { + showProjectPicker = true + } label: { + HStack { + Image(systemName: "folder") + Text("Change Project") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button { + Task { await viewModel.stopTimer() } + } label: { + HStack { + Image(systemName: "stop.fill") + Text("Stop Timer") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .controlSize(.large) + } + } + .padding(.horizontal, 24) + + Spacer() + } + } +} + +struct ProjectPickerSheet: View { + let projects: [Project] + let selectedProject: Project? + let onSelect: (Project?) -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Button { + onSelect(nil) + dismiss() + } label: { + HStack { + Text("No Project") + .foregroundStyle(.primary) + if selectedProject == nil { + Image(systemName: "checkmark") + .foregroundStyle(.accent) + } + } + } + + ForEach(projects) { project in + Button { + onSelect(project) + dismiss() + } label: { + HStack { + ProjectColorDot(color: project.color) + Text(project.name) + .foregroundStyle(.primary) + Text(project.client.name) + .foregroundStyle(.secondary) + if selectedProject?.id == project.id { + Image(systemName: "checkmark") + .foregroundStyle(.accent) + } + } + } + } + } + .navigationTitle("Select Project") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift b/ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift new file mode 100644 index 0000000..f2bcc93 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift @@ -0,0 +1,160 @@ +import Foundation +import SwiftUI + +@MainActor +final class TimerViewModel: ObservableObject { + @Published var activeTimer: OngoingTimer? + @Published var projects: [Project] = [] + @Published var selectedProject: Project? + @Published var isLoading = false + @Published var error: String? + @Published var elapsedTime: TimeInterval = 0 + + private let apiClient = APIClient() + private let database = DatabaseService.shared + private var timerTask: Task? + + init() { + startElapsedTimeUpdater() + } + + deinit { + timerTask?.cancel() + } + + func loadData() async { + isLoading = true + error = nil + + do { + // Fetch active timer + activeTimer = try await apiClient.request( + endpoint: APIEndpoint.timer, + authenticated: true + ) + + // Cache timer for widget + try await database.cacheTimer(activeTimer) + + // Fetch projects + let response: ProjectListResponse = try await apiClient.request( + endpoint: APIEndpoint.projects, + authenticated: true + ) + projects = response.projects + + // Set selected project if timer has one + if let timerProject = activeTimer?.project { + selectedProject = projects.first { $0.id == timerProject.id } + } + + // Calculate elapsed time + if let timer = activeTimer { + elapsedTime = timer.elapsedTime + } + + isLoading = false + } catch { + isLoading = false + self.error = error.localizedDescription + + // Try to load cached data + if let cachedTimer = try? await database.getCachedTimer() { + activeTimer = cachedTimer + elapsedTime = cachedTimer.elapsedTime + } + } + } + + func startTimer() async { + isLoading = true + error = nil + + do { + let input = StartTimerInput(projectId: selectedProject?.id) + activeTimer = try await apiClient.request( + endpoint: APIEndpoint.timerStart, + method: .post, + body: input, + authenticated: true + ) + + try await database.cacheTimer(activeTimer) + + if let timer = activeTimer { + elapsedTime = timer.elapsedTime + } + + isLoading = false + } catch { + isLoading = false + self.error = error.localizedDescription + } + } + + func stopTimer() async { + guard let timer = activeTimer else { return } + + isLoading = true + error = nil + + let projectId = selectedProject?.id ?? timer.projectId ?? "" + + do { + let input = StopTimerInput(projectId: projectId) + _ = try await apiClient.request( + endpoint: APIEndpoint.timerStop, + method: .post, + body: input, + authenticated: true + ) + + activeTimer = nil + selectedProject = nil + elapsedTime = 0 + + try await database.cacheTimer(nil) + + isLoading = false + } catch { + isLoading = false + self.error = error.localizedDescription + } + } + + func updateProject(_ project: Project?) async { + selectedProject = project + + guard let timer = activeTimer else { return } + + do { + guard let projectId = project?.id else { return } + + let input = UpdateTimerInput(projectId: projectId) + activeTimer = try await apiClient.request( + endpoint: APIEndpoint.timer, + method: .put, + body: input, + authenticated: true + ) + + try await database.cacheTimer(activeTimer) + } catch { + self.error = error.localizedDescription + } + } + + private func startElapsedTimeUpdater() { + timerTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + + guard let self = self, self.activeTimer != nil else { continue } + + await MainActor.run { + self.elapsedTime = self.activeTimer?.elapsedTime ?? 0 + } + } + } + } +} diff --git a/ios/TimeTracker/TimeTracker/Info.plist b/ios/TimeTracker/TimeTracker/Info.plist new file mode 100644 index 0000000..8105d63 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Info.plist @@ -0,0 +1,66 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIColorName + LaunchBackground + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.timetracker.app + CFBundleURLSchemes + + timetracker + + + + API_BASE_URL + $(API_BASE_URL) + + diff --git a/ios/TimeTracker/TimeTracker/Models/Client.swift b/ios/TimeTracker/TimeTracker/Models/Client.swift new file mode 100644 index 0000000..9d683ba --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Models/Client.swift @@ -0,0 +1,23 @@ +import Foundation + +struct Client: Codable, Identifiable, Equatable { + let id: String + let name: String + let description: String? + let createdAt: String + let updatedAt: String +} + +struct ClientListResponse: Codable { + let clients: [Client] +} + +struct CreateClientInput: Codable { + let name: String + let description: String? +} + +struct UpdateClientInput: Codable { + let name: String? + let description: String? +} diff --git a/ios/TimeTracker/TimeTracker/Models/OngoingTimer.swift b/ios/TimeTracker/TimeTracker/Models/OngoingTimer.swift new file mode 100644 index 0000000..f4b8714 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Models/OngoingTimer.swift @@ -0,0 +1,41 @@ +import Foundation + +struct OngoingTimer: Codable, Identifiable, Equatable { + let id: String + let startTime: String + let projectId: String? + let project: ProjectReference? + let createdAt: String + let updatedAt: String + + var elapsedTime: TimeInterval { + guard let start = ISO8601DateFormatter().date(from: startTime) else { + return 0 + } + return Date().timeIntervalSince(start) + } +} + +struct StartTimerInput: Codable { + let projectId: String? + + init(projectId: String? = nil) { + self.projectId = projectId + } +} + +struct UpdateTimerInput: Codable { + let projectId: String + + init(projectId: String) { + self.projectId = projectId + } +} + +struct StopTimerInput: Codable { + let projectId: String + + init(projectId: String) { + self.projectId = projectId + } +} diff --git a/ios/TimeTracker/TimeTracker/Models/Project.swift b/ios/TimeTracker/TimeTracker/Models/Project.swift new file mode 100644 index 0000000..4079baf --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Models/Project.swift @@ -0,0 +1,35 @@ +import Foundation + +struct Project: Codable, Identifiable, Equatable { + let id: String + let name: String + let description: String? + let color: String? + let clientId: String + let client: ClientReference + let createdAt: String + let updatedAt: String +} + +struct ClientReference: Codable, Equatable { + let id: String + let name: String +} + +struct ProjectListResponse: Codable { + let projects: [Project] +} + +struct CreateProjectInput: Codable { + let name: String + let description: String? + let color: String? + let clientId: String +} + +struct UpdateProjectInput: Codable { + let name: String? + let description: String? + let color: String? + let clientId: String? +} diff --git a/ios/TimeTracker/TimeTracker/Models/TimeEntry.swift b/ios/TimeTracker/TimeTracker/Models/TimeEntry.swift new file mode 100644 index 0000000..bf553f5 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Models/TimeEntry.swift @@ -0,0 +1,91 @@ +import Foundation + +struct TimeEntry: Codable, Identifiable, Equatable { + let id: String + let startTime: String + let endTime: String + let description: String? + let projectId: String + let project: ProjectReference + let createdAt: String + let updatedAt: String + + var duration: TimeInterval { + guard let start = ISO8601DateFormatter().date(from: startTime), + let end = ISO8601DateFormatter().date(from: endTime) else { + return 0 + } + return end.timeIntervalSince(start) + } +} + +struct ProjectReference: Codable, Equatable { + let id: String + let name: String + let color: String? + let client: ClientReference +} + +struct TimeEntryListResponse: Codable { + let entries: [TimeEntry] + let pagination: Pagination +} + +struct Pagination: Codable, Equatable { + let page: Int + let limit: Int + let total: Int + let totalPages: Int +} + +struct TimeEntryFilters: Codable { + let startDate: String? + let endDate: String? + let projectId: String? + let clientId: String? + let page: Int? + let limit: Int? + + init( + startDate: Date? = nil, + endDate: Date? = nil, + projectId: String? = nil, + clientId: String? = nil, + page: Int = 1, + limit: Int = 20 + ) { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate] + + self.startDate = startDate.map { formatter.string(from: $0) } + self.endDate = endDate.map { formatter.string(from: $0) } + self.projectId = projectId + self.clientId = clientId + self.page = page + self.limit = limit + } +} + +struct CreateTimeEntryInput: Codable { + let startTime: String + let endTime: String + let description: String? + let projectId: String + + init(startTime: Date, endTime: Date, description: String? = nil, projectId: String) { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + + self.startTime = formatter.string(from: startTime) + self.endTime = formatter.string(from: endTime) + self.description = description + self.projectId = projectId + } +} + +struct UpdateTimeEntryInput: Codable { + let startTime: String? + let endTime: String? + let description: String? + let projectId: String? +} diff --git a/ios/TimeTracker/TimeTracker/Models/TimeStatistics.swift b/ios/TimeTracker/TimeTracker/Models/TimeStatistics.swift new file mode 100644 index 0000000..c63cf4c --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Models/TimeStatistics.swift @@ -0,0 +1,57 @@ +import Foundation + +struct TimeStatistics: Codable, Equatable { + let totalSeconds: Int + let entryCount: Int + let byProject: [ProjectStatistics] + let byClient: [ClientStatistics] + let filters: StatisticsFilters +} + +struct ProjectStatistics: Codable, Identifiable, Equatable { + let projectId: String + let projectName: String + let projectColor: String? + let totalSeconds: Int + let entryCount: Int + + var id: String { projectId } +} + +struct ClientStatistics: Codable, Identifiable, Equatable { + let clientId: String + let clientName: String + let totalSeconds: Int + let entryCount: Int + + var id: String { clientId } +} + +struct StatisticsFilters: Codable, Equatable { + let startDate: String? + let endDate: String? + let projectId: String? + let clientId: String? +} + +struct StatisticsFiltersInput: Codable { + let startDate: String? + let endDate: String? + let projectId: String? + let clientId: String? + + init( + startDate: Date? = nil, + endDate: Date? = nil, + projectId: String? = nil, + clientId: String? = nil + ) { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate] + + self.startDate = startDate.map { formatter.string(from: $0) } + self.endDate = endDate.map { formatter.string(from: $0) } + self.projectId = projectId + self.clientId = clientId + } +} diff --git a/ios/TimeTracker/TimeTracker/Models/User.swift b/ios/TimeTracker/TimeTracker/Models/User.swift new file mode 100644 index 0000000..51f1316 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Models/User.swift @@ -0,0 +1,19 @@ +import Foundation + +struct User: Codable, Equatable { + let id: String + let username: String + let fullName: String? + let email: String +} + +struct UserResponse: Codable { + let id: String + let username: String + let fullName: String? + let email: String + + func toUser() -> User { + User(id: id, username: username, fullName: fullName, email: email) + } +} diff --git a/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..aa06b69 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.984", + "green" : "0.584", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/Contents.json b/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json b/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..97650a1 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/TimeTracker/TimeTracker/Shared/Components/LoadingView.swift b/ios/TimeTracker/TimeTracker/Shared/Components/LoadingView.swift new file mode 100644 index 0000000..1944bf8 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Shared/Components/LoadingView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct LoadingView: View { + var message: String = "Loading..." + + var body: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.2) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct ErrorView: View { + let message: String + var retryAction: (() -> Void)? + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.red) + + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if let retryAction = retryAction { + Button("Retry", action: retryAction) + .buttonStyle(.borderedProminent) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct EmptyView: View { + let icon: String + let title: String + var message: String? = nil + + var body: some View { + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundStyle(.secondary) + + Text(title) + .font(.headline) + + if let message = message { + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/ios/TimeTracker/TimeTracker/Shared/Components/ProjectColorDot.swift b/ios/TimeTracker/TimeTracker/Shared/Components/ProjectColorDot.swift new file mode 100644 index 0000000..a57e774 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Shared/Components/ProjectColorDot.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct ProjectColorDot: View { + let color: String? + var size: CGFloat = 12 + + var body: some View { + Circle() + .fill(colorValue) + .frame(width: size, height: size) + } + + private var colorValue: Color { + if let hex = color { + return Color(hex: hex) + } + return .gray + } +} + +struct ProjectColorBadge: View { + let color: String? + let name: String + + var body: some View { + HStack(spacing: 8) { + ProjectColorDot(color: color, size: 10) + Text(name) + .font(.subheadline) + .foregroundStyle(.primary) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(.systemGray6)) + .cornerRadius(8) + } +} diff --git a/ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift b/ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift new file mode 100644 index 0000000..70ccfdb --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct StatCard: View { + let title: String + let value: String + let icon: String + var color: Color = .accentColor + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: icon) + .foregroundStyle(color) + Text(title) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Text(value) + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.primary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + } +} + +extension TimeInterval { + var formattedDuration: String { + let hours = Int(self) / 3600 + let minutes = (Int(self) % 3600) / 60 + let seconds = Int(self) % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%02d:%02d", minutes, seconds) + } + } + + var formattedHours: String { + let hours = self / 3600 + return String(format: "%.1fh", hours) + } +} diff --git a/ios/TimeTracker/TimeTracker/Shared/Extensions/Color+Extensions.swift b/ios/TimeTracker/TimeTracker/Shared/Extensions/Color+Extensions.swift new file mode 100644 index 0000000..c8b2144 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Shared/Extensions/Color+Extensions.swift @@ -0,0 +1,46 @@ +import SwiftUI + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } + + static let defaultProjectColors: [Color] = [ + Color(hex: "EF4444"), // Red + Color(hex: "F97316"), // Orange + Color(hex: "EAB308"), // Yellow + Color(hex: "22C55E"), // Green + Color(hex: "14B8A6"), // Teal + Color(hex: "06B6D4"), // Cyan + Color(hex: "3B82F6"), // Blue + Color(hex: "6366F1"), // Indigo + Color(hex: "A855F7"), // Purple + Color(hex: "EC4899"), // Pink + ] + + static func projectColor(for index: Int) -> Color { + defaultProjectColors[index % defaultProjectColors.count] + } +} diff --git a/ios/TimeTracker/TimeTracker/Shared/Extensions/Date+Extensions.swift b/ios/TimeTracker/TimeTracker/Shared/Extensions/Date+Extensions.swift new file mode 100644 index 0000000..21551a7 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Shared/Extensions/Date+Extensions.swift @@ -0,0 +1,66 @@ +import Foundation + +extension Date { + var startOfDay: Date { + Calendar.current.startOfDay(for: self) + } + + var endOfDay: Date { + var components = DateComponents() + components.day = 1 + components.second = -1 + return Calendar.current.date(byAdding: components, to: startOfDay) ?? self + } + + var startOfWeek: Date { + let calendar = Calendar.current + let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: self) + return calendar.date(from: components) ?? self + } + + var endOfWeek: Date { + var components = DateComponents() + components.day = 7 + components.second = -1 + return Calendar.current.date(byAdding: components, to: startOfWeek) ?? self + } + + func formatted(style: DateFormatter.Style) -> String { + let formatter = DateFormatter() + formatter.dateStyle = style + formatter.timeStyle = .none + return formatter.string(from: self) + } + + func formattedTime() -> String { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter.string(from: self) + } + + func formattedDateTime() -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: self) + } + + var iso8601String: String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: self) + } + + var iso8601FullDate: String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate] + return formatter.string(from: self) + } + + static func fromISO8601(_ string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } +} diff --git a/ios/TimeTracker/TimeTracker/Shared/Extensions/View+Extensions.swift b/ios/TimeTracker/TimeTracker/Shared/Extensions/View+Extensions.swift new file mode 100644 index 0000000..ce73a1c --- /dev/null +++ b/ios/TimeTracker/TimeTracker/Shared/Extensions/View+Extensions.swift @@ -0,0 +1,38 @@ +import SwiftUI + +extension View { + func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + + @ViewBuilder + func `if`(_ condition: Bool, transform: (Self) -> Transform) -> some View { + if condition { + transform(self) + } else { + self + } + } +} + +struct FormFieldStyle: ViewModifier { + var isEditing: Bool = false + + func body(content: Content) -> some View { + content + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color(.systemGray6)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isEditing ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } +} + +extension View { + func formFieldStyle(isEditing: Bool = false) -> some View { + modifier(FormFieldStyle(isEditing: isEditing)) + } +} diff --git a/ios/TimeTracker/TimeTracker/TimeTracker.entitlements b/ios/TimeTracker/TimeTracker/TimeTracker.entitlements new file mode 100644 index 0000000..6bfcb0f --- /dev/null +++ b/ios/TimeTracker/TimeTracker/TimeTracker.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.timetracker.app + + + diff --git a/ios/TimeTracker/TimeTracker/TimeTrackerApp/TimeTrackerApp.swift b/ios/TimeTracker/TimeTracker/TimeTrackerApp/TimeTrackerApp.swift new file mode 100644 index 0000000..57b7518 --- /dev/null +++ b/ios/TimeTracker/TimeTracker/TimeTrackerApp/TimeTrackerApp.swift @@ -0,0 +1,68 @@ +import SwiftUI + +@main +struct TimeTrackerApp: App { + @StateObject private var authManager = AuthManager.shared + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(authManager) + } + } +} + +struct RootView: View { + @EnvironmentObject var authManager: AuthManager + + var body: some View { + Group { + if authManager.isAuthenticated { + MainTabView() + } else { + LoginView() + } + } + .task { + await authManager.checkAuthState() + } + } +} + +struct MainTabView: View { + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + DashboardView() + .tabItem { + Label("Dashboard", systemImage: "chart.bar") + } + .tag(0) + + TimerView() + .tabItem { + Label("Timer", systemImage: "timer") + } + .tag(1) + + TimeEntriesView() + .tabItem { + Label("Entries", systemImage: "clock") + } + .tag(2) + + ProjectsView() + .tabItem { + Label("Projects", systemImage: "folder") + } + .tag(3) + + ClientsView() + .tabItem { + Label("Clients", systemImage: "person.2") + } + .tag(4) + } + } +} diff --git a/ios/TimeTracker/TimeTrackerWidget/Assets.xcassets/Contents.json b/ios/TimeTracker/TimeTrackerWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/TimeTracker/TimeTrackerWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/TimeTracker/TimeTrackerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/TimeTracker/TimeTrackerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..97650a1 --- /dev/null +++ b/ios/TimeTracker/TimeTrackerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/TimeTracker/TimeTrackerWidget/Info.plist b/ios/TimeTracker/TimeTrackerWidget/Info.plist new file mode 100644 index 0000000..4a4caae --- /dev/null +++ b/ios/TimeTracker/TimeTrackerWidget/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Timer + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.entitlements b/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.entitlements new file mode 100644 index 0000000..6bfcb0f --- /dev/null +++ b/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.timetracker.app + + + diff --git a/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.swift b/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.swift new file mode 100644 index 0000000..d8d4cb7 --- /dev/null +++ b/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.swift @@ -0,0 +1,194 @@ +import WidgetKit +import SwiftUI + +struct TimerEntry: TimelineEntry { + let date: Date + let timer: WidgetTimer? + let projectName: String? + let projectColor: String? +} + +struct WidgetTimer: Codable { + let id: String + let startTime: String + let projectId: String? + + var elapsedTime: TimeInterval { + guard let start = ISO8601DateFormatter().date(from: startTime) else { + return 0 + } + return Date().timeIntervalSince(start) + } +} + +struct Provider: TimelineProvider { + private let appGroupIdentifier = "group.com.timetracker.app" + + func placeholder(in context: Context) -> TimerEntry { + TimerEntry( + date: Date(), + timer: nil, + projectName: nil, + projectColor: nil + ) + } + + func getSnapshot(in context: Context, completion: @escaping (TimerEntry) -> Void) { + let entry = loadTimerEntry() + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = loadTimerEntry() + + let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())! + let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) + + completion(timeline) + } + + private func loadTimerEntry() -> TimerEntry { + guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier), + let data = userDefaults.data(forKey: "cachedTimer") else { + return TimerEntry( + date: Date(), + timer: nil, + projectName: nil, + projectColor: nil + ) + } + + do { + let timer = try JSONDecoder().decode(WidgetTimer.self, from: data) + return TimerEntry( + date: Date(), + timer: timer, + projectName: timer.projectId, + projectColor: nil + ) + } catch { + return TimerEntry( + date: Date(), + timer: nil, + projectName: nil, + projectColor: nil + ) + } + } +} + +struct TimerWidgetEntryView: View { + var entry: Provider.Entry + @Environment(\.widgetFamily) var family + + var body: some View { + switch family { + case .systemSmall: + smallWidget + case .systemMedium: + mediumWidget + default: + smallWidget + } + } + + private var smallWidget: some View { + VStack(spacing: 8) { + if let timer = entry.timer { + Image(systemName: "timer") + .font(.title2) + .foregroundStyle(.green) + + Text(timer.elapsedTime.formattedDuration) + .font(.system(size: 24, weight: .medium, design: .monospaced)) + + if let projectId = entry.projectName { + Text(projectId) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } else { + Image(systemName: "timer") + .font(.title2) + .foregroundStyle(.secondary) + + Text("No timer") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .containerBackground(for: .widget) { + Color(.systemBackground) + } + } + + private var mediumWidget: some View { + HStack(spacing: 16) { + if let timer = entry.timer { + VStack(spacing: 4) { + Image(systemName: "timer") + .font(.title) + .foregroundStyle(.green) + + Text(timer.elapsedTime.formattedDuration) + .font(.system(size: 28, weight: .medium, design: .monospaced)) + } + + VStack(alignment: .leading, spacing: 4) { + if let projectId = entry.projectName { + Text(projectId) + .font(.headline) + } + Text("Tap to open") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + VStack(spacing: 8) { + Image(systemName: "timer") + .font(.title) + .foregroundStyle(.secondary) + + Text("No active timer") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .containerBackground(for: .widget) { + Color(.systemBackground) + } + } +} + +extension TimeInterval { + var formattedDuration: String { + let hours = Int(self) / 3600 + let minutes = (Int(self) % 3600) / 60 + let seconds = Int(self) % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%02d:%02d", minutes, seconds) + } + } +} + +struct TimeTrackerWidget: Widget { + let kind: String = "TimeTrackerWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: Provider()) { entry in + TimerWidgetEntryView(entry: entry) + } + .configurationDisplayName("Timer") + .description("Shows your active timer.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} diff --git a/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidgetBundle.swift b/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidgetBundle.swift new file mode 100644 index 0000000..c70d43b --- /dev/null +++ b/ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidgetBundle.swift @@ -0,0 +1,9 @@ +import WidgetKit +import SwiftUI + +@main +struct TimeTrackerWidgetBundle: WidgetBundle { + var body: some Widget { + TimeTrackerWidget() + } +} diff --git a/ios/TimeTracker/project.yml b/ios/TimeTracker/project.yml new file mode 100644 index 0000000..f7a554c --- /dev/null +++ b/ios/TimeTracker/project.yml @@ -0,0 +1,94 @@ +name: TimeTracker +options: + bundleIdPrefix: com.timetracker + deploymentTarget: + iOS: "17.0" + xcodeVersion: "15.0" + generateEmptyDirectories: true + +packages: + SQLite: + url: https://github.com/stephencelis/SQLite.swift + version: 0.15.3 + KeychainAccess: + url: https://github.com/kishikawakatsumi/KeychainAccess + version: 4.2.2 + +settings: + base: + SWIFT_VERSION: "5.9" + DEVELOPMENT_TEAM: "" + CODE_SIGN_IDENTITY: "" + CODE_SIGNING_REQUIRED: "NO" + CODE_SIGNING_ALLOWED: "NO" + +targets: + TimeTracker: + type: application + platform: iOS + sources: + - path: TimeTracker + excludes: + - "**/.DS_Store" + settings: + base: + INFOPLIST_FILE: TimeTracker/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: com.timetracker.app + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + TARGETED_DEVICE_FAMILY: "1,2" + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: "YES" + dependencies: + - target: TimeTrackerWidget + embed: true + - package: SQLite + product: SQLite + - package: KeychainAccess + entitlements: + path: TimeTracker/TimeTracker.entitlements + properties: + com.apple.security.application-groups: + - group.com.timetracker.app + + TimeTrackerWidget: + type: app-extension + platform: iOS + sources: + - path: TimeTrackerWidget + excludes: + - "**/.DS_Store" + settings: + base: + INFOPLIST_FILE: TimeTrackerWidget/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: com.timetracker.app.widget + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + SKIP_INSTALL: "YES" + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME: WidgetBackground + CODE_SIGN_ENTITLEMENTS: TimeTrackerWidget/TimeTrackerWidget.entitlements + dependencies: + - package: SQLite + product: SQLite + entitlements: + path: TimeTrackerWidget/TimeTrackerWidget.entitlements + properties: + com.apple.security.application-groups: + - group.com.timetracker.app + +schemes: + TimeTracker: + build: + targets: + TimeTracker: all + TimeTrackerWidget: all + run: + config: Debug + test: + config: Debug + profile: + config: Release + analyze: + config: Debug + archive: + config: Release