- Completion animations: play user-selected animation on task card after completing, with DataManager guard to prevent race condition during animation playback. Works in both AllTasksView and ResidenceDetailView. Animation preference persisted via @AppStorage and configurable from Settings. - Subscription: add trial fields (trialStart, trialEnd, trialActive) and subscriptionSource to model, cross-platform purchase guard, trial banner in upgrade prompt, and platform-aware subscription management in profile. - Analytics: disable PostHog SDK debug logging and remove console print statements to reduce debug console noise. - Documents: remove redundant nested do-catch blocks in ViewModel wrapper. - Widgets: add debounced timeline reloads and thread-safe file I/O queue. - Onboarding: fix animation leak on disappear, remove unused state vars. - Remove unused files (ContentView, StateFlowExtensions, CustomView). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
771 lines
32 KiB
Swift
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 {
|
|
NavigationStack {
|
|
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.map { "\($0)" } ?? ""
|
|
bathrooms = residence.bathrooms.map { "\($0)" } ?? ""
|
|
squareFootage = residence.squareFootage.map { "\($0)" } ?? ""
|
|
lotSize = residence.lotSize.map { "\($0)" } ?? ""
|
|
yearBuilt = residence.yearBuilt.map { "\($0)" } ?? ""
|
|
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))
|
|
}
|