Files
honeyDueKMP/iosApp/iosApp/ResidenceFormView.swift
Trey t 2fc4a48fc9 Replace PostHog integration with AnalyticsManager architecture
Remove old PostHogAnalytics singleton and replace with guide-based
two-file architecture: AnalyticsManager (singleton wrapper with super
properties, session replay, opt-out, subscription funnel) and
AnalyticsEvent (type-safe enum with associated values).

Key changes:
- New API key, self-hosted analytics endpoint
- All 19 events ported to type-safe AnalyticsEvent enum
- Screen tracking via AnalyticsManager.Screen enum + SwiftUI modifier
- Remove all identify() calls — fully anonymous analytics
- Add lifecycle hooks: flush on background, update super properties on foreground
- Add privacy opt-out toggle in Settings
- Subscription funnel methods ready for IAP integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:48:49 -06:00

771 lines
32 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 {
ZStack {
WarmGradientBackground()
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
// Property Details Section
OrganicFormSection(title: L10n.Residences.propertyDetails, icon: "house_outline") {
VStack(spacing: 16) {
OrganicFormTextField(
label: L10n.Residences.propertyName,
placeholder: "My Home",
text: $name,
error: nameError.isEmpty ? nil : nameError,
accessibilityId: AccessibilityIdentifiers.Residence.nameField
)
.focused($focusedField, equals: .name)
OrganicFormPicker(
label: L10n.Residences.propertyType,
selection: $selectedPropertyType,
options: residenceTypes,
optionLabel: { $0.name },
placeholder: L10n.Residences.selectType
)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
}
}
.padding(.top, 8)
// Address Section
OrganicFormSection(title: L10n.Residences.address, icon: "mappin.circle.fill") {
VStack(spacing: 16) {
OrganicFormTextField(
label: L10n.Residences.streetAddress,
placeholder: "123 Main St",
text: $streetAddress,
accessibilityId: AccessibilityIdentifiers.Residence.streetAddressField
)
.focused($focusedField, equals: .streetAddress)
OrganicFormTextField(
label: L10n.Residences.apartmentUnit,
placeholder: "Apt 4B",
text: $apartmentUnit,
accessibilityId: AccessibilityIdentifiers.Residence.apartmentUnitField
)
.focused($focusedField, equals: .apartmentUnit)
HStack(spacing: 12) {
OrganicFormTextField(
label: L10n.Residences.city,
placeholder: "City",
text: $city,
accessibilityId: AccessibilityIdentifiers.Residence.cityField
)
.focused($focusedField, equals: .city)
OrganicFormTextField(
label: L10n.Residences.stateProvince,
placeholder: "State",
text: $stateProvince,
accessibilityId: AccessibilityIdentifiers.Residence.stateProvinceField
)
.focused($focusedField, equals: .stateProvince)
.frame(maxWidth: 120)
}
HStack(spacing: 12) {
OrganicFormTextField(
label: L10n.Residences.postalCode,
placeholder: "12345",
text: $postalCode,
accessibilityId: AccessibilityIdentifiers.Residence.postalCodeField
)
.focused($focusedField, equals: .postalCode)
OrganicFormTextField(
label: L10n.Residences.country,
placeholder: "USA",
text: $country,
accessibilityId: AccessibilityIdentifiers.Residence.countryField
)
.focused($focusedField, equals: .country)
}
}
}
// Property Features Section
OrganicFormSection(title: L10n.Residences.propertyFeatures, icon: "square.grid.2x2.fill") {
VStack(spacing: 16) {
HStack(spacing: 12) {
OrganicFormTextField(
label: L10n.Residences.bedrooms,
placeholder: "0",
text: $bedrooms,
keyboardType: .numberPad,
accessibilityId: AccessibilityIdentifiers.Residence.bedroomsField
)
.focused($focusedField, equals: .bedrooms)
OrganicFormTextField(
label: L10n.Residences.bathrooms,
placeholder: "0.0",
text: $bathrooms,
keyboardType: .decimalPad,
accessibilityId: AccessibilityIdentifiers.Residence.bathroomsField
)
.focused($focusedField, equals: .bathrooms)
}
HStack(spacing: 12) {
OrganicFormTextField(
label: L10n.Residences.squareFootage,
placeholder: "sq ft",
text: $squareFootage,
keyboardType: .numberPad,
accessibilityId: AccessibilityIdentifiers.Residence.squareFootageField
)
.focused($focusedField, equals: .squareFootage)
OrganicFormTextField(
label: L10n.Residences.lotSize,
placeholder: "acres",
text: $lotSize,
keyboardType: .decimalPad,
accessibilityId: AccessibilityIdentifiers.Residence.lotSizeField
)
.focused($focusedField, equals: .lotSize)
}
OrganicFormTextField(
label: L10n.Residences.yearBuilt,
placeholder: "2020",
text: $yearBuilt,
keyboardType: .numberPad,
accessibilityId: AccessibilityIdentifiers.Residence.yearBuiltField
)
.focused($focusedField, equals: .yearBuilt)
}
}
// Additional Details Section
OrganicFormSection(title: L10n.Residences.additionalDetails, icon: "text.alignleft") {
VStack(spacing: 16) {
OrganicFormTextArea(
label: L10n.Residences.description,
placeholder: "Add notes about your property...",
text: $description
)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField)
OrganicFormToggle(
label: L10n.Residences.primaryResidence,
isOn: $isPrimary,
icon: "star.fill"
)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
}
}
// Users Section (edit mode only, owner only)
if isEditMode && isCurrentUserOwner {
OrganicFormSection(title: "Shared Users (\(users.count))", icon: "person.2.fill") {
VStack(spacing: 12) {
if isLoadingUsers {
HStack {
Spacer()
ProgressView()
Spacer()
}
.padding(.vertical, 20)
} else if users.isEmpty {
Text("No shared users")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.padding(.vertical, 12)
} else {
ForEach(users, id: \.id) { user in
OrganicUserRow(
user: user,
isOwner: user.id == existingResidence?.ownerId,
onRemove: {
userToRemove = user
showRemoveUserConfirmation = true
}
)
}
}
Text("Use the share button to invite others")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal, 16)
}
Spacer()
.frame(height: 40)
}
.padding(.horizontal, 16)
}
.keyboardDismissToolbar()
}
.navigationTitle(isEditMode ? L10n.Residences.editTitle : L10n.Residences.addTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
.padding(8)
.background(Color.appBackgroundSecondary.opacity(0.8))
.clipShape(Circle())
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.formCancelButton)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: submitForm) {
HStack(spacing: 6) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color.appTextOnPrimary))
.scaleEffect(0.8)
}
Text(L10n.Common.save)
.font(.system(size: 15, weight: .semibold))
}
.foregroundColor(canSave ? Color.appTextOnPrimary : Color.appTextSecondary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(canSave ? Color.appPrimary : Color.appTextSecondary.opacity(0.3))
.clipShape(Capsule())
}
.disabled(!canSave || viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.saveButton)
}
}
.onAppear {
if !isEditMode {
AnalyticsManager.shared.trackScreen(.newResidence)
}
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?")
}
}
}
}
private func loadResidenceTypes() {
Task {
_ = try? await APILayer.shared.getResidenceTypes(forceRefresh: false)
}
}
private func initializeForm() {
if let residence = existingResidence {
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
if let propertyTypeId = residence.propertyTypeId {
selectedPropertyType = residenceTypes.first { $0.id == Int32(propertyTypeId) }
}
}
}
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 }
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)
}()
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 {
viewModel.updateResidence(id: residence.id, request: request) { success in
if success {
onSuccess?()
isPresented = false
}
}
} else {
viewModel.createResidence(request: request) { success in
if success {
AnalyticsManager.shared.track(.residenceCreated(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] {
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: - Organic Form Components
private struct OrganicFormSection<Content: View>: View {
let title: String
let icon: String
@ViewBuilder let content: Content
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 28, height: 28)
if icon == "house_outline" {
Image("house_outline")
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
.foregroundColor(Color.appPrimary)
} else {
Image(systemName: icon)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
}
Text(title.uppercased())
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
}
content
}
.padding(OrganicSpacing.cozy)
.background(
ZStack {
Color.appBackgroundSecondary
GeometryReader { geo in
OrganicBlobShape(variation: Int.random(in: 0...2))
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(colorScheme == .dark ? 0.06 : 0.03),
Color.appPrimary.opacity(0.01)
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.4
)
)
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.5)
.offset(x: geo.size.width * 0.5, y: -geo.size.height * 0.1)
.blur(radius: 15)
}
GrainTexture(opacity: 0.012)
}
)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.naturalShadow(.medium)
}
}
private struct OrganicFormTextField: View {
let label: String
let placeholder: String
@Binding var text: String
var error: String? = nil
var keyboardType: UIKeyboardType = .default
var accessibilityId: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
TextField(placeholder, text: $text)
.font(.system(size: 16, weight: .medium))
.keyboardType(keyboardType)
.padding(14)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(error != nil ? Color.appError : Color.appTextSecondary.opacity(0.1), lineWidth: 1)
)
.accessibilityIdentifier(accessibilityId ?? "")
if let error = error {
Text(error)
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color.appError)
}
}
}
}
private struct OrganicFormTextArea: View {
let label: String
let placeholder: String
@Binding var text: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
TextField(placeholder, text: $text, axis: .vertical)
.font(.system(size: 16, weight: .medium))
.lineLimit(3...6)
.padding(14)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.appTextSecondary.opacity(0.1), lineWidth: 1)
)
}
}
}
private struct OrganicFormPicker<T: Hashable>: View {
let label: String
@Binding var selection: T?
let options: [T]
let optionLabel: (T) -> String
let placeholder: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
Menu {
Button(action: { selection = nil }) {
Text(placeholder)
}
ForEach(options, id: \.self) { option in
Button(action: { selection = option }) {
Text(optionLabel(option))
}
}
} label: {
HStack {
Text(selection.map { optionLabel($0) } ?? placeholder)
.font(.system(size: 16, weight: .medium))
.foregroundColor(selection == nil ? Color.appTextSecondary : Color.appTextPrimary)
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
.padding(14)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.appTextSecondary.opacity(0.1), lineWidth: 1)
)
}
}
}
}
private struct OrganicFormToggle: View {
let label: String
@Binding var isOn: Bool
let icon: String
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(isOn ? Color.appAccent.opacity(0.15) : Color.appTextSecondary.opacity(0.1))
.frame(width: 36, height: 36)
Image(systemName: icon)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(isOn ? Color.appAccent : Color.appTextSecondary)
}
Text(label)
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color.appTextPrimary)
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(Color.appPrimary)
}
.padding(14)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
private struct OrganicUserRow: View {
let user: ResidenceUserResponse
let isOwner: Bool
let onRemove: () -> Void
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 40, height: 40)
Text(String(user.username.prefix(1)).uppercased())
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(user.username)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.appTextPrimary)
if isOwner {
Text("Owner")
.font(.system(size: 10, weight: .bold))
.foregroundColor(Color.appTextOnPrimary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.appPrimary)
.clipShape(Capsule())
}
}
if !user.email.isEmpty {
Text(user.email)
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
Spacer()
if !isOwner {
Button(action: onRemove) {
Image(systemName: "trash")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
.padding(8)
.background(Color.appError.opacity(0.1))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
}
.padding(12)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
#Preview("Add Mode") {
ResidenceFormView(existingResidence: nil, isPresented: .constant(true))
}