- 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>
762 lines
26 KiB
Swift
762 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 showingShareSheet = 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: $showingShareSheet) {
|
|
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 {
|
|
Task { @MainActor in
|
|
// Small delay to allow state to settle before dismissing
|
|
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func shareContractor(_ contractor: Contractor) {
|
|
if let url = ContractorSharingManager.shared.createShareableFile(contractor: contractor) {
|
|
shareFileURL = url
|
|
showingShareSheet = true
|
|
}
|
|
}
|
|
|
|
// 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 }.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 }.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("house_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)
|
|
}
|
|
}
|