Files
honeyDueKMP/iosApp/iosApp/Helpers/DateUtils.swift
Trey t 9d6e7c4f2a Add per-user notification time preferences
Allow users to customize when they receive notification reminders:
- Add hour fields to NotificationPreference model
- Add timezone conversion utilities (localHourToUtc, utcHourToLocal)
- Add time picker UI for iOS (wheel picker in sheet)
- Add time picker UI for Android (hour chip selector dialog)
- Times stored in UTC, displayed in user's local timezone
- Add localized strings for time picker UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 00:25:38 -06:00

249 lines
8.3 KiB
Swift

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
}
// MARK: - Timezone Conversion Utilities
/// Convert a local hour (0-23) to UTC hour
/// - Parameter localHour: Hour in the device's local timezone (0-23)
/// - Returns: Hour in UTC (0-23)
static func localHourToUtc(_ localHour: Int) -> Int {
let now = Date()
let calendar = Calendar.current
// Create a date with the given local hour
var components = calendar.dateComponents(in: TimeZone.current, from: now)
components.hour = localHour
components.minute = 0
components.second = 0
guard let localDate = calendar.date(from: components) else {
return localHour
}
// Get the hour in UTC
var utcCalendar = Calendar.current
utcCalendar.timeZone = TimeZone(identifier: "UTC")!
let utcHour = utcCalendar.component(.hour, from: localDate)
return utcHour
}
/// Convert a UTC hour (0-23) to local hour
/// - Parameter utcHour: Hour in UTC (0-23)
/// - Returns: Hour in the device's local timezone (0-23)
static func utcHourToLocal(_ utcHour: Int) -> Int {
let now = Date()
// Create a calendar in UTC
var utcCalendar = Calendar.current
utcCalendar.timeZone = TimeZone(identifier: "UTC")!
// Create a date with the given UTC hour
var components = utcCalendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: now)
components.hour = utcHour
components.minute = 0
components.second = 0
guard let utcDate = utcCalendar.date(from: components) else {
return utcHour
}
// Get the hour in local timezone
let localHour = Calendar.current.component(.hour, from: utcDate)
return localHour
}
/// Format an hour (0-23) to a human-readable 12-hour format
/// - Parameter hour: Hour in 24-hour format (0-23)
/// - Returns: Formatted string like "8:00 AM" or "2:00 PM"
static func formatHour(_ hour: Int) -> String {
switch hour {
case 0:
return "12:00 AM"
case 1..<12:
return "\(hour):00 AM"
case 12:
return "12:00 PM"
default:
return "\(hour - 12):00 PM"
}
}
}