Files
honeyDueKMP/iosApp/iosApp/Contractor/ContractorDetailView.swift
Trey t 28339544e5 Update API URL to myhoneydue.com, fix missing translations, and UI polish
- Update DEV API URLs from treytartt.com to api.myhoneydue.com
- Add rounded corners to app icon in login, register, and onboarding screens
- Add 9 missing English translations in Localizable.xcstrings
- Fix property feature pills to use equal height for balanced layout
- Remove duplicate honeyDue user scheme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:00:52 -06:00

761 lines
26 KiB
Swift

import SwiftUI
import ComposeApp
struct ContractorDetailView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@StateObject private var viewModel = ContractorViewModel()
@StateObject private var residenceViewModel = ResidenceViewModel()
let contractorId: Int32
@State private var showingEditSheet = false
@State private var showingDeleteAlert = false
@State private var shareFileURL: URL?
@State private var showingUpgradePrompt = false
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
var body: some View {
ZStack {
WarmGradientBackground()
contentStateView
}
.onAppear {
residenceViewModel.loadMyResidences()
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if let contractor = viewModel.selectedContractor {
Menu {
Button(action: {
if subscriptionCache.canShareContractor() {
shareContractor(contractor)
} else {
showingUpgradePrompt = true
}
}) {
Label(L10n.Common.share, systemImage: "square.and.arrow.up")
}
Button(action: { viewModel.toggleFavorite(id: contractorId) { _ in
viewModel.loadContractorDetail(id: contractorId)
}}) {
Label(
contractor.isFavorite ? L10n.Contractors.removeFromFavorites : L10n.Contractors.addToFavorites,
systemImage: contractor.isFavorite ? "star.slash" : "star"
)
}
Button(action: { showingEditSheet = true }) {
Label(L10n.Common.edit, systemImage: "pencil")
}
Divider()
Button(role: .destructive, action: { showingDeleteAlert = true }) {
Label(L10n.Common.delete, systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
.foregroundColor(Color.appPrimary)
}
}
}
}
.sheet(isPresented: Binding(
get: { shareFileURL != nil },
set: { if !$0 { shareFileURL = nil } }
)) {
if let url = shareFileURL {
ShareSheet(activityItems: [url])
}
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "share_contractor", isPresented: $showingUpgradePrompt)
}
.sheet(isPresented: $showingEditSheet) {
ContractorFormSheet(
contractor: viewModel.selectedContractor,
onSave: {
viewModel.loadContractorDetail(id: contractorId)
}
)
.presentationDetents([.large])
}
.alert(L10n.Contractors.deleteConfirm, isPresented: $showingDeleteAlert) {
Button(L10n.Common.cancel, role: .cancel) {}
Button(L10n.Common.delete, role: .destructive) {
deleteContractor()
}
} message: {
Text(L10n.Contractors.deleteMessage)
}
.onAppear {
viewModel.loadContractorDetail(id: contractorId)
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.loadContractorDetail(id: contractorId) }
)
}
private func deleteContractor() {
viewModel.deleteContractor(id: contractorId) { success in
if success {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
dismiss()
}
}
}
}
private func shareContractor(_ contractor: Contractor) {
if let url = ContractorSharingManager.shared.createShareableFile(contractor: contractor) {
shareFileURL = url
}
}
// MARK: - Content State View
@ViewBuilder
private var contentStateView: some View {
if viewModel.isLoading {
ProgressView()
.scaleEffect(1.2)
} else if let error = viewModel.errorMessage {
ErrorView(message: error) {
viewModel.loadContractorDetail(id: contractorId)
}
} else if let contractor = viewModel.selectedContractor {
contractorScrollView(contractor: contractor)
}
}
// MARK: - Main Scroll View
@ViewBuilder
private func contractorScrollView(contractor: Contractor) -> some View {
ScrollView {
VStack(spacing: AppSpacing.lg) {
headerCard(contractor: contractor)
quickActionsView(contractor: contractor)
contactInfoSection(contractor: contractor)
addressSection(contractor: contractor)
residenceSection(residenceId: (contractor.residenceId as? Int32))
notesSection(notes: contractor.notes)
statisticsSection(contractor: contractor)
metadataSection(contractor: contractor)
}
.padding(AppSpacing.md)
}
}
// MARK: - Header Card
@ViewBuilder
private func headerCard(contractor: Contractor) -> some View {
VStack(spacing: AppSpacing.md) {
// Avatar
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 80, height: 80)
Image(systemName: "person.fill")
.font(.system(size: 40))
.foregroundColor(Color.appPrimary)
}
// Name
Text(contractor.name)
.font(.title3.weight(.semibold))
.foregroundColor(Color.appTextPrimary)
// Company
if let company = contractor.company {
Text(company)
.font(.title3.weight(.semibold))
.foregroundColor(Color.appTextSecondary)
}
// Specialties Badges
specialtiesBadges(contractor: contractor)
// Rating
ratingView(contractor: contractor)
}
.padding(AppSpacing.lg)
.frame(maxWidth: .infinity)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
}
@ViewBuilder
private func specialtiesBadges(contractor: Contractor) -> some View {
if !contractor.specialties.isEmpty {
FlowLayout(spacing: AppSpacing.xs) {
ForEach(contractor.specialties, id: \.id) { specialty in
HStack(spacing: AppSpacing.xxs) {
Image(systemName: "wrench.and.screwdriver")
.font(.caption)
Text(specialty.name)
.font(.body)
}
.padding(.horizontal, AppSpacing.sm)
.padding(.vertical, AppSpacing.xxs)
.background(Color.appPrimary.opacity(0.1))
.foregroundColor(Color.appPrimary)
.cornerRadius(AppRadius.full)
}
}
}
}
@ViewBuilder
private func ratingView(contractor: Contractor) -> some View {
if let rating = contractor.rating, rating.doubleValue > 0 {
HStack(spacing: AppSpacing.xxs) {
ForEach(0..<5) { index in
Image(systemName: index < Int(rating.doubleValue) ? "star.fill" : "star")
.foregroundColor(Color.appAccent)
.font(.caption)
}
Text(String(format: "%.1f", rating.doubleValue))
.font(.title3.weight(.semibold))
.foregroundColor(Color.appTextPrimary)
}
if contractor.taskCount > 0 {
Text(String(format: L10n.Contractors.completedTasks, contractor.taskCount))
.font(.callout)
.foregroundColor(Color.appTextSecondary)
}
}
}
// MARK: - Quick Actions
@ViewBuilder
private func quickActionsView(contractor: Contractor) -> some View {
let hasPhone = !(contractor.phone?.isEmpty ?? true)
let hasEmail = !(contractor.email?.isEmpty ?? true)
let hasWebsite = !(contractor.website?.isEmpty ?? true)
let hasAddress = !(contractor.streetAddress?.isEmpty ?? true) ||
!(contractor.city?.isEmpty ?? true)
if hasPhone || hasEmail || hasWebsite || hasAddress {
HStack(spacing: AppSpacing.sm) {
phoneQuickAction(phone: contractor.phone)
emailQuickAction(email: contractor.email)
websiteQuickAction(website: contractor.website)
directionsQuickAction(contractor: contractor)
}
}
}
@ViewBuilder
private func phoneQuickAction(phone: String?) -> some View {
if let phone = phone, !phone.isEmpty {
QuickActionButton(
icon: "phone.fill",
label: L10n.Contractors.callAction,
color: Color.appPrimary
) {
if let url = URL(string: "tel:\(phone.replacingOccurrences(of: " ", with: ""))") {
openURL(url)
}
}
}
}
@ViewBuilder
private func emailQuickAction(email: String?) -> some View {
if let email = email, !email.isEmpty {
QuickActionButton(
icon: "envelope.fill",
label: L10n.Contractors.emailAction,
color: Color.appSecondary
) {
if let url = URL(string: "mailto:\(email)") {
openURL(url)
}
}
}
}
@ViewBuilder
private func websiteQuickAction(website: String?) -> some View {
if let website = website, !website.isEmpty {
QuickActionButton(
icon: "safari.fill",
label: L10n.Contractors.websiteAction,
color: Color.appAccent
) {
var urlString = website
if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
urlString = "https://\(urlString)"
}
if let url = URL(string: urlString) {
openURL(url)
}
}
}
}
@ViewBuilder
private func directionsQuickAction(contractor: Contractor) -> some View {
let hasAddress = !(contractor.streetAddress?.isEmpty ?? true) ||
!(contractor.city?.isEmpty ?? true)
if hasAddress {
QuickActionButton(
icon: "map.fill",
label: L10n.Contractors.directionsAction,
color: Color.appError
) {
let address = [
contractor.streetAddress,
contractor.city,
contractor.stateProvince,
contractor.postalCode
].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ")
if let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "maps://?address=\(encoded)") {
openURL(url)
}
}
}
}
// MARK: - Contact Information Section
@ViewBuilder
private func contactInfoSection(contractor: Contractor) -> some View {
let hasPhone = !(contractor.phone?.isEmpty ?? true)
let hasEmail = !(contractor.email?.isEmpty ?? true)
let hasWebsite = !(contractor.website?.isEmpty ?? true)
if hasPhone || hasEmail || hasWebsite {
DetailSection(title: L10n.Contractors.contactInfoSection) {
phoneContactRow(phone: contractor.phone)
emailContactRow(email: contractor.email)
websiteContactRow(website: contractor.website)
}
}
}
@ViewBuilder
private func phoneContactRow(phone: String?) -> some View {
if let phone = phone, !phone.isEmpty {
ContactDetailRow(
icon: "phone.fill",
label: L10n.Contractors.phoneLabel,
value: phone,
iconColor: Color.appPrimary
) {
if let url = URL(string: "tel:\(phone.replacingOccurrences(of: " ", with: ""))") {
openURL(url)
}
}
}
}
@ViewBuilder
private func emailContactRow(email: String?) -> some View {
if let email = email, !email.isEmpty {
ContactDetailRow(
icon: "envelope.fill",
label: L10n.Contractors.emailLabel,
value: email,
iconColor: Color.appSecondary
) {
if let url = URL(string: "mailto:\(email)") {
openURL(url)
}
}
}
}
@ViewBuilder
private func websiteContactRow(website: String?) -> some View {
if let website = website, !website.isEmpty {
ContactDetailRow(
icon: "safari.fill",
label: L10n.Contractors.websiteLabel,
value: website,
iconColor: Color.appAccent
) {
var urlString = website
if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
urlString = "https://\(urlString)"
}
if let url = URL(string: urlString) {
openURL(url)
}
}
}
}
// MARK: - Address Section
@ViewBuilder
private func addressSection(contractor: Contractor) -> some View {
let hasStreet = !(contractor.streetAddress?.isEmpty ?? true)
let hasCity = !(contractor.city?.isEmpty ?? true)
if hasStreet || hasCity {
let addressComponents = [
contractor.streetAddress,
[contractor.city, contractor.stateProvince].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),
contractor.postalCode
].compactMap { $0 }.filter { !$0.isEmpty }
if !addressComponents.isEmpty {
DetailSection(title: L10n.Contractors.addressSection) {
addressButton(contractor: contractor, addressComponents: addressComponents)
}
}
}
}
@ViewBuilder
private func addressButton(contractor: Contractor, addressComponents: [String]) -> some View {
let fullAddress = addressComponents.joined(separator: "\n")
Button {
let address = [
contractor.streetAddress,
contractor.city,
contractor.stateProvince,
contractor.postalCode
].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", ")
if let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "maps://?address=\(encoded)") {
openURL(url)
}
} label: {
HStack(alignment: .top, spacing: AppSpacing.sm) {
Image(systemName: "mappin.circle.fill")
.foregroundColor(Color.appError)
.frame(width: 20)
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
Text(L10n.Contractors.locationLabel)
.font(.caption.weight(.medium))
.foregroundColor(Color.appTextSecondary)
Text(fullAddress)
.font(.body)
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.leading)
}
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
.padding(AppSpacing.md)
}
}
// MARK: - Residence Section
@ViewBuilder
private func residenceSection(residenceId: Int32?) -> some View {
if let residenceId = residenceId {
DetailSection(title: L10n.Contractors.associatedPropertySection) {
HStack(spacing: AppSpacing.sm) {
Image("outline")
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 18, height: 18)
.foregroundColor(Color.appPrimary)
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
Text(L10n.Contractors.propertyLabel)
.font(.caption.weight(.medium))
.foregroundColor(Color.appTextSecondary)
if let residence = residenceViewModel.myResidences?.residences.first(where: { $0.id == residenceId }) {
Text(residence.name)
.font(.body)
.foregroundColor(Color.appTextPrimary)
} else {
Text("Property #\(residenceId)")
.font(.body)
.foregroundColor(Color.appTextPrimary)
}
}
Spacer()
}
.padding(AppSpacing.md)
}
}
}
// MARK: - Notes Section
@ViewBuilder
private func notesSection(notes: String?) -> some View {
if let notes = notes, !notes.isEmpty {
DetailSection(title: L10n.Contractors.notesSection) {
HStack(alignment: .top, spacing: AppSpacing.sm) {
Image(systemName: "note.text")
.foregroundColor(Color.appAccent)
.frame(width: 20)
Text(notes)
.font(.body)
.foregroundColor(Color.appTextPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(AppSpacing.md)
}
}
}
// MARK: - Statistics Section
@ViewBuilder
private func statisticsSection(contractor: Contractor) -> some View {
DetailSection(title: L10n.Contractors.statisticsSection) {
HStack(spacing: AppSpacing.lg) {
StatCard(
icon: "checkmark.circle.fill",
value: "\(contractor.taskCount)",
label: L10n.Contractors.tasksCompletedLabel,
color: Color.appPrimary
)
if let rating = contractor.rating, rating.doubleValue > 0 {
StatCard(
icon: "star.fill",
value: String(format: "%.1f", rating.doubleValue),
label: L10n.Contractors.averageRatingLabel,
color: Color.appAccent
)
}
}
.padding(AppSpacing.md)
}
}
// MARK: - Metadata Section
@ViewBuilder
private func metadataSection(contractor: Contractor) -> some View {
DetailSection(title: L10n.Contractors.infoSection) {
VStack(spacing: 0) {
createdByRow(createdBy: contractor.createdBy)
memberSinceRow(createdAt: contractor.createdAt)
}
}
}
@ViewBuilder
private func createdByRow(createdBy: ContractorUser?) -> some View {
if let createdBy = createdBy {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "person.badge.plus")
.foregroundColor(Color.appTextSecondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
Text(L10n.Contractors.addedByLabel)
.font(.caption.weight(.medium))
.foregroundColor(Color.appTextSecondary)
Text(createdBy.username)
.font(.body)
.foregroundColor(Color.appTextPrimary)
}
Spacer()
}
.padding(AppSpacing.md)
Divider()
.padding(.horizontal, AppSpacing.md)
}
}
@ViewBuilder
private func memberSinceRow(createdAt: String) -> some View {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "calendar.badge.plus")
.foregroundColor(Color.appTextSecondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
Text(L10n.Contractors.memberSinceLabel)
.font(.caption.weight(.medium))
.foregroundColor(Color.appTextSecondary)
Text(DateUtils.formatDateMedium(createdAt))
.font(.body)
.foregroundColor(Color.appTextPrimary)
}
Spacer()
}
.padding(AppSpacing.md)
}
}
// MARK: - Detail Section
struct DetailSection<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: AppSpacing.sm) {
Text(title)
.font(.headline)
.foregroundColor(Color.appTextPrimary)
.padding(.horizontal, AppSpacing.md)
VStack(spacing: 0) {
content()
}
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
}
}
}
// MARK: - Detail Row
struct DetailRow: View {
let icon: String
let label: String
let value: String
var iconColor: Color = Color(.secondaryLabel)
var body: some View {
HStack(alignment: .top, spacing: AppSpacing.sm) {
Image(systemName: icon)
.foregroundColor(iconColor)
.frame(width: 20)
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
Text(label)
.font(.caption.weight(.medium))
.foregroundColor(Color.appTextSecondary)
Text(value)
.font(.body)
.foregroundColor(Color.appTextPrimary)
}
Spacer()
}
.padding(AppSpacing.md)
}
}
// MARK: - Quick Action Button
struct QuickActionButton: View {
let icon: String
let label: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: AppSpacing.xs) {
ZStack {
Circle()
.fill(color.opacity(0.1))
.frame(width: 50, height: 50)
Image(systemName: icon)
.font(.system(size: 20, weight: .semibold))
.foregroundColor(color)
}
Text(label)
.font(.caption.weight(.medium))
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, AppSpacing.sm)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md)
}
}
}
// MARK: - Contact Detail Row (Clickable)
struct ContactDetailRow: View {
let icon: String
let label: String
let value: String
var iconColor: Color = Color(.secondaryLabel)
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(alignment: .top, spacing: AppSpacing.sm) {
Image(systemName: icon)
.foregroundColor(iconColor)
.frame(width: 20)
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
Text(label)
.font(.caption.weight(.medium))
.foregroundColor(Color.appTextSecondary)
Text(value)
.font(.body)
.foregroundColor(Color.appPrimary)
}
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
.padding(AppSpacing.md)
}
}
}
// MARK: - Stat Card
struct StatCard: View {
let icon: String
let value: String
let label: String
let color: Color
var body: some View {
VStack(spacing: AppSpacing.xs) {
ZStack {
Circle()
.fill(color.opacity(0.1))
.frame(width: 44, height: 44)
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(color)
}
Text(value)
.font(.title2.weight(.bold))
.foregroundColor(Color.appTextPrimary)
Text(label)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
}