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>
249 lines
8.3 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|