Files
honeyDueKMP/iosApp/iosApp/ResidenceFormView.swift
Trey t c334ce0bd0 Add PostHog analytics integration for Android and iOS
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>
2025-12-07 23:53:00 -06:00

490 lines
20 KiB
Swift

import SwiftUI
import ComposeApp
struct ResidenceFormView: View {
let existingResidence: ResidenceResponse?
@Binding var isPresented: Bool
var onSuccess: (() -> Void)?
@StateObject private var viewModel = ResidenceViewModel()
@ObservedObject private var dataManager = DataManagerObservable.shared
@FocusState private var focusedField: Field?
// Lookups from DataManagerObservable
private var residenceTypes: [ResidenceType] { dataManager.residenceTypes }
// User management state
@State private var users: [ResidenceUserResponse] = []
@State private var isLoadingUsers = false
@State private var userToRemove: ResidenceUserResponse?
@State private var showRemoveUserConfirmation = false
// Check if current user is the owner
private var isCurrentUserOwner: Bool {
guard let residence = existingResidence,
let currentUser = dataManager.currentUser else { return false }
return Int(residence.ownerId) == Int(currentUser.id)
}
// Form fields
@State private var name: String = ""
@State private var selectedPropertyType: ResidenceType?
@State private var streetAddress: String = ""
@State private var apartmentUnit: String = ""
@State private var city: String = ""
@State private var stateProvince: String = ""
@State private var postalCode: String = ""
@State private var country: String = "USA"
@State private var bedrooms: String = ""
@State private var bathrooms: String = ""
@State private var squareFootage: String = ""
@State private var lotSize: String = ""
@State private var yearBuilt: String = ""
@State private var description: String = ""
@State private var isPrimary: Bool = false
// Validation errors
@State private var nameError: String = ""
enum Field {
case name, streetAddress, apartmentUnit, city, stateProvince, postalCode, country
case bedrooms, bathrooms, squareFootage, lotSize, yearBuilt, description
}
private var isEditMode: Bool {
existingResidence != nil
}
private var canSave: Bool {
!name.isEmpty
}
var body: some View {
NavigationView {
Form {
Section {
TextField(L10n.Residences.propertyName, text: $name)
.focused($focusedField, equals: .name)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField)
if !nameError.isEmpty {
Text(nameError)
.font(.caption)
.foregroundColor(Color.appError)
}
Picker(L10n.Residences.propertyType, selection: $selectedPropertyType) {
Text(L10n.Residences.selectType).tag(nil as ResidenceType?)
ForEach(residenceTypes, id: \.id) { type in
Text(type.name).tag(type as ResidenceType?)
}
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
} header: {
Text(L10n.Residences.propertyDetails)
} footer: {
Text(L10n.Residences.requiredName)
.font(.caption)
.foregroundColor(Color.appError)
}
.listRowBackground(Color.appBackgroundSecondary)
Section {
TextField(L10n.Residences.streetAddress, text: $streetAddress)
.focused($focusedField, equals: .streetAddress)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField)
TextField(L10n.Residences.apartmentUnit, text: $apartmentUnit)
.focused($focusedField, equals: .apartmentUnit)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField)
TextField(L10n.Residences.city, text: $city)
.focused($focusedField, equals: .city)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField)
TextField(L10n.Residences.stateProvince, text: $stateProvince)
.focused($focusedField, equals: .stateProvince)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField)
TextField(L10n.Residences.postalCode, text: $postalCode)
.focused($focusedField, equals: .postalCode)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField)
TextField(L10n.Residences.country, text: $country)
.focused($focusedField, equals: .country)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField)
} header: {
Text(L10n.Residences.address)
}
.listRowBackground(Color.appBackgroundSecondary)
Section(header: Text(L10n.Residences.propertyFeatures)) {
HStack {
Text(L10n.Residences.bedrooms)
Spacer()
TextField("0", text: $bedrooms)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 60)
.focused($focusedField, equals: .bedrooms)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField)
}
HStack {
Text(L10n.Residences.bathrooms)
Spacer()
TextField("0.0", text: $bathrooms)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 60)
.focused($focusedField, equals: .bathrooms)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField)
}
TextField(L10n.Residences.squareFootage, text: $squareFootage)
.keyboardType(.numberPad)
.focused($focusedField, equals: .squareFootage)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField)
TextField(L10n.Residences.lotSize, text: $lotSize)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .lotSize)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField)
TextField(L10n.Residences.yearBuilt, text: $yearBuilt)
.keyboardType(.numberPad)
.focused($focusedField, equals: .yearBuilt)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField)
}
.listRowBackground(Color.appBackgroundSecondary)
Section(header: Text(L10n.Residences.additionalDetails)) {
TextField(L10n.Residences.description, text: $description, axis: .vertical)
.lineLimit(3...6)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField)
Toggle(L10n.Residences.primaryResidence, isOn: $isPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
}
.listRowBackground(Color.appBackgroundSecondary)
// Users section (edit mode only, owner only)
if isEditMode && isCurrentUserOwner {
Section {
if isLoadingUsers {
HStack {
Spacer()
ProgressView()
Spacer()
}
} else if users.isEmpty {
Text("No shared users")
.foregroundColor(.secondary)
} else {
ForEach(users, id: \.id) { user in
UserRow(
user: user,
isOwner: user.id == existingResidence?.ownerId,
onRemove: {
userToRemove = user
showRemoveUserConfirmation = true
}
)
}
}
} header: {
Text("Shared Users (\(users.count))")
} footer: {
Text("Users with access to this residence. Use the share button to invite others.")
}
.listRowBackground(Color.appBackgroundSecondary)
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.foregroundColor(Color.appError)
.font(.caption)
}
.listRowBackground(Color.appBackgroundSecondary)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(isEditMode ? L10n.Residences.editTitle : L10n.Residences.addTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(L10n.Common.cancel) {
isPresented = false
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.formCancelButton)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(L10n.Common.save) {
submitForm()
}
.disabled(!canSave || viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.saveButton)
}
}
.onAppear {
if !isEditMode {
PostHogAnalytics.shared.screen(AnalyticsEvents.newResidenceScreenShown)
}
loadResidenceTypes()
initializeForm()
if isEditMode && isCurrentUserOwner {
loadUsers()
}
}
.alert("Remove User", isPresented: $showRemoveUserConfirmation) {
Button("Cancel", role: .cancel) {
userToRemove = nil
}
Button("Remove", role: .destructive) {
if let user = userToRemove {
removeUser(user)
}
}
} message: {
if let user = userToRemove {
Text("Are you sure you want to remove \(user.username) from this residence?")
}
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { submitForm() }
)
}
}
private func loadResidenceTypes() {
Task {
// Trigger residence types refresh if needed
// Residence types are now loaded from DataManagerObservable
// Just trigger a refresh if needed
_ = try? await APILayer.shared.getResidenceTypes(forceRefresh: false)
}
}
private func initializeForm() {
if let residence = existingResidence {
// Edit mode - populate fields from existing residence
name = residence.name
streetAddress = residence.streetAddress ?? ""
apartmentUnit = residence.apartmentUnit ?? ""
city = residence.city ?? ""
stateProvince = residence.stateProvince ?? ""
postalCode = residence.postalCode ?? ""
country = residence.country ?? ""
bedrooms = residence.bedrooms != nil ? "\(residence.bedrooms!)" : ""
bathrooms = residence.bathrooms != nil ? "\(residence.bathrooms!)" : ""
squareFootage = residence.squareFootage != nil ? "\(residence.squareFootage!)" : ""
lotSize = residence.lotSize != nil ? "\(residence.lotSize!)" : ""
yearBuilt = residence.yearBuilt != nil ? "\(residence.yearBuilt!)" : ""
description = residence.description_ ?? ""
isPrimary = residence.isPrimary
// Set the selected property type
if let propertyTypeId = residence.propertyTypeId {
selectedPropertyType = residenceTypes.first { $0.id == Int32(propertyTypeId) }
}
}
// In add mode, leave selectedPropertyType as nil to force user to select
}
private func validateForm() -> Bool {
var isValid = true
if name.isEmpty {
nameError = L10n.Residences.nameRequired
isValid = false
} else {
nameError = ""
}
return isValid
}
private func submitForm() {
guard validateForm() else { return }
// Convert optional numeric fields to Kotlin types
let bedroomsValue: KotlinInt? = {
guard !bedrooms.isEmpty, let value = Int32(bedrooms) else { return nil }
return KotlinInt(int: value)
}()
let bathroomsValue: KotlinDouble? = {
guard !bathrooms.isEmpty, let value = Double(bathrooms) else { return nil }
return KotlinDouble(double: value)
}()
let squareFootageValue: KotlinInt? = {
guard !squareFootage.isEmpty, let value = Int32(squareFootage) else { return nil }
return KotlinInt(int: value)
}()
let lotSizeValue: KotlinDouble? = {
guard !lotSize.isEmpty, let value = Double(lotSize) else { return nil }
return KotlinDouble(double: value)
}()
let yearBuiltValue: KotlinInt? = {
guard !yearBuilt.isEmpty, let value = Int32(yearBuilt) else { return nil }
return KotlinInt(int: value)
}()
// Convert propertyType to KotlinInt if it exists
let propertyTypeValue: KotlinInt? = {
guard let type = selectedPropertyType else { return nil }
return KotlinInt(int: Int32(type.id))
}()
let request = ResidenceCreateRequest(
name: name,
propertyTypeId: propertyTypeValue,
streetAddress: streetAddress.isEmpty ? nil : streetAddress,
apartmentUnit: apartmentUnit.isEmpty ? nil : apartmentUnit,
city: city.isEmpty ? nil : city,
stateProvince: stateProvince.isEmpty ? nil : stateProvince,
postalCode: postalCode.isEmpty ? nil : postalCode,
country: country.isEmpty ? nil : country,
bedrooms: bedroomsValue,
bathrooms: bathroomsValue,
squareFootage: squareFootageValue,
lotSize: lotSizeValue,
yearBuilt: yearBuiltValue,
description: description.isEmpty ? nil : description,
purchaseDate: nil,
purchasePrice: nil,
isPrimary: KotlinBoolean(bool: isPrimary)
)
if let residence = existingResidence {
// Edit mode
viewModel.updateResidence(id: residence.id, request: request) { success in
if success {
onSuccess?()
isPresented = false
}
}
} else {
// Add mode
viewModel.createResidence(request: request) { success in
if success {
// Track residence created
PostHogAnalytics.shared.capture(AnalyticsEvents.residenceCreated, properties: [
"residence_type": selectedPropertyType?.name ?? "unknown"
])
onSuccess?()
isPresented = false
}
}
}
}
private func loadUsers() {
guard let residence = existingResidence,
TokenStorage.shared.getToken() != nil else { return }
isLoadingUsers = true
Task {
do {
let result = try await APILayer.shared.getResidenceUsers(residenceId: residence.id)
await MainActor.run {
if let successResult = result as? ApiResultSuccess<NSArray>,
let responseData = successResult.data as? [ResidenceUserResponse] {
// Filter out the owner from the list
self.users = responseData.filter { $0.id != residence.ownerId }
}
self.isLoadingUsers = false
}
} catch {
await MainActor.run {
self.isLoadingUsers = false
}
}
}
}
private func removeUser(_ user: ResidenceUserResponse) {
guard let residence = existingResidence,
TokenStorage.shared.getToken() != nil else { return }
Task {
do {
let result = try await APILayer.shared.removeUser(residenceId: residence.id, userId: user.id)
await MainActor.run {
if result is ApiResultSuccess<RemoveUserResponse> {
self.users.removeAll { $0.id == user.id }
}
self.userToRemove = nil
}
} catch {
await MainActor.run {
self.userToRemove = nil
}
}
}
}
}
// MARK: - User Row Component
private struct UserRow: View {
let user: ResidenceUserResponse
let isOwner: Bool
let onRemove: () -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(user.username)
.font(.body)
if isOwner {
Text("Owner")
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.appPrimary)
.clipShape(Capsule())
}
}
if !user.email.isEmpty {
Text(user.email)
.font(.caption)
.foregroundColor(.secondary)
}
let fullName = [user.firstName, user.lastName]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: " ")
if !fullName.isEmpty {
Text(fullName)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if !isOwner {
Button(action: onRemove) {
Image(systemName: "trash")
.foregroundColor(Color.appError)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
}
#Preview("Add Mode") {
ResidenceFormView(existingResidence: nil, isPresented: .constant(true))
}