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.city,
|
||||||
contractor.stateProvince,
|
contractor.stateProvince,
|
||||||
contractor.postalCode
|
contractor.postalCode
|
||||||
].compactMap { $0 }.joined(separator: ", ")
|
].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ")
|
||||||
|
|
||||||
if let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
if let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||||
let url = URL(string: "maps://?address=\(encoded)") {
|
let url = URL(string: "maps://?address=\(encoded)") {
|
||||||
@@ -430,7 +430,7 @@ struct ContractorDetailView: View {
|
|||||||
contractor.city,
|
contractor.city,
|
||||||
contractor.stateProvince,
|
contractor.stateProvince,
|
||||||
contractor.postalCode
|
contractor.postalCode
|
||||||
].compactMap { $0 }.joined(separator: ", ")
|
].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ")
|
||||||
|
|
||||||
if let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
if let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||||
let url = URL(string: "maps://?address=\(encoded)") {
|
let url = URL(string: "maps://?address=\(encoded)") {
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ struct DocumentFormView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(Color.appError)
|
.foregroundColor(Color.appError)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
.sectionBackground()
|
||||||
|
|
||||||
Section(L10n.Documents.warrantyClaims) {
|
Section(L10n.Documents.warrantyClaims) {
|
||||||
TextField(L10n.Documents.claimPhoneOptional, text: $claimPhone)
|
TextField(L10n.Documents.claimPhoneOptional, text: $claimPhone)
|
||||||
@@ -151,14 +151,14 @@ struct DocumentFormView: View {
|
|||||||
TextField(L10n.Documents.claimWebsiteOptional, text: $claimWebsite)
|
TextField(L10n.Documents.claimWebsiteOptional, text: $claimWebsite)
|
||||||
.keyboardType(.URL)
|
.keyboardType(.URL)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
.sectionBackground()
|
||||||
|
|
||||||
Section(L10n.Documents.warrantyDates) {
|
Section(L10n.Documents.warrantyDates) {
|
||||||
TextField(L10n.Documents.purchaseDate, text: $purchaseDate)
|
TextField(L10n.Documents.purchaseDate, text: $purchaseDate)
|
||||||
TextField(L10n.Documents.startDate, text: $startDate)
|
TextField(L10n.Documents.startDate, text: $startDate)
|
||||||
TextField(L10n.Documents.endDate, text: $endDate)
|
TextField(L10n.Documents.endDate, text: $endDate)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
.sectionBackground()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ struct DocumentFormView: View {
|
|||||||
.frame(height: 200)
|
.frame(height: 200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
.sectionBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(L10n.Documents.photos) {
|
Section(L10n.Documents.photos) {
|
||||||
@@ -191,17 +191,17 @@ struct DocumentFormView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
.sectionBackground()
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
formContent
|
formContent
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(WarmGradientBackground())
|
.background(Color.appBackgroundPrimary)
|
||||||
.navigationTitle(isEditMode ? (isWarranty ? L10n.Documents.editWarranty : L10n.Documents.editDocument) : (isWarranty ? L10n.Documents.addWarranty : L10n.Documents.addDocument))
|
.navigationTitle(isEditMode ? (isWarranty ? L10n.Documents.editWarranty : L10n.Documents.editDocument) : (isWarranty ? L10n.Documents.addWarranty : L10n.Documents.addDocument))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -315,7 +315,7 @@ struct DocumentFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
.sectionBackground()
|
||||||
|
|
||||||
// Basic Information
|
// Basic Information
|
||||||
Section {
|
Section {
|
||||||
@@ -336,7 +336,7 @@ struct DocumentFormView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(Color.appError)
|
.foregroundColor(Color.appError)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
.sectionBackground()
|
||||||
|
|
||||||
// Warranty-specific fields
|
// Warranty-specific fields
|
||||||
warrantySection
|
warrantySection
|
||||||
@@ -362,7 +362,7 @@ struct DocumentFormView: View {
|
|||||||
.lineLimit(3...6)
|
.lineLimit(3...6)
|
||||||
.keyboardDismissToolbar()
|
.keyboardDismissToolbar()
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
.sectionBackground()
|
||||||
|
|
||||||
// Active Status (Edit mode only)
|
// Active Status (Edit mode only)
|
||||||
if isEditMode {
|
if isEditMode {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "en",
|
"sourceLanguage" : "en",
|
||||||
"strings" : {
|
"strings" : {
|
||||||
"" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"*" : {
|
"*" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -24944,6 +24941,9 @@
|
|||||||
"Share this 6-character code. They can enter it in the app to join." : {
|
"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.",
|
"comment" : "A description of how to share the invitation code with others.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Shared Users (%lld)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Sign in with Google" : {
|
"Sign in with Google" : {
|
||||||
|
|
||||||
@@ -30148,8 +30148,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"Your Home Dashboard" : {
|
"Your Home Dashboard" : {
|
||||||
"comment" : "The title of the main view in the Home app.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"Your home maintenance companion" : {
|
"Your home maintenance companion" : {
|
||||||
"comment" : "The tagline for the app, describing its purpose.",
|
"comment" : "The tagline for the app, describing its purpose.",
|
||||||
|
|||||||
@@ -60,256 +60,201 @@ struct ResidenceFormView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
Form {
|
||||||
WarmGradientBackground()
|
// Property Details
|
||||||
|
Section {
|
||||||
|
TextField(L10n.Residences.propertyName, text: $name)
|
||||||
|
.focused($focusedField, equals: .name)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField)
|
||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
if !nameError.isEmpty {
|
||||||
VStack(spacing: OrganicSpacing.comfortable) {
|
FieldError(message: nameError)
|
||||||
// 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(
|
Picker(L10n.Residences.propertyType, selection: $selectedPropertyType) {
|
||||||
label: L10n.Residences.propertyType,
|
Text(L10n.Residences.selectType).tag(nil as ResidenceType?)
|
||||||
selection: $selectedPropertyType,
|
ForEach(residenceTypes, id: \.self) { type in
|
||||||
options: residenceTypes,
|
Text(type.name).tag(type as ResidenceType?)
|
||||||
optionLabel: { $0.name },
|
|
||||||
placeholder: L10n.Residences.selectType
|
|
||||||
)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.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
|
// Address
|
||||||
OrganicFormSection(title: L10n.Residences.address, icon: "mappin.circle.fill") {
|
Section {
|
||||||
VStack(spacing: 16) {
|
TextField(L10n.Residences.streetAddress, text: $streetAddress)
|
||||||
OrganicFormTextField(
|
.focused($focusedField, equals: .streetAddress)
|
||||||
label: L10n.Residences.streetAddress,
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField)
|
||||||
placeholder: "123 Main St",
|
|
||||||
text: $streetAddress,
|
|
||||||
accessibilityId: AccessibilityIdentifiers.Residence.streetAddressField
|
|
||||||
)
|
|
||||||
.focused($focusedField, equals: .streetAddress)
|
|
||||||
|
|
||||||
OrganicFormTextField(
|
TextField(L10n.Residences.apartmentUnit, text: $apartmentUnit)
|
||||||
label: L10n.Residences.apartmentUnit,
|
.focused($focusedField, equals: .apartmentUnit)
|
||||||
placeholder: "Apt 4B",
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField)
|
||||||
text: $apartmentUnit,
|
|
||||||
accessibilityId: AccessibilityIdentifiers.Residence.apartmentUnitField
|
|
||||||
)
|
|
||||||
.focused($focusedField, equals: .apartmentUnit)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
TextField(L10n.Residences.city, text: $city)
|
||||||
OrganicFormTextField(
|
.focused($focusedField, equals: .city)
|
||||||
label: L10n.Residences.city,
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField)
|
||||||
placeholder: "City",
|
|
||||||
text: $city,
|
|
||||||
accessibilityId: AccessibilityIdentifiers.Residence.cityField
|
|
||||||
)
|
|
||||||
.focused($focusedField, equals: .city)
|
|
||||||
|
|
||||||
OrganicFormTextField(
|
TextField(L10n.Residences.stateProvince, text: $stateProvince)
|
||||||
label: L10n.Residences.stateProvince,
|
.focused($focusedField, equals: .stateProvince)
|
||||||
placeholder: "State",
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField)
|
||||||
text: $stateProvince,
|
|
||||||
accessibilityId: AccessibilityIdentifiers.Residence.stateProvinceField
|
|
||||||
)
|
|
||||||
.focused($focusedField, equals: .stateProvince)
|
|
||||||
.frame(maxWidth: 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
TextField(L10n.Residences.postalCode, text: $postalCode)
|
||||||
OrganicFormTextField(
|
.focused($focusedField, equals: .postalCode)
|
||||||
label: L10n.Residences.postalCode,
|
.keyboardType(.numberPad)
|
||||||
placeholder: "12345",
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField)
|
||||||
text: $postalCode,
|
|
||||||
accessibilityId: AccessibilityIdentifiers.Residence.postalCodeField
|
|
||||||
)
|
|
||||||
.focused($focusedField, equals: .postalCode)
|
|
||||||
|
|
||||||
OrganicFormTextField(
|
TextField(L10n.Residences.country, text: $country)
|
||||||
label: L10n.Residences.country,
|
.focused($focusedField, equals: .country)
|
||||||
placeholder: "USA",
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField)
|
||||||
text: $country,
|
} header: {
|
||||||
accessibilityId: AccessibilityIdentifiers.Residence.countryField
|
Text(L10n.Residences.address)
|
||||||
)
|
}
|
||||||
.focused($focusedField, equals: .country)
|
.sectionBackground()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Property Features Section
|
// Property Features
|
||||||
OrganicFormSection(title: L10n.Residences.propertyFeatures, icon: "square.grid.2x2.fill") {
|
Section {
|
||||||
VStack(spacing: 16) {
|
TextField(L10n.Residences.bedrooms, text: $bedrooms)
|
||||||
HStack(spacing: 12) {
|
.keyboardType(.numberPad)
|
||||||
OrganicFormTextField(
|
.focused($focusedField, equals: .bedrooms)
|
||||||
label: L10n.Residences.bedrooms,
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField)
|
||||||
placeholder: "0",
|
|
||||||
text: $bedrooms,
|
|
||||||
keyboardType: .numberPad,
|
|
||||||
accessibilityId: AccessibilityIdentifiers.Residence.bedroomsField
|
|
||||||
)
|
|
||||||
.focused($focusedField, equals: .bedrooms)
|
|
||||||
|
|
||||||
OrganicFormTextField(
|
TextField(L10n.Residences.bathrooms, text: $bathrooms)
|
||||||
label: L10n.Residences.bathrooms,
|
.keyboardType(.decimalPad)
|
||||||
placeholder: "0.0",
|
.focused($focusedField, equals: .bathrooms)
|
||||||
text: $bathrooms,
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField)
|
||||||
keyboardType: .decimalPad,
|
|
||||||
accessibilityId: AccessibilityIdentifiers.Residence.bathroomsField
|
|
||||||
)
|
|
||||||
.focused($focusedField, equals: .bathrooms)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
TextField(L10n.Residences.squareFootage, text: $squareFootage)
|
||||||
OrganicFormTextField(
|
.keyboardType(.numberPad)
|
||||||
label: L10n.Residences.squareFootage,
|
.focused($focusedField, equals: .squareFootage)
|
||||||
placeholder: "sq ft",
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField)
|
||||||
text: $squareFootage,
|
|
||||||
keyboardType: .numberPad,
|
|
||||||
accessibilityId: AccessibilityIdentifiers.Residence.squareFootageField
|
|
||||||
)
|
|
||||||
.focused($focusedField, equals: .squareFootage)
|
|
||||||
|
|
||||||
OrganicFormTextField(
|
TextField(L10n.Residences.lotSize, text: $lotSize)
|
||||||
label: L10n.Residences.lotSize,
|
.keyboardType(.decimalPad)
|
||||||
placeholder: "acres",
|
.focused($focusedField, equals: .lotSize)
|
||||||
text: $lotSize,
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField)
|
||||||
keyboardType: .decimalPad,
|
|
||||||
accessibilityId: AccessibilityIdentifiers.Residence.lotSizeField
|
|
||||||
)
|
|
||||||
.focused($focusedField, equals: .lotSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
OrganicFormTextField(
|
TextField(L10n.Residences.yearBuilt, text: $yearBuilt)
|
||||||
label: L10n.Residences.yearBuilt,
|
.keyboardType(.numberPad)
|
||||||
placeholder: "2020",
|
.focused($focusedField, equals: .yearBuilt)
|
||||||
text: $yearBuilt,
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField)
|
||||||
keyboardType: .numberPad,
|
} header: {
|
||||||
accessibilityId: AccessibilityIdentifiers.Residence.yearBuiltField
|
Text(L10n.Residences.propertyFeatures)
|
||||||
)
|
}
|
||||||
.focused($focusedField, equals: .yearBuilt)
|
.sectionBackground()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional Details Section
|
// Additional Details
|
||||||
OrganicFormSection(title: L10n.Residences.additionalDetails, icon: "text.alignleft") {
|
Section {
|
||||||
VStack(spacing: 16) {
|
TextField(L10n.Residences.description, text: $description, axis: .vertical)
|
||||||
OrganicFormTextArea(
|
.lineLimit(3...6)
|
||||||
label: L10n.Residences.description,
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField)
|
||||||
placeholder: "Add notes about your property...",
|
|
||||||
text: $description
|
|
||||||
)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField)
|
|
||||||
|
|
||||||
OrganicFormToggle(
|
Toggle(L10n.Residences.primaryResidence, isOn: $isPrimary)
|
||||||
label: L10n.Residences.primaryResidence,
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
|
||||||
isOn: $isPrimary,
|
} header: {
|
||||||
icon: "star.fill"
|
Text(L10n.Residences.additionalDetails)
|
||||||
)
|
}
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
|
.sectionBackground()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users Section (edit mode only, owner only)
|
// Users Section (edit mode only, owner only)
|
||||||
if isEditMode && isCurrentUserOwner {
|
if isEditMode && isCurrentUserOwner {
|
||||||
OrganicFormSection(title: "Shared Users (\(users.count))", icon: "person.2.fill") {
|
Section {
|
||||||
VStack(spacing: 12) {
|
if isLoadingUsers {
|
||||||
if isLoadingUsers {
|
HStack {
|
||||||
HStack {
|
Spacer()
|
||||||
Spacer()
|
ProgressView()
|
||||||
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()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(16)
|
} else if users.isEmpty {
|
||||||
.background(Color.appError.opacity(0.1))
|
Text("No shared users")
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.padding(.horizontal, 16)
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
Spacer()
|
Text("Shared Users (\(users.count))")
|
||||||
.frame(height: 40)
|
} 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)
|
.navigationTitle(isEditMode ? L10n.Residences.editTitle : L10n.Residences.addTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.keyboardDismissToolbar()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button(action: { isPresented = false }) {
|
Button(L10n.Common.cancel) {
|
||||||
Image(systemName: "xmark")
|
isPresented = false
|
||||||
.font(.system(size: 14, weight: .semibold))
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.padding(8)
|
|
||||||
.background(Color.appBackgroundSecondary.opacity(0.8))
|
|
||||||
.clipShape(Circle())
|
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.formCancelButton)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.formCancelButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button(action: submitForm) {
|
Button(action: submitForm) {
|
||||||
HStack(spacing: 6) {
|
if viewModel.isLoading {
|
||||||
if viewModel.isLoading {
|
ProgressView()
|
||||||
ProgressView()
|
} else {
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: Color.appTextOnPrimary))
|
|
||||||
.scaleEffect(0.8)
|
|
||||||
}
|
|
||||||
Text(L10n.Common.save)
|
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)
|
.disabled(!canSave || viewModel.isLoading)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.saveButton)
|
.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") {
|
#Preview("Add Mode") {
|
||||||
ResidenceFormView(existingResidence: nil, isPresented: .constant(true))
|
ResidenceFormView(existingResidence: nil, isPresented: .constant(true))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ struct DynamicTaskCard: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
PriorityBadge(priority: task.priorityName ?? "")
|
if let priorityName = task.priorityName, !priorityName.isEmpty {
|
||||||
|
PriorityBadge(priority: priorityName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !task.description_.isEmpty {
|
if !task.description_.isEmpty {
|
||||||
@@ -43,9 +45,11 @@ struct DynamicTaskCard: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Label(task.frequencyDisplayName ?? "", systemImage: "repeat")
|
if let frequency = task.frequencyDisplayName, !frequency.isEmpty {
|
||||||
.font(.caption)
|
Label(frequency, systemImage: "repeat")
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.font(.caption)
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ struct TaskCard: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
PriorityBadge(priority: task.priorityName ?? "")
|
if let priorityName = task.priorityName, !priorityName.isEmpty {
|
||||||
|
PriorityBadge(priority: priorityName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
@@ -44,10 +46,12 @@ struct TaskCard: View {
|
|||||||
|
|
||||||
// Metadata Pills
|
// Metadata Pills
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
TaskMetadataPill(
|
if let frequency = task.frequencyDisplayName, !frequency.isEmpty {
|
||||||
icon: "repeat",
|
TaskMetadataPill(
|
||||||
text: task.frequencyDisplayName ?? ""
|
icon: "repeat",
|
||||||
)
|
text: frequency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
|||||||
@@ -209,10 +209,6 @@ struct TaskFormView: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text(L10n.Tasks.category)
|
Text(L10n.Tasks.category)
|
||||||
} footer: {
|
|
||||||
Text(L10n.Tasks.required)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
}
|
}
|
||||||
.sectionBackground()
|
.sectionBackground()
|
||||||
|
|
||||||
@@ -246,10 +242,6 @@ struct TaskFormView: View {
|
|||||||
Text("Enter the number of days between each occurrence")
|
Text("Enter the number of days between each occurrence")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
} else {
|
|
||||||
Text(L10n.Tasks.required)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sectionBackground()
|
.sectionBackground()
|
||||||
@@ -265,10 +257,6 @@ struct TaskFormView: View {
|
|||||||
Toggle(L10n.Tasks.inProgressLabel, isOn: $inProgress)
|
Toggle(L10n.Tasks.inProgressLabel, isOn: $inProgress)
|
||||||
} header: {
|
} header: {
|
||||||
Text(L10n.Tasks.priorityAndStatus)
|
Text(L10n.Tasks.priorityAndStatus)
|
||||||
} footer: {
|
|
||||||
Text(L10n.Tasks.required)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
}
|
}
|
||||||
.sectionBackground()
|
.sectionBackground()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user