Implement comprehensive analytics tracking across both platforms: Android (Kotlin): - Add PostHog SDK dependency and initialization in MainActivity - Create expect/actual pattern for cross-platform analytics (commonMain/androidMain/iosMain/jvmMain/jsMain/wasmJsMain) - Track screen views: registration, login, residences, tasks, contractors, documents, notifications, profile - Track key events: user_registered, user_signed_in, residence_created, task_created, contractor_created, document_created - Track paywall events: contractor_paywall_shown, documents_paywall_shown - Track sharing events: residence_shared, contractor_shared - Track theme_changed event iOS (Swift): - Add PostHog iOS SDK via SPM - Create PostHogAnalytics wrapper and AnalyticsEvents constants - Initialize SDK in iOSApp with session replay support - Track same screen views and events as Android - Track user identification after login/registration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
211 lines
7.3 KiB
Swift
211 lines
7.3 KiB
Swift
import Foundation
|
|
import ComposeApp
|
|
|
|
/// Manages residence share package creation and import via .casera files.
|
|
/// For residences, the share code is generated server-side (unlike contractors which are exported client-side).
|
|
@MainActor
|
|
class ResidenceSharingManager: ObservableObject {
|
|
|
|
// MARK: - Singleton
|
|
|
|
static let shared = ResidenceSharingManager()
|
|
|
|
// MARK: - Published Properties
|
|
|
|
/// True while generating a share package from the server
|
|
@Published var isGeneratingPackage: Bool = false
|
|
|
|
/// True while importing a residence from a share package
|
|
@Published var isImporting: Bool = false
|
|
|
|
/// Error message if generation or import fails
|
|
@Published var errorMessage: String?
|
|
|
|
/// True after successful import
|
|
@Published var importSuccess: Bool = false
|
|
|
|
/// Name of the imported residence
|
|
@Published var importedResidenceName: String?
|
|
|
|
// MARK: - Private Properties
|
|
|
|
private let jsonEncoder: JSONEncoder = {
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = .prettyPrinted
|
|
return encoder
|
|
}()
|
|
|
|
private let jsonDecoder = JSONDecoder()
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Export (Share)
|
|
|
|
/// Creates a shareable .casera file for a residence.
|
|
/// This calls the backend to generate a one-time share code, then packages it.
|
|
/// - Parameter residence: The residence to share
|
|
/// - Returns: URL to the temporary file, or nil if creation failed
|
|
func createShareableFile(residence: ResidenceResponse) async -> URL? {
|
|
isGeneratingPackage = true
|
|
errorMessage = nil
|
|
|
|
defer { isGeneratingPackage = false }
|
|
|
|
// Call API to generate share package
|
|
let result: Any
|
|
do {
|
|
result = try await APILayer.shared.generateSharePackage(residenceId: residence.id)
|
|
} catch {
|
|
errorMessage = "Failed to generate share code: \(error.localizedDescription)"
|
|
return nil
|
|
}
|
|
|
|
guard let success = result as? ApiResultSuccess<SharedResidence>,
|
|
let sharedResidence = success.data else {
|
|
if let error = result as? ApiResultError {
|
|
errorMessage = ErrorMessageParser.parse(error.message)
|
|
} else {
|
|
errorMessage = "Failed to generate share code"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Create Swift-compatible structure for JSON encoding
|
|
let exportData = SharedResidenceExport(from: sharedResidence)
|
|
|
|
guard let jsonData = try? jsonEncoder.encode(exportData) else {
|
|
print("ResidenceSharingManager: Failed to encode residence to JSON")
|
|
errorMessage = "Failed to create share file"
|
|
return nil
|
|
}
|
|
|
|
// Create a safe filename
|
|
let safeName = residence.name
|
|
.replacingOccurrences(of: " ", with: "_")
|
|
.replacingOccurrences(of: "/", with: "-")
|
|
.prefix(50)
|
|
let fileName = "\(safeName).casera"
|
|
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
|
|
|
|
do {
|
|
try jsonData.write(to: tempURL)
|
|
// Track residence shared event
|
|
PostHogAnalytics.shared.capture(AnalyticsEvents.residenceShared, properties: ["method": "file"])
|
|
return tempURL
|
|
} catch {
|
|
print("ResidenceSharingManager: Failed to write .casera file: \(error)")
|
|
errorMessage = "Failed to save share file"
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Import
|
|
|
|
/// Imports a residence share from a .casera file URL.
|
|
/// This validates the share code with the server and adds the user to the residence.
|
|
/// - Parameters:
|
|
/// - url: The URL to the .casera file
|
|
/// - completion: Called with true on success, false on failure
|
|
func importResidence(from url: URL, completion: @escaping (Bool) -> Void) {
|
|
isImporting = true
|
|
errorMessage = nil
|
|
|
|
// Verify user is authenticated
|
|
guard TokenStorage.shared.getToken() != nil else {
|
|
errorMessage = "You must be logged in to join a residence"
|
|
isImporting = false
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
// Start accessing security-scoped resource if needed
|
|
let accessing = url.startAccessingSecurityScopedResource()
|
|
defer {
|
|
if accessing {
|
|
url.stopAccessingSecurityScopedResource()
|
|
}
|
|
}
|
|
|
|
do {
|
|
let data = try Data(contentsOf: url)
|
|
let exportData = try jsonDecoder.decode(SharedResidenceExport.self, from: data)
|
|
|
|
// Use the share code to join the residence
|
|
Task {
|
|
do {
|
|
let result = try await APILayer.shared.joinWithCode(code: exportData.shareCode)
|
|
|
|
if let success = result as? ApiResultSuccess<JoinResidenceResponse>,
|
|
let joinResponse = success.data {
|
|
self.importedResidenceName = joinResponse.residence.name
|
|
self.importSuccess = true
|
|
self.isImporting = false
|
|
completion(true)
|
|
} else if let error = result as? ApiResultError {
|
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
|
self.isImporting = false
|
|
completion(false)
|
|
} else {
|
|
self.errorMessage = "Unknown error occurred"
|
|
self.isImporting = false
|
|
completion(false)
|
|
}
|
|
} catch {
|
|
self.errorMessage = error.localizedDescription
|
|
self.isImporting = false
|
|
completion(false)
|
|
}
|
|
}
|
|
} catch {
|
|
errorMessage = "Failed to read residence share file: \(error.localizedDescription)"
|
|
isImporting = false
|
|
completion(false)
|
|
}
|
|
}
|
|
|
|
/// Resets the import state after showing success/error feedback
|
|
func resetState() {
|
|
errorMessage = nil
|
|
importSuccess = false
|
|
importedResidenceName = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Swift Codable Structure
|
|
|
|
/// Swift-native Codable structure for .casera residence share format.
|
|
/// This mirrors the Kotlin SharedResidence model for JSON serialization.
|
|
struct SharedResidenceExport: Codable {
|
|
let version: Int
|
|
let type: String
|
|
let shareCode: String
|
|
let residenceName: String
|
|
let sharedBy: String?
|
|
let expiresAt: String?
|
|
let exportedAt: String?
|
|
let exportedBy: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case version
|
|
case type
|
|
case shareCode = "share_code"
|
|
case residenceName = "residence_name"
|
|
case sharedBy = "shared_by"
|
|
case expiresAt = "expires_at"
|
|
case exportedAt = "exported_at"
|
|
case exportedBy = "exported_by"
|
|
}
|
|
|
|
/// Initialize from Kotlin SharedResidence
|
|
init(from sharedResidence: SharedResidence) {
|
|
self.version = Int(sharedResidence.version)
|
|
self.type = sharedResidence.type
|
|
self.shareCode = sharedResidence.shareCode
|
|
self.residenceName = sharedResidence.residenceName
|
|
self.sharedBy = sharedResidence.sharedBy
|
|
self.expiresAt = sharedResidence.expiresAt
|
|
self.exportedAt = sharedResidence.exportedAt
|
|
self.exportedBy = sharedResidence.exportedBy
|
|
}
|
|
}
|