Project Name: DreamLauncherLite
Bundle Identifier: com.dreamlauncher.lite.ios
Minimum iOS Version: 16.0
Language: Swift 5.9+
UI Framework: SwiftUI
Architecture: MVVM + Services
com.dreamlauncher.lite.ioscom.dreamlauncher.lite.ios.monitorcom.dreamlauncher.lite.ios.reportSwift Package Manager:
// Package.swift
dependencies: [
// No external dependencies - keep it simple!
// All functionality uses Apple frameworks
]
Why no dependencies?
┌─────────────────────────────────────────────┐
│ Views (SwiftUI) │
│ AuthView, DashboardView, SettingsView │
└─────────────┬───────────────────────────────┘
│
│ ObservableObject
▼
┌─────────────────────────────────────────────┐
│ ViewModels (@MainActor) │
│ AuthViewModel, DashboardViewModel │
└─────────────┬───────────────────────────────┘
│
│ async/await
▼
┌─────────────────────────────────────────────┐
│ Services │
│ AuthService, ScreenTimeService, │
│ SyncService, APIClient │
└─────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Storage Layer │
│ Keychain, CoreData, UserDefaults │
└─────────────────────────────────────────────┘
Purpose: Handle Apple Sign-In and token management
// Services/AuthService.swift
import AuthenticationServices
import Security
@MainActor
class AuthService: ObservableObject {
@Published var isAuthenticated = false
@Published var currentUser: User?
private let apiClient: APIClient
private let keychain = KeychainService()
init(apiClient: APIClient = .shared) {
self.apiClient = apiClient
checkAuthStatus()
}
// MARK: - Authentication
func signInWithApple(
authorization: ASAuthorization
) async throws {
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let identityToken = credential.identityToken,
let tokenString = String(data: identityToken, encoding: .utf8),
let authCode = credential.authorizationCode,
let codeString = String(data: authCode, encoding: .utf8)
else {
throw AuthError.invalidCredentials
}
// Call API
let response = try await apiClient.signInWithApple(
identityToken: tokenString,
authorizationCode: codeString,
email: credential.email,
fullName: credential.fullName
)
// Store tokens
try keychain.save(response.accessToken, for: .accessToken)
try keychain.save(response.refreshToken, for: .refreshToken)
// Update state
currentUser = response.user
isAuthenticated = true
}
func signOut() {
keychain.delete(for: .accessToken)
keychain.delete(for: .refreshToken)
currentUser = nil
isAuthenticated = false
}
func refreshTokenIfNeeded() async throws {
guard let refreshToken = keychain.get(for: .refreshToken) else {
throw AuthError.notAuthenticated
}
let response = try await apiClient.refreshToken(refreshToken)
try keychain.save(response.accessToken, for: .accessToken)
try keychain.save(response.refreshToken, for: .refreshToken)
}
// MARK: - Private
private func checkAuthStatus() {
isAuthenticated = keychain.get(for: .accessToken) != nil
}
}
// MARK: - Supporting Types
enum AuthError: LocalizedError {
case invalidCredentials
case notAuthenticated
case networkError(Error)
var errorDescription: String? {
switch self {
case .invalidCredentials:
return "Invalid credentials provided"
case .notAuthenticated:
return "User is not authenticated"
case .networkError(let error):
return "Network error: \(error.localizedDescription)"
}
}
}
struct AuthResponse: Codable {
let accessToken: String
let refreshToken: String
let user: User
}
struct User: Codable, Identifiable {
let id: String
let appleId: String?
let email: String?
let name: String?
}
Purpose: Interact with Family Controls framework
// Services/ScreenTimeService.swift
import FamilyControls
import DeviceActivity
class ScreenTimeService {
private let authCenter = AuthorizationCenter.shared
// MARK: - Authorization
func requestAuthorization() async throws {
let status = authCenter.authorizationStatus
switch status {
case .notDetermined:
try await authCenter.requestAuthorization(for: .individual)
case .denied:
throw ScreenTimeError.authorizationDenied
case .approved:
return
@unknown default:
throw ScreenTimeError.unknownAuthStatus
}
}
var isAuthorized: Bool {
authCenter.authorizationStatus == .approved
}
// MARK: - Data Collection
func fetchTodayScreenTime() async throws -> ScreenTimeData {
guard isAuthorized else {
throw ScreenTimeError.notAuthorized
}
let today = Calendar.current.startOfDay(for: Date())
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)!
return try await fetchScreenTime(
from: today,
to: tomorrow
)
}
func fetchScreenTime(from: Date, to: Date) async throws -> ScreenTimeData {
// This is a simplified version - actual implementation
// requires DeviceActivityReport extension
return ScreenTimeData(
startDate: from,
endDate: to,
totalMinutes: 0, // Will be populated by report extension
deviceType: currentDeviceType()
)
}
func fetchWeekScreenTime() async throws -> [ScreenTimeData] {
let today = Calendar.current.startOfDay(for: Date())
let weekAgo = Calendar.current.date(byAdding: .day, value: -7, to: today)!
var days: [ScreenTimeData] = []
for dayOffset in 0..<7 {
let date = Calendar.current.date(byAdding: .day, value: -dayOffset, to: today)!
let nextDay = Calendar.current.date(byAdding: .day, value: 1, to: date)!
let data = try await fetchScreenTime(from: date, to: nextDay)
days.append(data)
}
return days
}
// MARK: - Private
private func currentDeviceType() -> String {
#if os(iOS)
switch UIDevice.current.userInterfaceIdiom {
case .phone:
return "IOS_PHONE"
case .pad:
return "IOS_TABLET"
default:
return "UNKNOWN"
}
#else
return "UNKNOWN"
#endif
}
}
// MARK: - Supporting Types
enum ScreenTimeError: LocalizedError {
case notAuthorized
case authorizationDenied
case unknownAuthStatus
case dataUnavailable
var errorDescription: String? {
switch self {
case .notAuthorized:
return "Screen Time access not authorized"
case .authorizationDenied:
return "User denied Screen Time authorization"
case .unknownAuthStatus:
return "Unknown authorization status"
case .dataUnavailable:
return "Screen Time data is not available"
}
}
}
struct ScreenTimeData {
let startDate: Date
let endDate: Date
let totalMinutes: Int
let deviceType: String
var durationInMinutes: Int { totalMinutes }
}
Purpose: Sync screen time data to backend
// Services/SyncService.swift
import Foundation
import CoreData
actor SyncService {
private let apiClient: APIClient
private let storage: StorageService
private var isSyncing = false
init(
apiClient: APIClient = .shared,
storage: StorageService = .shared
) {
self.apiClient = apiClient
self.storage = storage
}
// MARK: - Public API
func sync() async throws {
guard !isSyncing else {
print("Sync already in progress")
return
}
isSyncing = true
defer { isSyncing = false }
print("Starting screen time sync...")
// Get unsynced entries
let unsynced = try await storage.getUnsyncedEntries()
guard !unsynced.isEmpty else {
print("No unsynced entries")
return
}
print("Syncing \(unsynced.count) entries")
// Convert to DTOs
let dtos = unsynced.map { CreateScreenTimeDTO(from: $0) }
// Upload to API
try await apiClient.uploadScreenTime(dtos)
// Mark as synced
let ids = unsynced.map { $0.id }
try await storage.markAsSynced(ids)
print("Sync completed successfully")
}
func syncInBackground() async {
do {
try await sync()
} catch {
print("Background sync failed: \(error)")
// Don't throw - background sync should be resilient
}
}
// MARK: - Background Task
func scheduleBackgroundSync() {
BackgroundTaskManager.shared.scheduleSync()
}
}
// MARK: - DTOs
struct CreateScreenTimeDTO: Codable {
let startDate: Date
let endDate: Date
let durationInMinutes: Int
let timeZone: String
init(from entry: ScreenTimeEntry) {
self.startDate = entry.startDate
self.endDate = entry.endDate
self.durationInMinutes = Int(entry.durationInMinutes)
self.timeZone = TimeZone.current.identifier
}
}
Purpose: Network layer for all API communication
// Services/APIClient.swift
import Foundation
class APIClient {
static let shared = APIClient()
private let baseURL: URL
private let session: URLSession
private let keychain = KeychainService()
private init() {
self.baseURL = URL(string: Config.apiBaseURL)!
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
// MARK: - Authentication
func signInWithApple(
identityToken: String,
authorizationCode: String,
email: String?,
fullName: PersonNameComponents?
) async throws -> AuthResponse {
let endpoint = baseURL.appendingPathComponent("/api/v1/apple-auth/sign-in")
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any?] = [
"identityToken": identityToken,
"authorizationCode": authorizationCode,
"user": [
"email": email,
"firstName": fullName?.givenName,
"lastName": fullName?.familyName
]
]
request.httpBody = try JSONSerialization.data(
withJSONObject: body.compactMapValues { $0 }
)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw APIError.httpError(httpResponse.statusCode)
}
return try JSONDecoder().decode(AuthResponse.self, from: data)
}
func refreshToken(_ refreshToken: String) async throws -> AuthResponse {
let endpoint = baseURL.appendingPathComponent("/api/v1/refresh-token")
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ["refreshToken": refreshToken]
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw APIError.invalidResponse
}
return try JSONDecoder().decode(AuthResponse.self, from: data)
}
// MARK: - Screen Time
func uploadScreenTime(_ entries: [CreateScreenTimeDTO]) async throws {
for entry in entries {
try await uploadSingleEntry(entry)
}
}
private func uploadSingleEntry(_ entry: CreateScreenTimeDTO) async throws {
let endpoint = baseURL.appendingPathComponent("/api/v1/user-screen-time")
var request = try await authorizedRequest(for: endpoint, method: "POST")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
request.httpBody = try encoder.encode(entry)
let (_, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw APIError.uploadFailed
}
}
func fetchScreenTime(
startDate: Date,
endDate: Date
) async throws -> [ScreenTimeResponseData] {
var components = URLComponents(
url: baseURL.appendingPathComponent("/api/v1/user-screen-time"),
resolvingAgainstBaseURL: true
)!
let formatter = ISO8601DateFormatter()
components.queryItems = [
URLQueryItem(name: "startDate", value: formatter.string(from: startDate)),
URLQueryItem(name: "endDate", value: formatter.string(from: endDate)),
URLQueryItem(name: "limit", value: "1000")
]
let request = try await authorizedRequest(for: components.url!, method: "GET")
let (data, _) = try await session.data(for: request)
let response = try JSONDecoder().decode(
ScreenTimeResponse.self,
from: data
)
return response.data
}
func fetchCombinedScreenTime(
startDate: Date,
endDate: Date
) async throws -> CombinedScreenTimeResponse {
var components = URLComponents(
url: baseURL.appendingPathComponent("/api/v1/user-screen-time/combined"),
resolvingAgainstBaseURL: true
)!
let formatter = ISO8601DateFormatter()
components.queryItems = [
URLQueryItem(name: "startDate", value: formatter.string(from: startDate)),
URLQueryItem(name: "endDate", value: formatter.string(from: endDate))
]
let request = try await authorizedRequest(for: components.url!, method: "GET")
let (data, _) = try await session.data(for: request)
return try JSONDecoder().decode(
CombinedScreenTimeResponse.self,
from: data
)
}
// MARK: - Private Helpers
private func authorizedRequest(
for url: URL,
method: String
) async throws -> URLRequest {
guard let token = keychain.get(for: .accessToken) else {
throw APIError.notAuthenticated
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return request
}
}
// MARK: - Response Types
struct ScreenTimeResponse: Codable {
let success: Bool
let data: [ScreenTimeResponseData]
let pagination: Pagination
}
struct ScreenTimeResponseData: Codable {
let id: String
let userId: String
let startDate: Date
let endDate: Date
let durationInMinutes: Int
let deviceType: String?
let createdAt: Date
}
struct CombinedScreenTimeResponse: Codable {
let success: Bool
let data: [ScreenTimeResponseData]
let platformBreakdown: PlatformBreakdown
}
struct PlatformBreakdown: Codable {
let ios: Int
let macos: Int
let total: Int
}
struct Pagination: Codable {
let total: Int
let limit: Int
let offset: Int
let hasMore: Bool
}
// MARK: - Errors
enum APIError: LocalizedError {
case invalidResponse
case httpError(Int)
case notAuthenticated
case uploadFailed
case decodingError(Error)
var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid server response"
case .httpError(let code):
return "HTTP error: \(code)"
case .notAuthenticated:
return "User not authenticated"
case .uploadFailed:
return "Failed to upload data"
case .decodingError(let error):
return "Decoding error: \(error.localizedDescription)"
}
}
}
// Storage/CoreDataStack.swift
import CoreData
class CoreDataStack {
static let shared = CoreDataStack()
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "DreamLauncher")
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Unable to load persistent stores: \(error)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return container
}()
var viewContext: NSManagedObjectContext {
persistentContainer.viewContext
}
func newBackgroundContext() -> NSManagedObjectContext {
persistentContainer.newBackgroundContext()
}
func save() throws {
let context = viewContext
guard context.hasChanges else { return }
try context.save()
}
}
// Storage/StorageService.swift
import CoreData
actor StorageService {
static let shared = StorageService()
private let stack = CoreDataStack.shared
// MARK: - Screen Time Entries
func saveScreenTimeEntry(_ data: ScreenTimeData) async throws {
let context = stack.newBackgroundContext()
try await context.perform {
let entry = ScreenTimeEntry(context: context)
entry.id = UUID()
entry.startDate = data.startDate
entry.endDate = data.endDate
entry.durationInMinutes = Int32(data.totalMinutes)
entry.deviceType = data.deviceType
entry.synced = false
entry.createdAt = Date()
try context.save()
}
}
func getUnsyncedEntries() async throws -> [ScreenTimeEntry] {
let context = stack.newBackgroundContext()
return try await context.perform {
let request: NSFetchRequest<ScreenTimeEntry> = ScreenTimeEntry.fetchRequest()
request.predicate = NSPredicate(format: "synced == NO")
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
request.fetchLimit = 100
return try context.fetch(request)
}
}
func markAsSynced(_ ids: [UUID]) async throws {
let context = stack.newBackgroundContext()
try await context.perform {
let request: NSFetchRequest<ScreenTimeEntry> = ScreenTimeEntry.fetchRequest()
request.predicate = NSPredicate(format: "id IN %@", ids)
let entries = try context.fetch(request)
for entry in entries {
entry.synced = true
entry.syncedAt = Date()
}
try context.save()
}
}
func getTodayScreenTime() async throws -> Int {
let context = stack.viewContext
return try await context.perform {
let today = Calendar.current.startOfDay(for: Date())
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)!
let request: NSFetchRequest<ScreenTimeEntry> = ScreenTimeEntry.fetchRequest()
request.predicate = NSPredicate(
format: "startDate >= %@ AND startDate < %@",
today as NSDate,
tomorrow as NSDate
)
let entries = try context.fetch(request)
return entries.reduce(0) { $0 + Int($1.durationInMinutes) }
}
}
}
// Storage/KeychainService.swift
import Security
import Foundation
class KeychainService {
enum KeychainKey: String {
case accessToken = "com.dreamlauncher.accessToken"
case refreshToken = "com.dreamlauncher.refreshToken"
}
func save(_ value: String, for key: KeychainKey) throws {
let data = value.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key.rawValue,
kSecValueData as String: data
]
// Delete existing
SecItemDelete(query as CFDictionary)
// Add new
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func get(for key: KeychainKey) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key.rawValue,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let string = String(data: data, encoding: .utf8) else {
return nil
}
return string
}
func delete(for key: KeychainKey) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key.rawValue
]
SecItemDelete(query as CFDictionary)
}
}
enum KeychainError: Error {
case saveFailed(OSStatus)
case deleteFailed(OSStatus)
}
// ViewModels/DashboardViewModel.swift
import SwiftUI
import Combine
@MainActor
class DashboardViewModel: ObservableObject {
@Published var todayMinutes: Int = 0
@Published var weekData: [DayData] = []
@Published var platformBreakdown: PlatformBreakdown?
@Published var isLoading = false
@Published var error: Error?
private let screenTimeService: ScreenTimeService
private let syncService: SyncService
private let apiClient: APIClient
private let storage: StorageService
init(
screenTimeService: ScreenTimeService = ScreenTimeService(),
syncService: SyncService = .init(),
apiClient: APIClient = .shared,
storage: StorageService = .shared
) {
self.screenTimeService = screenTimeService
self.syncService = syncService
self.apiClient = apiClient
self.storage = storage
}
func loadData() async {
isLoading = true
defer { isLoading = false }
do {
// Load today's local data
todayMinutes = try await storage.getTodayScreenTime()
// Load week data from API (includes macOS)
let today = Date()
let weekAgo = Calendar.current.date(byAdding: .day, value: -7, to: today)!
let combined = try await apiClient.fetchCombinedScreenTime(
startDate: weekAgo,
endDate: today
)
platformBreakdown = combined.platformBreakdown
weekData = processWeekData(combined.data)
} catch {
self.error = error
print("Error loading data: \(error)")
}
}
func refresh() async {
// Sync local data
try? await syncService.sync()
// Reload
await loadData()
}
private func processWeekData(_ data: [ScreenTimeResponseData]) -> [DayData] {
// Group by day and sum durations
var dayMap: [Date: DayData] = [:]
for entry in data {
let day = Calendar.current.startOfDay(for: entry.startDate)
if var existing = dayMap[day] {
existing.totalMinutes += entry.durationInMinutes
if entry.deviceType?.starts(with: "IOS") == true {
existing.iosMinutes += entry.durationInMinutes
} else if entry.deviceType == "MACOS" {
existing.macosMinutes += entry.durationInMinutes
}
dayMap[day] = existing
} else {
let iosMinutes = entry.deviceType?.starts(with: "IOS") == true ? entry.durationInMinutes : 0
let macosMinutes = entry.deviceType == "MACOS" ? entry.durationInMinutes : 0
dayMap[day] = DayData(
date: day,
totalMinutes: entry.durationInMinutes,
iosMinutes: iosMinutes,
macosMinutes: macosMinutes
)
}
}
return dayMap.values.sorted { $0.date < $1.date }
}
}
struct DayData: Identifiable {
let date: Date
var totalMinutes: Int
var iosMinutes: Int
var macosMinutes: Int
var id: Date { date }
var formattedHours: String {
let hours = Double(totalMinutes) / 60.0
return String(format: "%.1fh", hours)
}
}
// Views/AuthView.swift
import SwiftUI
import AuthenticationServices
struct AuthView: View {
@EnvironmentObject var authService: AuthService
@State private var isLoading = false
@State private var error: Error?
var body: some View {
VStack(spacing: 40) {
Spacer()
// Logo
Image(systemName: "moon.fill")
.resizable()
.frame(width: 100, height: 100)
.foregroundColor(.blue)
// Title
VStack(spacing: 8) {
Text("DreamLauncher")
.font(.largeTitle)
.fontWeight(.bold)
Text("Cross-platform screen time tracking")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
// Sign in button
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.email, .fullName]
} onCompletion: { result in
Task {
await handleSignIn(result)
}
}
.signInWithAppleButtonStyle(.black)
.frame(height: 50)
.padding(.horizontal, 40)
if let error = error {
Text(error.localizedDescription)
.font(.caption)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
Spacer()
}
.overlay {
if isLoading {
ProgressView()
}
}
}
private func handleSignIn(_ result: Result<ASAuthorization, Error>) async {
isLoading = true
defer { isLoading = false }
switch result {
case .success(let authorization):
do {
try await authService.signInWithApple(authorization: authorization)
} catch {
self.error = error
}
case .failure(let error):
self.error = error
}
}
}
// Views/DashboardView.swift
import SwiftUI
struct DashboardView: View {
@StateObject private var viewModel = DashboardViewModel()
@EnvironmentObject var authService: AuthService
var body: some View {
NavigationView {
ScrollView {
VStack(spacing: 24) {
// Today's total
TodayCard(minutes: viewModel.todayMinutes)
// Week chart
WeekChartView(days: viewModel.weekData)
// Platform breakdown
if let breakdown = viewModel.platformBreakdown {
PlatformBreakdownView(breakdown: breakdown)
}
}
.padding()
}
.navigationTitle("Screen Time")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink {
SettingsView()
} label: {
Image(systemName: "gear")
}
}
}
.refreshable {
await viewModel.refresh()
}
.task {
await viewModel.loadData()
}
}
}
}
// MARK: - Subviews
struct TodayCard: View {
let minutes: Int
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Today's Screen Time")
.font(.headline)
.foregroundColor(.secondary)
Text(formattedDuration)
.font(.system(size: 48, weight: .bold))
ProgressView(value: Double(minutes), total: 720) // 12 hours max
.tint(.blue)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(radius: 2)
}
private var formattedDuration: String {
let hours = minutes / 60
let mins = minutes % 60
return "\(hours)h \(mins)m"
}
}
struct PlatformBreakdownView: View {
let breakdown: PlatformBreakdown
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Platform Breakdown")
.font(.headline)
HStack {
PlatformRow(
icon: "iphone",
name: "iPhone/iPad",
minutes: breakdown.ios
)
Divider()
PlatformRow(
icon: "laptopcomputer",
name: "MacBook",
minutes: breakdown.macos
)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(radius: 2)
}
}
struct PlatformRow: View {
let icon: String
let name: String
let minutes: Int
var body: some View {
VStack {
Image(systemName: icon)
.font(.largeTitle)
.foregroundColor(.blue)
Text(name)
.font(.caption)
.foregroundColor(.secondary)
Text(formattedDuration)
.font(.headline)
}
.frame(maxWidth: .infinity)
}
private var formattedDuration: String {
let hours = Double(minutes) / 60.0
return String(format: "%.1fh", hours)
}
}
// Config.swift
enum Config {
static var apiBaseURL: String {
#if DEBUG
return "http://localhost:3000"
#else
return "https://your-api.railway.app"
#endif
}
static let backgroundTaskIdentifier = "com.dreamlauncher.lite.sync"
static let syncInterval: TimeInterval = 4 * 60 * 60 // 4 hours
}
Compare to old app: 10,000+ lines
90% reduction in code complexity!