Probably more than you wanted, but better too much than too little.
import Foundation
import SwiftUI
import UserNotifications
struct Periodical: Codable, Identifiable
{
var id: UUID = UUID()
let name: String // name of the bill or periodical
var dueDate: Date // due date
let price: Double? // amount due
let notes: String // any comments
var timestamp: Date? = Date.now
var itemOptions: ItemOptions?
var dateExpired: Bool // Is the current date at or past due date?
{ dueDate.removeTime() <= Date.now.removeTime() }
var daysTillDue: Int
{
let dateOnlyDueDate = dueDate.removeTime()
let dateOnlyToday = Date.now.removeTime()
let myCalendar = Calendar.current
return myCalendar.dateComponents([.day], from: dateOnlyToday, to: dateOnlyDueDate).day!
}
var buttonLabel: String
{ price == nil ? "Done" : "Paid" }
}
final class Periodicals: ObservableObject
{
// Error handling
struct AppError: Identifiable
{
let id = UUID().uuidString
let errorString: String
}
var appError: AppError? = nil
var errorHeading = ""
// End of error handling
@AppStorage("iconBadge") var iconBadge = false
@AppStorage("notifications") var notifications = false
static let itemKey = "Items"
static let fileExt = ".txt"
@Published private(set) var items = [Periodical]()
// Computed property which is the number
// of items that are past due.
var numberPastDue: Int
{ items.filter { $0.dateExpired }.count}
// Computed property which is the total amount that's past due.
var totalPastDue: Double
{
items.filter()
{ $0.dateExpired && $0.price != nil }
.reduce(0){ $0 + $1.price! }
}
// Computed property which is the total due within the
// specified number of days, including past due.
var dueInXDays: Double
{
items.filter()
{ $0.daysTillDue <= summaryDays && $0.price != nil }
.reduce(0){ $0 + $1.price! }
}
// Items are added and replaced using currentItem.
// If the id in currentItem matches an existing item,
// the original item is replaced, otherwise it is
// appended to the array. In any case, the array is
// then sorted by due date, earliest first, and saved.
var currentItem: Periodical?
{
didSet
{
let index = itemIndex(id: currentItem!.id)
if index == -1 // This is a new item being added.
{
cancelUndo()
items.append(currentItem!)
}
else // This is an existing item being edited.
{
previousItem = items[index] // save for undo
items[index] = currentItem!
}
save() // Save the list.
}
}
// If there is an item in 'previousItem', then it is
// used for undo by exchanging it with 'currentItem'.
var previousItem: Periodical?
func undo()
{ currentItem = previousItem }
func cancelUndo()
{ previousItem = nil }
init()
{ load() }
// Returns 'true' if the provided item name matches the
// name of another item, otherwise returns 'false'.
func duplicateItem(name: String, id: UUID) -> Bool
{ items.firstIndex(where: { $0.name == name && $0.id != id }) != nil }
// Encode and save the data.
func save()
{
let encoder = JSONEncoder()
sortByDueDate()
if let encoded = try? encoder.encode(items)
{ saveFile(data: encoded) }
}
private func sortByDueDate()
{ items.sort() { $0.dueDate < $1.dueDate };}
// Load and decode the data file.
func load()
{
if let json = loadFile()
{
let decoder = JSONDecoder()
do
{
let decoded = try decoder.decode([Periodical].self, from: json)
items = decoded
return
}
catch
{
print(error.localizedDescription)
print(error)
errorHeading = "Error decoding data"
appError = AppError(errorString: error.localizedDescription)
}
}
items = []
}
// ***************************************
// *** Import/Export support functions ***
// ***************************************
// Returns a String representation of the data. Used by Export.
func getDataAsString() -> String?
{
let data = loadFile() ?? Data()
return String(data: data, encoding: String.Encoding.utf8)
}
// Replaces list with contents of data. Used by Import.
func updateListFromData(_ json: Data, replace: Bool) throws
{
let decoder = JSONDecoder()
do
{
let decoded = try decoder.decode([Periodical].self, from: json)
if replace
{ items = decoded }
else
{ items = removeDuplicates(items + decoded) }
save()
}
}
// - A periodical is a duplicate if their id's are equal.
// - The duplicate with the earliest timestamp is removed.
// - A timestamp of nil is earlier than any other timestamp.
// - If two items both have a nil timestamp, the item with
// the earliest due date is removed.
func removeDuplicates(_ list: [Periodical]) -> [Periodical]
{
let uniques = list.reduce(into: Dictionary<String, Periodical>())
{ result, currentVal in
// Do we already have this periodical in our result?
if let existingVal = result[currentVal.id.uuidString]
{
// Yes we do, so compare the timestamps.
switch (currentVal.timestamp, existingVal.timestamp)
{
case (nil, nil):
// Both currentVal.timestamp and existingVal.timestamp are nil.
// Save the one with the latest due date.
if currentVal.dueDate > existingVal.dueDate
{ result[currentVal.id.uuidString] = currentVal }
case (nil, .some(_)):
// currentVal.timestamp == nil, so do nothing.
break
case (.some(_), nil):
// existingVal.timestamp == nil so replace with currentVal.
result[currentVal.id.uuidString] = currentVal
default:
// Both currentVal.timestamp and existingVal.timestamp have values,
// so compare them and keep the latest one.
if currentVal.timestamp! > existingVal.timestamp!
{ result[currentVal.id.uuidString] = currentVal }
}
}
else
{
// No we do not, so add it.
result[currentVal.id.uuidString] = currentVal
}
}
return Array(uniques.values)
}
// **********************************************
// *** End of Import/Export support functions ***
// **********************************************
// Remove a single periodical from the list, based on the ID.
func removeItem(id: UUID)
{
let index = items.firstIndex { $0.id == id }
previousItem = items[index!]
items.remove(at: index!)
save()
}
// 1) Set the icon badge number to the number of items with expired due dates.
// 2) Set up notifications to increase the icon badge number for future items.
func setNotifications()
{
var badgeValue = 0
// 1)
if iconBadge
{ badgeValue = numberPastDue }
UIApplication.shared.applicationIconBadgeNumber = badgeValue
// 2)
let center = UNUserNotificationCenter.current()
center.removeAllPendingNotificationRequests()
if iconBadge || notifications
{
// There is a limit of 64 pending notifications. If this limit
// is exceeded, the 64 soonest notifications are supposed to
// survive. This doesn't seem to be the case. What actually
// happens is that the last 64 notifications that were activated
// are the ones that survive. To get around this, I'm cutting the
// unexpired items array down to the first 30, which means there
// won't be any more than 60 notifications activated.
let unexpiredItems = items.filter { !$0.dateExpired }.prefix(30)
for item in unexpiredItems
{
if notifications
{ addNotification(for: item) }
if iconBadge
{
badgeValue += 1
setFutureIconBadgeValue(for: item, badgeValue: badgeValue)
}
}
}
print("List of Requests")
center.getPendingNotificationRequests(completionHandler:
{ notifications in
for notification in notifications
{ print(notification) }
})
}
// Activate a notification for the item's due date.
private func addNotification(for item: Periodical)
{
let center = UNUserNotificationCenter.current()
let content = UNMutableNotificationContent()
content.title = "'\(item.name)' is due today."
content.sound = UNNotificationSound.default
if #available(iOS 15, *)
{ content.interruptionLevel = .active }
var triggerDate = Calendar.current.dateComponents([.year, .month, .day], from: item.dueDate)
triggerDate.hour = 9
let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false)
let request = UNNotificationRequest(identifier: item.id.uuidString, content: content, trigger: trigger)
center.add(request)
}
// Activate a notification for future icon badge value.
private func setFutureIconBadgeValue(for item: Periodical, badgeValue: Int)
{
var iconBadgeID: String
{
let formatter = DateFormatter()
formatter.dateStyle = .short
let idModifier = formatter.string(from: item.dueDate)
return "iconBadge." + idModifier
}
let center = UNUserNotificationCenter.current()
let content = UNMutableNotificationContent()
content.badge = badgeValue as NSNumber
var triggerDate = Calendar.current.dateComponents([.year, .month, .day], from: item.dueDate)
triggerDate.hour = 0
triggerDate.minute = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false)
let request = UNNotificationRequest(identifier: iconBadgeID, content: content, trigger: trigger)
center.add(request)
}
// Returns the index of the item whose id matches the
// supplied UUID or -1 if the item is not found.
private func itemIndex(id: UUID) -> Int
{ items.firstIndex(where: { $0.id == id }) ?? -1;}
// Return the URL of the app's documents directory.
private func getDocumentsDirectory() -> URL
{
// Find all possible documents directories for this user.
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
// Just return the first one, which ought to be the only one.
return paths[0]
}
// Save the data to the Documents dirctory.
private func saveFile(data: Data)
{
let url = getDocumentsDirectory().appendingPathComponent(Self.itemKey + Self.fileExt)
do
{ try data.write(to: url, options: [.atomicWrite, .completeFileProtection]) }
catch
{
print(error.localizedDescription)
print(error)
errorHeading = "Error saving data"
appError = AppError(errorString: error.localizedDescription)
}
}
// Load the data from the Documents directory.
private func loadFile() -> Data?
{
let url = getDocumentsDirectory().appendingPathComponent(Self.itemKey + Self.fileExt)
if !FileManager.default.fileExists(atPath: url.relativePath)
{ return nil }
do
{
let data = try Data(contentsOf: url)
return data
}
catch
{
print(error.localizedDescription)
print(error)
errorHeading = "Error reading data"
appError = AppError(errorString: error.localizedDescription)
return nil
}
}
}