Add centralized DateUtils and enhance contractor detail views
- Add DateUtils.kt for shared Kotlin date formatting with formatDate, formatDateMedium, formatDateTime, formatRelativeDate, and isOverdue - Add DateUtils.swift for iOS with matching date formatting functions - Enhance ContractorDetailScreen (Android) with quick action buttons (call, email, website, directions), clickable contact rows, residence association, statistics section, and metadata - Enhance ContractorDetailView (iOS) with same features, refactored into smaller @ViewBuilder functions to fix Swift compiler type-check timeout - Fix empty string handling in iOS - check !isEmpty in addition to != nil for optional fields like phone, email, website, address - Update various task and document views to use centralized DateUtils 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -262,10 +262,17 @@ struct MediumWidgetView: View {
|
||||
.font(.system(size: 42, weight: .bold))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text(entry.taskCount == 1 ? "upcoming\n task" : "upcoming\ntasks")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
VStack(alignment: .leading) {
|
||||
Text("upcoming:")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(entry.taskCount == 1 ? "task" : "tasks")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.tt.mycrib.MyCribDev</string>
|
||||
<string>group.com.tt.casera.CaseraDev</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "mycrib-icon@2x.png",
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
BIN
iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon.png
Normal file
BIN
iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 464 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 638 KiB |
@@ -3,7 +3,9 @@ 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
|
||||
|
||||
@@ -13,149 +15,10 @@ struct ContractorDetailView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appBackgroundPrimary.ignoresSafeArea()
|
||||
|
||||
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 {
|
||||
ScrollView {
|
||||
VStack(spacing: AppSpacing.lg) {
|
||||
// Header Card
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rating
|
||||
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("\(contractor.taskCount) completed tasks")
|
||||
.font(.callout)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
|
||||
// Contact Information
|
||||
DetailSection(title: "Contact Information") {
|
||||
if let phone = contractor.phone {
|
||||
DetailRow(icon: "phone", label: "Phone", value: phone, iconColor: Color.appPrimary)
|
||||
}
|
||||
|
||||
if let email = contractor.email {
|
||||
DetailRow(icon: "envelope", label: "Email", value: email, iconColor: Color.appPrimary)
|
||||
}
|
||||
|
||||
if let website = contractor.website {
|
||||
DetailRow(icon: "globe", label: "Website", value: website, iconColor: Color.appAccent)
|
||||
}
|
||||
}
|
||||
|
||||
// Address
|
||||
if contractor.streetAddress != nil || contractor.city != nil {
|
||||
DetailSection(title: "Address") {
|
||||
let addressComponents = [
|
||||
contractor.streetAddress,
|
||||
[contractor.city, contractor.stateProvince].compactMap { $0 }.joined(separator: ", "),
|
||||
contractor.postalCode
|
||||
].compactMap { $0 }.filter { !$0.isEmpty }
|
||||
|
||||
if !addressComponents.isEmpty {
|
||||
DetailRow(
|
||||
icon: "mappin.circle",
|
||||
label: "Location",
|
||||
value: addressComponents.joined(separator: "\n"),
|
||||
iconColor: Color.appError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notes
|
||||
if let notes = contractor.notes, !notes.isEmpty {
|
||||
DetailSection(title: "Notes") {
|
||||
Text(notes)
|
||||
.font(.body)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
// Task History
|
||||
DetailSection(title: "Task History") {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.foregroundColor(Color.appAccent)
|
||||
Spacer()
|
||||
Text("\(contractor.taskCount) completed tasks")
|
||||
.font(.body)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
contentStateView
|
||||
}
|
||||
.onAppear {
|
||||
residenceViewModel.loadMyResidences()
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -224,6 +87,494 @@ struct ContractorDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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("\(contractor.taskCount) completed tasks")
|
||||
.font(.callout)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Quick Actions
|
||||
|
||||
@ViewBuilder
|
||||
private func quickActionsView(contractor: Contractor) -> some View {
|
||||
let hasPhone = contractor.phone != nil && !contractor.phone!.isEmpty
|
||||
let hasEmail = contractor.email != nil && !contractor.email!.isEmpty
|
||||
let hasWebsite = contractor.website != nil && !contractor.website!.isEmpty
|
||||
let hasAddress = (contractor.streetAddress != nil && !contractor.streetAddress!.isEmpty) ||
|
||||
(contractor.city != nil && !contractor.city!.isEmpty)
|
||||
|
||||
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: "Call",
|
||||
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: "Email",
|
||||
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: "Website",
|
||||
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 != nil && !contractor.streetAddress!.isEmpty) ||
|
||||
(contractor.city != nil && !contractor.city!.isEmpty)
|
||||
if hasAddress {
|
||||
QuickActionButton(
|
||||
icon: "map.fill",
|
||||
label: "Directions",
|
||||
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 != nil && !contractor.phone!.isEmpty
|
||||
let hasEmail = contractor.email != nil && !contractor.email!.isEmpty
|
||||
let hasWebsite = contractor.website != nil && !contractor.website!.isEmpty
|
||||
|
||||
if hasPhone || hasEmail || hasWebsite {
|
||||
DetailSection(title: "Contact Information") {
|
||||
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: "Phone",
|
||||
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: "Email",
|
||||
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: "Website",
|
||||
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 != nil && !contractor.streetAddress!.isEmpty
|
||||
let hasCity = contractor.city != nil && !contractor.city!.isEmpty
|
||||
|
||||
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: "Address") {
|
||||
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("Location")
|
||||
.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: "Associated Property") {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
Image(systemName: "house.fill")
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.frame(width: 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
Text("Property")
|
||||
.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: "Notes") {
|
||||
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: "Statistics") {
|
||||
HStack(spacing: AppSpacing.lg) {
|
||||
StatCard(
|
||||
icon: "checkmark.circle.fill",
|
||||
value: "\(contractor.taskCount)",
|
||||
label: "Tasks Completed",
|
||||
color: Color.appPrimary
|
||||
)
|
||||
|
||||
if let rating = contractor.rating, rating.doubleValue > 0 {
|
||||
StatCard(
|
||||
icon: "star.fill",
|
||||
value: String(format: "%.1f", rating.doubleValue),
|
||||
label: "Average Rating",
|
||||
color: Color.appAccent
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metadata Section
|
||||
|
||||
@ViewBuilder
|
||||
private func metadataSection(contractor: Contractor) -> some View {
|
||||
DetailSection(title: "Info") {
|
||||
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("Added By")
|
||||
.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("Member Since")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
|
||||
Text(DateUtils.formatDateMedium(createdAt))
|
||||
.font(.body)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Detail Section
|
||||
@@ -276,3 +627,103 @@ struct DetailRow: View {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ struct WarrantyCard: View {
|
||||
Text("Expires")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
Text(document.endDate ?? "N/A")
|
||||
Text(DateUtils.formatDateMedium(document.endDate) ?? "N/A")
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
@@ -188,13 +188,13 @@ struct DocumentDetailView: View {
|
||||
sectionHeader("Important Dates")
|
||||
|
||||
if let purchaseDate = document.purchaseDate {
|
||||
detailRow(label: "Purchase Date", value: purchaseDate)
|
||||
detailRow(label: "Purchase Date", value: DateUtils.formatDateMedium(purchaseDate))
|
||||
}
|
||||
if let startDate = document.startDate {
|
||||
detailRow(label: "Start Date", value: startDate)
|
||||
detailRow(label: "Start Date", value: DateUtils.formatDateMedium(startDate))
|
||||
}
|
||||
if let endDate = document.endDate {
|
||||
detailRow(label: "End Date", value: endDate)
|
||||
detailRow(label: "End Date", value: DateUtils.formatDateMedium(endDate))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -331,10 +331,10 @@ struct DocumentDetailView: View {
|
||||
detailRow(label: "Uploaded By", value: uploadedBy)
|
||||
}
|
||||
if let createdAt = document.createdAt {
|
||||
detailRow(label: "Created", value: createdAt)
|
||||
detailRow(label: "Created", value: DateUtils.formatDateTime(createdAt))
|
||||
}
|
||||
if let updatedAt = document.updatedAt {
|
||||
detailRow(label: "Updated", value: updatedAt)
|
||||
detailRow(label: "Updated", value: DateUtils.formatDateTime(updatedAt))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
179
iosApp/iosApp/Helpers/DateUtils.swift
Normal file
179
iosApp/iosApp/Helpers/DateUtils.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
|
||||
/// Utility for formatting dates in a human-readable format
|
||||
/// Mirrors the shared Kotlin DateUtils for consistent date display
|
||||
enum DateUtils {
|
||||
|
||||
private static let isoDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let isoDateTimeFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let isoDateTimeSimpleFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let mediumDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, yyyy"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let dateTimeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, yyyy 'at' h:mm a"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// Format a date string (YYYY-MM-DD) to a human-readable format
|
||||
/// Returns "Today", "Tomorrow", "Yesterday", or "Dec 15, 2024" format
|
||||
static func formatDate(_ dateString: String?) -> String {
|
||||
guard let dateString = dateString, !dateString.isEmpty else { return "" }
|
||||
|
||||
// Extract date part if it includes time
|
||||
let datePart = dateString.components(separatedBy: "T").first ?? dateString
|
||||
|
||||
guard let date = isoDateFormatter.date(from: datePart) else {
|
||||
return dateString
|
||||
}
|
||||
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let targetDate = Calendar.current.startOfDay(for: date)
|
||||
|
||||
let daysDiff = Calendar.current.dateComponents([.day], from: today, to: targetDate).day ?? 0
|
||||
|
||||
switch daysDiff {
|
||||
case 0:
|
||||
return "Today"
|
||||
case 1:
|
||||
return "Tomorrow"
|
||||
case -1:
|
||||
return "Yesterday"
|
||||
default:
|
||||
return mediumDateFormatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a date string to medium format (e.g., "Dec 15, 2024")
|
||||
static func formatDateMedium(_ dateString: String?) -> String {
|
||||
guard let dateString = dateString, !dateString.isEmpty else { return "" }
|
||||
|
||||
// Extract date part if it includes time
|
||||
let datePart = dateString.components(separatedBy: "T").first ?? dateString
|
||||
|
||||
guard let date = isoDateFormatter.date(from: datePart) else {
|
||||
return dateString
|
||||
}
|
||||
|
||||
return mediumDateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
/// Format an ISO datetime string to a human-readable date format
|
||||
/// Handles formats like "2024-01-01T00:00:00Z" or "2024-01-01T00:00:00.000Z"
|
||||
static func formatDateTime(_ dateTimeString: String?) -> String {
|
||||
guard let dateTimeString = dateTimeString, !dateTimeString.isEmpty else { return "" }
|
||||
|
||||
// Try parsing with fractional seconds first
|
||||
var date = isoDateTimeFormatter.date(from: dateTimeString)
|
||||
|
||||
// Fallback to simple ISO format
|
||||
if date == nil {
|
||||
date = isoDateTimeSimpleFormatter.date(from: dateTimeString)
|
||||
}
|
||||
|
||||
// Fallback to just date parsing
|
||||
guard let parsedDate = date else {
|
||||
return formatDate(dateTimeString)
|
||||
}
|
||||
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let targetDate = Calendar.current.startOfDay(for: parsedDate)
|
||||
|
||||
let daysDiff = Calendar.current.dateComponents([.day], from: today, to: targetDate).day ?? 0
|
||||
|
||||
switch daysDiff {
|
||||
case 0:
|
||||
return "Today"
|
||||
case 1:
|
||||
return "Tomorrow"
|
||||
case -1:
|
||||
return "Yesterday"
|
||||
default:
|
||||
return mediumDateFormatter.string(from: parsedDate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a datetime string with time (e.g., "Dec 15, 2024 at 3:30 PM")
|
||||
static func formatDateTimeWithTime(_ dateTimeString: String?) -> String {
|
||||
guard let dateTimeString = dateTimeString, !dateTimeString.isEmpty else { return "" }
|
||||
|
||||
// Try parsing with fractional seconds first
|
||||
var date = isoDateTimeFormatter.date(from: dateTimeString)
|
||||
|
||||
// Fallback to simple ISO format
|
||||
if date == nil {
|
||||
date = isoDateTimeSimpleFormatter.date(from: dateTimeString)
|
||||
}
|
||||
|
||||
guard let parsedDate = date else {
|
||||
return formatDate(dateTimeString)
|
||||
}
|
||||
|
||||
return dateTimeFormatter.string(from: parsedDate)
|
||||
}
|
||||
|
||||
/// Format a date for relative display (e.g., "2 days ago", "in 3 days")
|
||||
static func formatRelativeDate(_ dateString: String?) -> String {
|
||||
guard let dateString = dateString, !dateString.isEmpty else { return "" }
|
||||
|
||||
// Extract date part if it includes time
|
||||
let datePart = dateString.components(separatedBy: "T").first ?? dateString
|
||||
|
||||
guard let date = isoDateFormatter.date(from: datePart) else {
|
||||
return dateString
|
||||
}
|
||||
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let targetDate = Calendar.current.startOfDay(for: date)
|
||||
|
||||
let daysDiff = Calendar.current.dateComponents([.day], from: today, to: targetDate).day ?? 0
|
||||
|
||||
switch daysDiff {
|
||||
case 0:
|
||||
return "Today"
|
||||
case 1:
|
||||
return "Tomorrow"
|
||||
case -1:
|
||||
return "Yesterday"
|
||||
case 2...7:
|
||||
return "in \(daysDiff) days"
|
||||
case -7 ... -2:
|
||||
return "\(-daysDiff) days ago"
|
||||
default:
|
||||
return mediumDateFormatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a date string represents a date in the past
|
||||
static func isOverdue(_ dateString: String?) -> Bool {
|
||||
guard let dateString = dateString, !dateString.isEmpty else { return false }
|
||||
|
||||
// Extract date part if it includes time
|
||||
let datePart = dateString.components(separatedBy: "T").first ?? dateString
|
||||
|
||||
guard let date = isoDateFormatter.date(from: datePart) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
return date < today
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ struct ProfileTabView: View {
|
||||
|
||||
if subscriptionCache.currentTier == "pro",
|
||||
let expiresAt = subscription.expiresAt {
|
||||
Text("Active until \(formatDate(expiresAt))")
|
||||
Text("Active until \(DateUtils.formatDateMedium(expiresAt))")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
} else {
|
||||
@@ -193,14 +193,4 @@ struct ProfileTabView: View {
|
||||
Text("Your purchases have been restored successfully.")
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ dateString: String) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
if let date = formatter.date(from: dateString) {
|
||||
let displayFormatter = DateFormatter()
|
||||
displayFormatter.dateStyle = .medium
|
||||
return displayFormatter.string(from: date)
|
||||
}
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ struct CompletionCardView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(formatDate(completion.completionDate))
|
||||
Text(DateUtils.formatDateMedium(completion.completionDate))
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
@@ -99,21 +99,4 @@ struct CompletionCardView: View {
|
||||
PhotoViewerSheet(images: completion.images)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ dateString: String) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
if let date = formatter.date(from: dateString) {
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
// Try without time
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
if let date = formatter.date(from: dateString) {
|
||||
formatter.dateStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ struct DynamicTaskCard: View {
|
||||
Spacer()
|
||||
|
||||
if let due_date = task.dueDate {
|
||||
Label(formatDate(due_date), systemImage: "calendar")
|
||||
Label(DateUtils.formatDate(due_date), systemImage: "calendar")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
@@ -127,16 +127,6 @@ struct DynamicTaskCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ dateString: String) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
if let date = formatter.date(from: dateString) {
|
||||
formatter.dateStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
return dateString
|
||||
}
|
||||
|
||||
// MARK: - Menu Content
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -63,7 +63,7 @@ struct TaskCard: View {
|
||||
Image(systemName: "calendar")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
||||
Text(formatDate(dueDate))
|
||||
Text(DateUtils.formatDate(dueDate))
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
@@ -240,16 +240,6 @@ struct TaskCard: View {
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y)
|
||||
}
|
||||
|
||||
private func formatDate(_ dateString: String) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
if let date = formatter.date(from: dateString) {
|
||||
formatter.dateStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -138,7 +138,7 @@ struct CompletionHistoryCard: View {
|
||||
// Header with date and completed by
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(formatDate(completion.completionDate))
|
||||
Text(DateUtils.formatDateTimeWithTime(completion.completionDate))
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
@@ -254,29 +254,6 @@ struct CompletionHistoryCard: View {
|
||||
PhotoViewerSheet(images: completion.images)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ dateString: String) -> String {
|
||||
let formatters = [
|
||||
"yyyy-MM-dd'T'HH:mm:ssZ",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
"yyyy-MM-dd"
|
||||
]
|
||||
|
||||
let inputFormatter = DateFormatter()
|
||||
let outputFormatter = DateFormatter()
|
||||
outputFormatter.dateStyle = .long
|
||||
outputFormatter.timeStyle = .short
|
||||
|
||||
for format in formatters {
|
||||
inputFormatter.dateFormat = format
|
||||
if let date = inputFormatter.date(from: dateString) {
|
||||
return outputFormatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.tt.mycrib.MyCribDev</string>
|
||||
<string>group.com.tt.casera.CaseraDev</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Reference in New Issue
Block a user