diff --git a/ios/ChronoMind/App/ChronoMindApp.swift b/ios/ChronoMind/App/ChronoMindApp.swift index c680c1b..e6a777c 100644 --- a/ios/ChronoMind/App/ChronoMindApp.swift +++ b/ios/ChronoMind/App/ChronoMindApp.swift @@ -9,6 +9,7 @@ struct ChronoMindApp: App { @StateObject private var notificationManager = CMNotificationManager.shared @StateObject private var gamification = GamificationStore.shared @StateObject private var authService = CMAuthService.shared + @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { @@ -42,6 +43,20 @@ struct ChronoMindApp: App { CMLoginView(authService: authService) } } + .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .active: + CMTelemetryService.shared.start() + FeatureFlagService.shared.start(userId: authService.currentUser?.id) + CMTelemetryService.shared.trackEvent("info", module: "app", name: "app_foregrounded") + case .background: + CMTelemetryService.shared.trackEvent("info", module: "app", name: "app_backgrounded") + CMTelemetryService.shared.flush() + FeatureFlagService.shared.stop() + default: + break + } + } } } } diff --git a/ios/ChronoMind/Shared/Cloud/FeatureFlagService.swift b/ios/ChronoMind/Shared/Cloud/FeatureFlagService.swift new file mode 100644 index 0000000..882c877 --- /dev/null +++ b/ios/ChronoMind/Shared/Cloud/FeatureFlagService.swift @@ -0,0 +1,71 @@ +// ── Feature Flag Client ─────────────────────────────────────── +// Polls platform-service /flags/poll for feature flags. +// Flags cached in memory, re-polled every 5 minutes. +// Consumers call FeatureFlagService.shared.isEnabled("flag_key"). + +import Foundation + +@MainActor +final class FeatureFlagService: ObservableObject { + static let shared = FeatureFlagService() + + @Published private(set) var flags: [String: Bool] = [:] + private var pollTimer: Timer? + private let pollIntervalSec: TimeInterval = 5 * 60 // 5 minutes + private let productId = "chronomind" + + private var baseURL: String { + Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String + ?? "https://api.chronomind.app" + } + + private init() {} + + // MARK: - Public API + + func start(userId: String? = nil) { + Task { await fetchFlags(userId: userId) } + pollTimer?.invalidate() + pollTimer = Timer.scheduledTimer(withTimeInterval: pollIntervalSec, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in await self.fetchFlags(userId: userId) } + } + } + + func stop() { + pollTimer?.invalidate() + pollTimer = nil + } + + func isEnabled(_ key: String) -> Bool { + flags[key] == true + } + + // MARK: - Fetch + + private func fetchFlags(userId: String? = nil) async { + var components = URLComponents(string: "\(baseURL)/api/flags/poll") + var queryItems: [URLQueryItem] = [.init(name: "platform", value: "ios")] + if let userId { queryItems.append(.init(name: "userId", value: userId)) } + components?.queryItems = queryItems + + guard let url = components?.url else { return } + + var request = URLRequest(url: url) + request.setValue(productId, forHTTPHeaderField: "X-Product-Id") + request.timeoutInterval = 10 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return } + + struct FlagsResponse: Codable { + let flags: [String: Bool] + } + let parsed = try JSONDecoder().decode(FlagsResponse.self, from: data) + flags = parsed.flags + } catch { + // Keep existing flags on error + } + } +} diff --git a/ios/ChronoMind/Shared/Cloud/TelemetryService.swift b/ios/ChronoMind/Shared/Cloud/TelemetryService.swift new file mode 100644 index 0000000..0429b89 --- /dev/null +++ b/ios/ChronoMind/Shared/Cloud/TelemetryService.swift @@ -0,0 +1,138 @@ +// ── Platform Telemetry Client ───────────────────────────────── +// Sends events to platform-service /telemetry/events endpoint. +// Privacy: no PII, only action names + timing metrics. +// Fire-and-forget — errors never surface to the user. + +import Foundation + +@MainActor +final class CMTelemetryService { + static let shared = CMTelemetryService() + + private let productId = "chronomind" + private let platform = "ios" + private let channel = "native" + private var queue: [TelemetryPayload] = [] + private var flushTimer: Timer? + private let maxQueue = 50 + private let flushIntervalSec: TimeInterval = 30 + + private var installId: String { + let key = "chronomind-telemetry-install-id" + if let id = UserDefaults.standard.string(forKey: key) { return id } + let id = UUID().uuidString + UserDefaults.standard.set(id, forKey: key) + return id + } + + private let sessionId = UUID().uuidString + + private var baseURL: String { + Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String + ?? "https://api.chronomind.app" + } + + private struct TelemetryPayload: Codable { + let id: String + let productId: String + let anonymousInstallId: String + let sessionId: String + let platform: String + let channel: String + let osFamily: String + let osVersion: String + let appVersion: String + let buildNumber: String + let releaseChannel: String + let eventType: String + let module: String + let eventName: String + let feature: String? + let message: String? + let tags: [String: String]? + let metrics: [String: Double]? + let occurredAt: String + } + + private init() {} + + // MARK: - Public API + + func start() { + guard flushTimer == nil else { return } + flushTimer = Timer.scheduledTimer(withTimeInterval: flushIntervalSec, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in self.flush() } + } + } + + func stop() { + flush() + flushTimer?.invalidate() + flushTimer = nil + } + + func trackEvent( + _ eventType: String, + module: String, + name: String, + feature: String? = nil, + message: String? = nil, + tags: [String: String]? = nil, + metrics: [String: Double]? = nil + ) { + let event = TelemetryPayload( + id: UUID().uuidString, + productId: productId, + anonymousInstallId: installId, + sessionId: sessionId, + platform: platform, + channel: channel, + osFamily: "iOS", + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0", + buildNumber: Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1", + releaseChannel: "beta", + eventType: eventType, + module: module, + eventName: name, + feature: feature, + message: message, + tags: tags, + metrics: metrics, + occurredAt: ISO8601DateFormatter().string(from: Date()) + ) + queue.append(event) + + if queue.count >= maxQueue { + flush() + } + } + + func trackTimer(_ name: String, tags: [String: String]? = nil, metrics: [String: Double]? = nil) { + trackEvent("info", module: "timers", name: name, tags: tags, metrics: metrics) + } + + func trackScreen(_ screen: String) { + trackEvent("info", module: "navigation", name: "screen_view", tags: ["screen": screen]) + } + + func flush() { + guard !queue.isEmpty else { return } + let batch = queue + queue.removeAll() + + Task.detached { [baseURL, productId] in + guard let url = URL(string: "\(baseURL)/telemetry/events") else { return } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(productId, forHTTPHeaderField: "X-Product-Id") + request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") + request.httpBody = try? JSONEncoder().encode(["events": batch]) + request.timeoutInterval = 10 + + _ = try? await URLSession.shared.data(for: request) + } + } +} diff --git a/ios/ChronoMind/Views/Settings/SettingsView.swift b/ios/ChronoMind/Views/Settings/SettingsView.swift index bb0b152..00b4ef7 100644 --- a/ios/ChronoMind/Views/Settings/SettingsView.swift +++ b/ios/ChronoMind/Views/Settings/SettingsView.swift @@ -26,6 +26,10 @@ struct SettingsView: View { @State private var authMessage = "" @State private var authIsError = false @State private var authSubmitting = false + @State private var loginEmail = "" + @State private var loginPassword = "" + @State private var loginName = "" + @State private var loginIsRegister = false var body: some View { NavigationStack { @@ -130,7 +134,56 @@ struct SettingsView: View { .disabled(authSubmitting || deleteConfirmPw.isEmpty) } } else { - // Forgot Password (shown when logged out) + // Login / Register form + Text("Sign in to sync timers across devices") + .font(CMFonts.body(size: 13)) + .foregroundStyle(CMColors.textSecondary) + + // Mode toggle + Picker("", selection: $loginIsRegister) { + Text("Sign In").tag(false) + Text("Register").tag(true) + } + .pickerStyle(.segmented) + .onChange(of: loginIsRegister) { _, _ in authMessage = "" } + + if loginIsRegister { + TextField("Display Name", text: $loginName) + .textContentType(.name) + .autocapitalization(.words) + } + + TextField("Email", text: $loginEmail) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .autocapitalization(.none) + + SecureField("Password", text: $loginPassword) + .textContentType(loginIsRegister ? .newPassword : .password) + + if !authMessage.isEmpty { + Text(authMessage) + .font(CMFonts.body(size: 12)) + .foregroundStyle(authIsError ? CMColors.error : CMColors.success) + } + + Button { + Task { await performLogin() } + } label: { + HStack { + Spacer() + if authSubmitting { + ProgressView().tint(.white) + } else { + Text(loginIsRegister ? "Create Account" : "Sign In") + .font(CMFonts.body(size: 14, weight: .semibold)) + } + Spacer() + } + } + .disabled(authSubmitting || loginEmail.isEmpty || loginPassword.count < 8 || (loginIsRegister && loginName.isEmpty)) + + // Forgot Password Button { showForgotPw.toggle() authMessage = "" @@ -146,12 +199,6 @@ struct SettingsView: View { .textContentType(.emailAddress) .autocapitalization(.none) - if !authMessage.isEmpty { - Text(authMessage) - .font(CMFonts.body(size: 12)) - .foregroundStyle(authIsError ? CMColors.error : CMColors.success) - } - Button { Task { await performForgotPassword() } } label: { @@ -369,6 +416,25 @@ struct SettingsView: View { // MARK: - Auth Actions + private func performLogin() async { + authSubmitting = true + authMessage = "" + if loginIsRegister { + await authService.register(name: loginName, email: loginEmail, password: loginPassword) + } else { + await authService.login(email: loginEmail, password: loginPassword) + } + authSubmitting = false + if case .error(let msg) = authService.state { + authMessage = msg + authIsError = true + } else { + loginEmail = "" + loginPassword = "" + loginName = "" + } + } + private func performChangePassword() async { authSubmitting = true authMessage = ""