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>
490 lines
20 KiB
Swift
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))
|
|
}
|