import Foundation import Combine import BinaryCodable import SwiftUI final class TemperatureStorage: ObservableObject { static var documentDirectory: URL { try! FileManager.default.url( for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) } @AppStorage("newestDate") private var newestMeasurementTime: Int = 0 private var newestMeasurementDate: Date { get { Date(seconds: newestMeasurementTime) } set { newestMeasurementTime = newValue.seconds } } @Published var recentMeasurements: [TemperatureMeasurement] @Published var dailyMeasurementCounts: [MeasurementDailyCount] = [] private var unsavedMeasurements: [TemperatureMeasurement] = [] private let fileNameFormatter: DateFormatter private let storageFolder: URL private let overviewFileUrl: URL private let fm: FileManager private let lastValueInterval: TimeInterval init(lastMeasurements: [TemperatureMeasurement] = [], lastValueInterval: TimeInterval = 3600) { self.recentMeasurements = lastMeasurements let documentDirectory = TemperatureStorage.documentDirectory self.storageFolder = documentDirectory.appendingPathComponent("measurements") self.overviewFileUrl = documentDirectory.appendingPathComponent("overview.bin") self.fm = .default self.fileNameFormatter = DateFormatter() self.fileNameFormatter.dateFormat = "yyyyMMdd.bin" self.lastValueInterval = lastValueInterval if lastMeasurements.isEmpty { loadLastMeasurements() } else { setDailyCounts(from: lastMeasurements) } ensureExistenceOfFolder() } private func ensureExistenceOfFolder() { guard !fm.fileExists(atPath: storageFolder.path) else { return } do { try fm.createDirectory(at: storageFolder, withIntermediateDirectories: true) } catch { print("Failed to create folder: \(error)") } } private func fileName(for date: Date) -> String { fileNameFormatter.string(from: date) } private func fileName(for index: Int) -> String { String(format: "%08d.bin", index) } private func fileUrl(for fileName: String) -> URL { storageFolder.appendingPathComponent(fileName) } private func loadLastMeasurements() { let startDate = Date().addingTimeInterval(-lastValueInterval) let todayIndex = Date().dateIndex let todayValues = loadMeasurements(for: todayIndex) .filter { $0.date >= startDate } let dateIndexOfStart = startDate.dateIndex guard todayIndex != dateIndexOfStart else { recentMeasurements = todayValues return } let yesterdayValues = loadMeasurements(for: dateIndexOfStart) .filter { $0.date >= startDate } recentMeasurements = yesterdayValues + todayValues } private func loadMeasurements(for date: Date) -> [TemperatureMeasurement] { loadMeasurements(from: fileName(for: date)) } func loadMeasurements(for dateIndex: Int) -> [TemperatureMeasurement] { loadMeasurements(from: fileName(for: dateIndex)) } private func loadMeasurements(from fileName: String) -> [TemperatureMeasurement] { let fileUrl = fileUrl(for: fileName) guard fm.fileExists(atPath: fileUrl.path) else { print("No measurements for \(fileName)") return [] } do { let content = try Data(contentsOf: fileUrl) let points: [TemperatureMeasurement] = try BinaryDecoder.decode(from: content) print("Loaded \(points.count) points for \(fileName)") return points } catch { print("Failed to read file \(fileName): \(error)") return [] } } func save() { for (dateIndex, values) in unsavedMeasurements.splitByDate() { let count = saveNew(values, for: dateIndex) print("Day \(dateIndex): \(count) of \(values.count) saved") } unsavedMeasurements = [] saveDailyCounts() } /** - Returns: The number of new points */ private func saveNew(_ measurements: [TemperatureMeasurement], for dateIndex: Int) -> Int { let fileName = fileName(for: dateIndex) var existing = loadMeasurements(from: fileName) guard !existing.isEmpty else { save(measurements, for: fileName) setDailyCount(measurements.count, for: dateIndex) return measurements.count } var inserted = 0 for value in measurements { if existing.insert(value) { inserted += 1 } } save(existing, for: fileName) setDailyCount(existing.count, for: dateIndex) return inserted } private func save(_ measurements: [TemperatureMeasurement], for fileName: String) { let fileUrl = fileUrl(for: fileName) do { let data = try BinaryEncoder.encode(measurements.sorted()) try data.write(to: fileUrl) } catch { print("Failed to save \(fileName): \(error)") } } // MARK: Daily counts private func setDailyCount(_ count: Int, for dateIndex: Int) { guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex == dateIndex }) else { add(dailyCount: count, for: dateIndex) return } dailyMeasurementCounts[index].count = count } private func add(dailyCount count: Int, for dateIndex: Int) { let entry = MeasurementDailyCount(dateIndex: dateIndex, count: count) guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex < dateIndex }) else { dailyMeasurementCounts.append(entry) return } dailyMeasurementCounts.insert(entry, at: index) } private func incrementCount(for dateIndex: Int, by increment: Int = 1) { guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex == dateIndex }) else { add(dailyCount: increment, for: dateIndex) return } dailyMeasurementCounts[index].count += increment } private func loadDailyCounts() { do { let data = try Data(contentsOf: overviewFileUrl) dailyMeasurementCounts = try BinaryDecoder.decode(from: data) } catch { print("Failed to load overview: \(error)") } } private func saveDailyCounts() { do { let data = try BinaryEncoder.encode(dailyMeasurementCounts) try data.write(to: overviewFileUrl) } catch { print("Failed to write overview: \(error)") } } private func setDailyCounts(from measurements: [TemperatureMeasurement]) { self.dailyMeasurementCounts = measurements.reduce(into: [Int: Int]()) { counts, value in let index = value.date.dateIndex counts[index] = (counts[index] ?? 0) + 1 }.map { MeasurementDailyCount(dateIndex: $0.key, count: $0.value) } .sorted() } func recalculateDailyCounts() { do { let newValues: [Int: Int] = try fm.contentsOfDirectory(atPath: storageFolder.path) .reduce(into: [:]) { counts, fileName in guard let dateIndex = Int(fileName) else { return } counts[dateIndex] = loadMeasurements(from: fileName).count } DispatchQueue.main.async { self.dailyMeasurementCounts = newValues .map { .init(dateIndex: $0.key, count: $0.value) } .sorted() } } catch { print("Failed to load daily counts: \(error)") } } } extension TemperatureStorage: TemperatureDataTransferDelegate { func didReceiveRecording(_ measurement: TemperatureMeasurement) { // Add to unsaved measurements if unsavedMeasurements.insert(measurement) { incrementCount(for: measurement.date.dateIndex) } // Add to last measurements recentMeasurements.insert(measurement) } func saveAfterTransfer() { save() } } private extension Array where Element == TemperatureMeasurement { @discardableResult mutating func insert(_ measurement: TemperatureMeasurement) -> Bool { guard !contains(measurement) else { return false } guard let index = self.firstIndex(where: { $0.date > measurement.date }) else { append(measurement) return true } insert(measurement, at: index) return true } func splitByDate() -> [Int : [TemperatureMeasurement]] { reduce(into: [:]) { result, value in let dateIndex = value.date.dateIndex result[dateIndex] = (result[dateIndex] ?? []) + [value] } } } extension TemperatureStorage { static var mock: TemperatureStorage { .init(lastMeasurements: TemperatureMeasurement.mockData) } }