Polish UI consistency across all CRUD forms and fix data display issues
- Rewrite ResidenceFormView to use standard Form/Section pattern matching TaskFormView - Remove unused organic form components (OrganicFormSection, OrganicFormTextField, etc.) - Fix DocumentFormView: NavigationView→NavigationStack, WarmGradientBackground→appBackgroundPrimary, listRowBackground→sectionBackground - Add Required footer to residence name field and task title/property fields - Remove redundant Required footers from pickers that always have values - Fix grey priority dots on kanban cards by guarding PriorityBadge in DynamicTaskCard and TaskCard - Fix empty frequency labels showing on task cards - Fix contractor Maps URL building to filter empty strings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -320,7 +320,7 @@ struct ContractorDetailView: View {
|
||||
contractor.city,
|
||||
contractor.stateProvince,
|
||||
contractor.postalCode
|
||||
].compactMap { $0 }.joined(separator: ", ")
|
||||
].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ")
|
||||
|
||||
if let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: "maps://?address=\(encoded)") {
|
||||
@@ -430,7 +430,7 @@ struct ContractorDetailView: View {
|
||||
contractor.city,
|
||||
contractor.stateProvince,
|
||||
contractor.postalCode
|
||||
].compactMap { $0 }.joined(separator: ", ")
|
||||
].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ")
|
||||
|
||||
if let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: "maps://?address=\(encoded)") {
|
||||
|
||||
@@ -140,7 +140,7 @@ struct DocumentFormView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
.sectionBackground()
|
||||
|
||||
Section(L10n.Documents.warrantyClaims) {
|
||||
TextField(L10n.Documents.claimPhoneOptional, text: $claimPhone)
|
||||
@@ -151,14 +151,14 @@ struct DocumentFormView: View {
|
||||
TextField(L10n.Documents.claimWebsiteOptional, text: $claimWebsite)
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
.sectionBackground()
|
||||
|
||||
Section(L10n.Documents.warrantyDates) {
|
||||
TextField(L10n.Documents.purchaseDate, text: $purchaseDate)
|
||||
TextField(L10n.Documents.startDate, text: $startDate)
|
||||
TextField(L10n.Documents.endDate, text: $endDate)
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
.sectionBackground()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ struct DocumentFormView: View {
|
||||
.frame(height: 200)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
.sectionBackground()
|
||||
}
|
||||
|
||||
Section(L10n.Documents.photos) {
|
||||
@@ -191,17 +191,17 @@ struct DocumentFormView: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
.sectionBackground()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
Form {
|
||||
formContent
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(WarmGradientBackground())
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.navigationTitle(isEditMode ? (isWarranty ? L10n.Documents.editWarranty : L10n.Documents.editDocument) : (isWarranty ? L10n.Documents.addWarranty : L10n.Documents.addDocument))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -315,7 +315,7 @@ struct DocumentFormView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
.sectionBackground()
|
||||
|
||||
// Basic Information
|
||||
Section {
|
||||
@@ -336,7 +336,7 @@ struct DocumentFormView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
.sectionBackground()
|
||||
|
||||
// Warranty-specific fields
|
||||
warrantySection
|
||||
@@ -362,7 +362,7 @@ struct DocumentFormView: View {
|
||||
.lineLimit(3...6)
|
||||
.keyboardDismissToolbar()
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
.sectionBackground()
|
||||
|
||||
// Active Status (Edit mode only)
|
||||
if isEditMode {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"" : {
|
||||
|
||||
},
|
||||
"*" : {
|
||||
|
||||
},
|
||||
@@ -24944,6 +24941,9 @@
|
||||
"Share this 6-character code. They can enter it in the app to join." : {
|
||||
"comment" : "A description of how to share the invitation code with others.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Shared Users (%lld)" : {
|
||||
|
||||
},
|
||||
"Sign in with Google" : {
|
||||
|
||||
@@ -30148,8 +30148,7 @@
|
||||
|
||||
},
|
||||
"Your Home Dashboard" : {
|
||||
"comment" : "The title of the main view in the Home app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
||||
},
|
||||
"Your home maintenance companion" : {
|
||||
"comment" : "The tagline for the app, describing its purpose.",
|
||||
|
||||
@@ -60,256 +60,201 @@ struct ResidenceFormView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
Form {
|
||||
// Property Details
|
||||
Section {
|
||||
TextField(L10n.Residences.propertyName, text: $name)
|
||||
.focused($focusedField, equals: .name)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField)
|
||||
|
||||
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)
|
||||
if !nameError.isEmpty {
|
||||
FieldError(message: nameError)
|
||||
}
|
||||
|
||||
OrganicFormPicker(
|
||||
label: L10n.Residences.propertyType,
|
||||
selection: $selectedPropertyType,
|
||||
options: residenceTypes,
|
||||
optionLabel: { $0.name },
|
||||
placeholder: L10n.Residences.selectType
|
||||
)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
|
||||
}
|
||||
Picker(L10n.Residences.propertyType, selection: $selectedPropertyType) {
|
||||
Text(L10n.Residences.selectType).tag(nil as ResidenceType?)
|
||||
ForEach(residenceTypes, id: \.self) { type in
|
||||
Text(type.name).tag(type as ResidenceType?)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
|
||||
} header: {
|
||||
Text(L10n.Residences.propertyDetails)
|
||||
} footer: {
|
||||
Text(L10n.Residences.nameRequired)
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
.sectionBackground()
|
||||
|
||||
// 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)
|
||||
// Address
|
||||
Section {
|
||||
TextField(L10n.Residences.streetAddress, text: $streetAddress)
|
||||
.focused($focusedField, equals: .streetAddress)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField)
|
||||
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.apartmentUnit,
|
||||
placeholder: "Apt 4B",
|
||||
text: $apartmentUnit,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.apartmentUnitField
|
||||
)
|
||||
.focused($focusedField, equals: .apartmentUnit)
|
||||
TextField(L10n.Residences.apartmentUnit, text: $apartmentUnit)
|
||||
.focused($focusedField, equals: .apartmentUnit)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.city,
|
||||
placeholder: "City",
|
||||
text: $city,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.cityField
|
||||
)
|
||||
.focused($focusedField, equals: .city)
|
||||
TextField(L10n.Residences.city, text: $city)
|
||||
.focused($focusedField, equals: .city)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField)
|
||||
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.stateProvince,
|
||||
placeholder: "State",
|
||||
text: $stateProvince,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.stateProvinceField
|
||||
)
|
||||
.focused($focusedField, equals: .stateProvince)
|
||||
.frame(maxWidth: 120)
|
||||
}
|
||||
TextField(L10n.Residences.stateProvince, text: $stateProvince)
|
||||
.focused($focusedField, equals: .stateProvince)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.postalCode,
|
||||
placeholder: "12345",
|
||||
text: $postalCode,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.postalCodeField
|
||||
)
|
||||
.focused($focusedField, equals: .postalCode)
|
||||
TextField(L10n.Residences.postalCode, text: $postalCode)
|
||||
.focused($focusedField, equals: .postalCode)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField)
|
||||
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.country,
|
||||
placeholder: "USA",
|
||||
text: $country,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.countryField
|
||||
)
|
||||
.focused($focusedField, equals: .country)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextField(L10n.Residences.country, text: $country)
|
||||
.focused($focusedField, equals: .country)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField)
|
||||
} header: {
|
||||
Text(L10n.Residences.address)
|
||||
}
|
||||
.sectionBackground()
|
||||
|
||||
// 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)
|
||||
// Property Features
|
||||
Section {
|
||||
TextField(L10n.Residences.bedrooms, text: $bedrooms)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .bedrooms)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField)
|
||||
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.bathrooms,
|
||||
placeholder: "0.0",
|
||||
text: $bathrooms,
|
||||
keyboardType: .decimalPad,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.bathroomsField
|
||||
)
|
||||
.focused($focusedField, equals: .bathrooms)
|
||||
}
|
||||
TextField(L10n.Residences.bathrooms, text: $bathrooms)
|
||||
.keyboardType(.decimalPad)
|
||||
.focused($focusedField, equals: .bathrooms)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.squareFootage,
|
||||
placeholder: "sq ft",
|
||||
text: $squareFootage,
|
||||
keyboardType: .numberPad,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.squareFootageField
|
||||
)
|
||||
.focused($focusedField, equals: .squareFootage)
|
||||
TextField(L10n.Residences.squareFootage, text: $squareFootage)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .squareFootage)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField)
|
||||
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.lotSize,
|
||||
placeholder: "acres",
|
||||
text: $lotSize,
|
||||
keyboardType: .decimalPad,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.lotSizeField
|
||||
)
|
||||
.focused($focusedField, equals: .lotSize)
|
||||
}
|
||||
TextField(L10n.Residences.lotSize, text: $lotSize)
|
||||
.keyboardType(.decimalPad)
|
||||
.focused($focusedField, equals: .lotSize)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField)
|
||||
|
||||
OrganicFormTextField(
|
||||
label: L10n.Residences.yearBuilt,
|
||||
placeholder: "2020",
|
||||
text: $yearBuilt,
|
||||
keyboardType: .numberPad,
|
||||
accessibilityId: AccessibilityIdentifiers.Residence.yearBuiltField
|
||||
)
|
||||
.focused($focusedField, equals: .yearBuilt)
|
||||
}
|
||||
}
|
||||
TextField(L10n.Residences.yearBuilt, text: $yearBuilt)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .yearBuilt)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField)
|
||||
} header: {
|
||||
Text(L10n.Residences.propertyFeatures)
|
||||
}
|
||||
.sectionBackground()
|
||||
|
||||
// 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)
|
||||
// Additional Details
|
||||
Section {
|
||||
TextField(L10n.Residences.description, text: $description, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField)
|
||||
|
||||
OrganicFormToggle(
|
||||
label: L10n.Residences.primaryResidence,
|
||||
isOn: $isPrimary,
|
||||
icon: "star.fill"
|
||||
)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
|
||||
}
|
||||
}
|
||||
Toggle(L10n.Residences.primaryResidence, isOn: $isPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
|
||||
} header: {
|
||||
Text(L10n.Residences.additionalDetails)
|
||||
}
|
||||
.sectionBackground()
|
||||
|
||||
// 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)
|
||||
// Users Section (edit mode only, owner only)
|
||||
if isEditMode && isCurrentUserOwner {
|
||||
Section {
|
||||
if isLoadingUsers {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.appError.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.padding(.horizontal, 16)
|
||||
} else if users.isEmpty {
|
||||
Text("No shared users")
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
} else {
|
||||
ForEach(users, id: \.id) { user in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(user.username)
|
||||
.font(.body.weight(.medium))
|
||||
if user.id == existingResidence?.ownerId {
|
||||
Text("Owner")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.appPrimary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
if !user.email.isEmpty {
|
||||
Text(user.email)
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if user.id != existingResidence?.ownerId {
|
||||
Button {
|
||||
userToRemove = user
|
||||
showRemoveUserConfirmation = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
.frame(height: 40)
|
||||
} header: {
|
||||
Text("Shared Users (\(users.count))")
|
||||
} footer: {
|
||||
Text("Use the share button to invite others")
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.sectionBackground()
|
||||
}
|
||||
|
||||
// Error Message
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Section {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(Color.appError)
|
||||
Text(errorMessage)
|
||||
.foregroundColor(Color.appError)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
.sectionBackground()
|
||||
}
|
||||
.keyboardDismissToolbar()
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.navigationTitle(isEditMode ? L10n.Residences.editTitle : L10n.Residences.addTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.keyboardDismissToolbar()
|
||||
.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())
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.Common.cancel) {
|
||||
isPresented = false
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.formCancelButton)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(action: submitForm) {
|
||||
HStack(spacing: 6) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Color.appTextOnPrimary))
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
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)
|
||||
@@ -499,272 +444,6 @@ struct ResidenceFormView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ struct DynamicTaskCard: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
PriorityBadge(priority: task.priorityName ?? "")
|
||||
if let priorityName = task.priorityName, !priorityName.isEmpty {
|
||||
PriorityBadge(priority: priorityName)
|
||||
}
|
||||
}
|
||||
|
||||
if !task.description_.isEmpty {
|
||||
@@ -43,9 +45,11 @@ struct DynamicTaskCard: View {
|
||||
}
|
||||
|
||||
HStack {
|
||||
Label(task.frequencyDisplayName ?? "", systemImage: "repeat")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
if let frequency = task.frequencyDisplayName, !frequency.isEmpty {
|
||||
Label(frequency, systemImage: "repeat")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ struct TaskCard: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
PriorityBadge(priority: task.priorityName ?? "")
|
||||
if let priorityName = task.priorityName, !priorityName.isEmpty {
|
||||
PriorityBadge(priority: priorityName)
|
||||
}
|
||||
}
|
||||
|
||||
// Description
|
||||
@@ -44,10 +46,12 @@ struct TaskCard: View {
|
||||
|
||||
// Metadata Pills
|
||||
HStack(spacing: 10) {
|
||||
TaskMetadataPill(
|
||||
icon: "repeat",
|
||||
text: task.frequencyDisplayName ?? ""
|
||||
)
|
||||
if let frequency = task.frequencyDisplayName, !frequency.isEmpty {
|
||||
TaskMetadataPill(
|
||||
icon: "repeat",
|
||||
text: frequency
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
@@ -209,10 +209,6 @@ struct TaskFormView: View {
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.Tasks.category)
|
||||
} footer: {
|
||||
Text(L10n.Tasks.required)
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
.sectionBackground()
|
||||
|
||||
@@ -246,10 +242,6 @@ struct TaskFormView: View {
|
||||
Text("Enter the number of days between each occurrence")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
} else {
|
||||
Text(L10n.Tasks.required)
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
}
|
||||
.sectionBackground()
|
||||
@@ -265,10 +257,6 @@ struct TaskFormView: View {
|
||||
Toggle(L10n.Tasks.inProgressLabel, isOn: $inProgress)
|
||||
} header: {
|
||||
Text(L10n.Tasks.priorityAndStatus)
|
||||
} footer: {
|
||||
Text(L10n.Tasks.required)
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
.sectionBackground()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user