feat(ios): add telemetry client, feature flags client, Settings login/register form
This commit is contained in:
parent
ef27f9dcc6
commit
180c98160b
@ -9,6 +9,7 @@ struct ChronoMindApp: App {
|
|||||||
@StateObject private var notificationManager = CMNotificationManager.shared
|
@StateObject private var notificationManager = CMNotificationManager.shared
|
||||||
@StateObject private var gamification = GamificationStore.shared
|
@StateObject private var gamification = GamificationStore.shared
|
||||||
@StateObject private var authService = CMAuthService.shared
|
@StateObject private var authService = CMAuthService.shared
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@ -42,6 +43,20 @@ struct ChronoMindApp: App {
|
|||||||
CMLoginView(authService: authService)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
ios/ChronoMind/Shared/Cloud/FeatureFlagService.swift
Normal file
71
ios/ChronoMind/Shared/Cloud/FeatureFlagService.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
ios/ChronoMind/Shared/Cloud/TelemetryService.swift
Normal file
138
ios/ChronoMind/Shared/Cloud/TelemetryService.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,10 @@ struct SettingsView: View {
|
|||||||
@State private var authMessage = ""
|
@State private var authMessage = ""
|
||||||
@State private var authIsError = false
|
@State private var authIsError = false
|
||||||
@State private var authSubmitting = 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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@ -130,7 +134,56 @@ struct SettingsView: View {
|
|||||||
.disabled(authSubmitting || deleteConfirmPw.isEmpty)
|
.disabled(authSubmitting || deleteConfirmPw.isEmpty)
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
Button {
|
||||||
showForgotPw.toggle()
|
showForgotPw.toggle()
|
||||||
authMessage = ""
|
authMessage = ""
|
||||||
@ -146,12 +199,6 @@ struct SettingsView: View {
|
|||||||
.textContentType(.emailAddress)
|
.textContentType(.emailAddress)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
|
|
||||||
if !authMessage.isEmpty {
|
|
||||||
Text(authMessage)
|
|
||||||
.font(CMFonts.body(size: 12))
|
|
||||||
.foregroundStyle(authIsError ? CMColors.error : CMColors.success)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
Task { await performForgotPassword() }
|
Task { await performForgotPassword() }
|
||||||
} label: {
|
} label: {
|
||||||
@ -369,6 +416,25 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// MARK: - Auth Actions
|
// 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 {
|
private func performChangePassword() async {
|
||||||
authSubmitting = true
|
authSubmitting = true
|
||||||
authMessage = ""
|
authMessage = ""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user