276 lines
9.0 KiB
Swift
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)
|
||
|
}
|
||
|
}
|