adds ios
This commit is contained in:
89
ios/TimeTracker/README.md
Normal file
89
ios/TimeTracker/README.md
Normal file
@@ -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
|
||||
```
|
||||
91
ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift
Normal file
91
ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
141
ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift
Normal file
141
ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift
Normal file
@@ -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")
|
||||
}
|
||||
32
ios/TimeTracker/TimeTracker/Core/Constants.swift
Normal file
32
ios/TimeTracker/TimeTracker/Core/Constants.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
160
ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift
Normal file
160
ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift
Normal file
@@ -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<T: Decodable>(
|
||||
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?
|
||||
}
|
||||
33
ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift
Normal file
33
ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
30
ios/TimeTracker/TimeTracker/Core/Network/NetworkError.swift
Normal file
30
ios/TimeTracker/TimeTracker/Core/Network/NetworkError.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>("id")
|
||||
private let name = SQLite.Expression<String>("name")
|
||||
private let description = SQLite.Expression<String?>("description")
|
||||
private let createdAt = SQLite.Expression<String>("created_at")
|
||||
private let updatedAt = SQLite.Expression<String>("updated_at")
|
||||
|
||||
// Projects columns
|
||||
private let projectClientId = SQLite.Expression<String>("client_id")
|
||||
private let clientName = SQLite.Expression<String>("client_name")
|
||||
private let color = SQLite.Expression<String?>("color")
|
||||
|
||||
// Time entries columns
|
||||
private let startTime = SQLite.Expression<String>("start_time")
|
||||
private let endTime = SQLite.Expression<String>("end_time")
|
||||
private let projectId = SQLite.Expression<String>("project_id")
|
||||
private let projectName = SQLite.Expression<String>("project_name")
|
||||
private let projectColor = SQLite.Expression<String?>("project_color")
|
||||
private let entryDescription = SQLite.Expression<String?>("description")
|
||||
|
||||
// Pending sync columns
|
||||
private let syncId = SQLite.Expression<String>("id")
|
||||
private let syncType = SQLite.Expression<String>("type")
|
||||
private let syncAction = SQLite.Expression<String>("action")
|
||||
private let syncPayload = SQLite.Expression<String>("payload")
|
||||
private let syncCreatedAt = SQLite.Expression<String>("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)
|
||||
}
|
||||
}
|
||||
163
ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift
Normal file
163
ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
87
ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift
Normal file
87
ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
123
ios/TimeTracker/TimeTracker/Features/Clients/ClientsView.swift
Normal file
123
ios/TimeTracker/TimeTracker/Features/Clients/ClientsView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
167
ios/TimeTracker/TimeTracker/Features/Projects/ProjectsView.swift
Normal file
167
ios/TimeTracker/TimeTracker/Features/Projects/ProjectsView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
175
ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift
Normal file
175
ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
160
ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift
Normal file
160
ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift
Normal file
@@ -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<Void, Never>?
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
ios/TimeTracker/TimeTracker/Info.plist
Normal file
66
ios/TimeTracker/TimeTracker/Info.plist
Normal file
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>LaunchBackground</string>
|
||||
</dict>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.timetracker.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>timetracker</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>API_BASE_URL</key>
|
||||
<string>$(API_BASE_URL)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
23
ios/TimeTracker/TimeTracker/Models/Client.swift
Normal file
23
ios/TimeTracker/TimeTracker/Models/Client.swift
Normal file
@@ -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?
|
||||
}
|
||||
41
ios/TimeTracker/TimeTracker/Models/OngoingTimer.swift
Normal file
41
ios/TimeTracker/TimeTracker/Models/OngoingTimer.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
35
ios/TimeTracker/TimeTracker/Models/Project.swift
Normal file
35
ios/TimeTracker/TimeTracker/Models/Project.swift
Normal file
@@ -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?
|
||||
}
|
||||
91
ios/TimeTracker/TimeTracker/Models/TimeEntry.swift
Normal file
91
ios/TimeTracker/TimeTracker/Models/TimeEntry.swift
Normal file
@@ -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?
|
||||
}
|
||||
57
ios/TimeTracker/TimeTracker/Models/TimeStatistics.swift
Normal file
57
ios/TimeTracker/TimeTracker/Models/TimeStatistics.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
19
ios/TimeTracker/TimeTracker/Models/User.swift
Normal file
19
ios/TimeTracker/TimeTracker/Models/User.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
48
ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift
Normal file
48
ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`<Transform: View>(_ 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))
|
||||
}
|
||||
}
|
||||
10
ios/TimeTracker/TimeTracker/TimeTracker.entitlements
Normal file
10
ios/TimeTracker/TimeTracker/TimeTracker.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.timetracker.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
29
ios/TimeTracker/TimeTrackerWidget/Info.plist
Normal file
29
ios/TimeTracker/TimeTrackerWidget/Info.plist
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Timer</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.timetracker.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
194
ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.swift
Normal file
194
ios/TimeTracker/TimeTrackerWidget/TimeTrackerWidget.swift
Normal file
@@ -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<TimerEntry>) -> 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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct TimeTrackerWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
TimeTrackerWidget()
|
||||
}
|
||||
}
|
||||
94
ios/TimeTracker/project.yml
Normal file
94
ios/TimeTracker/project.yml
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user