TempTrack-iOS/TempTrack/Storage/TemperatureStorage.swift
2023-06-08 09:52:20 +02:00

276 lines
9.0 KiB
Swift

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