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:
Trey t
2025-12-01 14:08:45 -06:00
parent 94781f4c48
commit c07821711f
22 changed files with 1329 additions and 322 deletions

View File

@@ -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()
}

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "mycrib-icon@2x.png",
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 KiB

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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()

View 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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>