Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions FastPath/Models/FastingGoal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@
//

import Foundation
import GRDB
import StructuredQueries

/// Represents a fasting goal with a target duration
struct FastingGoal: Equatable, Codable {
@Table("fastingGoal")
class FastingGoal: Equatable, Codable, TableRecord, FetchableRecord, PersistableRecord {
/// Duration in seconds
var targetDuration: TimeInterval

/// Optional name for the goal (e.g., "16:8 Intermittent Fasting")
var name: String?
/// Name for the goal (e.g., "16:8 Intermittent Fasting"), serves as primary key.
var name: String

/// Optional description for the goal
var description: String?

init(targetDuration: TimeInterval, name: String? = nil, description: String? = nil) {
init(targetDuration: TimeInterval, name: String, description: String? = nil) {
self.targetDuration = targetDuration
self.name = name
self.description = description
Expand Down Expand Up @@ -47,4 +50,35 @@ struct FastingGoal: Equatable, Codable {
description: "An extended daily fast."
)
]

// Required for TableRecord conformance
// GRDB can use this to initialize instances from database rows.
// We've defined 'name' as the primary key in the table schema.
required init(row: Row) throws {
name = row["name"]
targetDuration = row["targetDuration"]
description = row["description"]
}

// Required for PersistableRecord conformance
// This tells GRDB how to persist the record to the database.
func encode(to container: inout PersistenceContainer) throws {
// 'name' is part of the primary key and should be included.
container["name"] = name
container["targetDuration"] = targetDuration
container["description"] = description
}

// Equatable conformance
static func == (lhs: FastingGoal, rhs: FastingGoal) -> Bool {
return lhs.name == rhs.name &&
lhs.targetDuration == rhs.targetDuration &&
lhs.description == rhs.description
}

// If 'name' is the primary key, it should be used for Hashable conformance as well.
// Codable conformance might require custom implementation if not all stored properties are Codable
// or if there's specific encoding/decoding logic needed (GRDB handles its own persistence).
// For simple Codable conformance for other uses (like JSON serialization), ensure all properties are Codable.
// TimeInterval (Double), String, and String? are all Codable.
}
28 changes: 27 additions & 1 deletion FastPath/Models/FastingRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
//

import Foundation
import GRDB
import StructuredQueries

/// Represents a single fasting record with start and end times
struct FastingRecord: Identifiable, Equatable, Codable {
@Table("fastingRecord")
class FastingRecord: Identifiable, Equatable, Codable, TableRecord, FetchableRecord, PersistableRecord {
var id: UUID
var startTime: Date
var endTime: Date?
Expand All @@ -22,11 +25,34 @@ struct FastingRecord: Identifiable, Equatable, Codable {
return endTime == nil
}

// Default initializer
init(id: UUID = UUID(), startTime: Date = Date(), endTime: Date? = nil) {
self.id = id
self.startTime = startTime
self.endTime = endTime
}

// Required for TableRecord conformance if no other initializers are suitable
// GRDB can often synthesize this if properties are straightforward
required init(row: Row) throws {
id = row["id"]
startTime = row["startTime"]
endTime = row["endTime"]
}

// Required for PersistableRecord conformance
func encode(to container: inout PersistenceContainer) throws {
container["id"] = id
container["startTime"] = startTime
container["endTime"] = endTime
}

// Equatable conformance
static func == (lhs: FastingRecord, rhs: FastingRecord) -> Bool {
return lhs.id == rhs.id &&
lhs.startTime == rhs.startTime &&
lhs.endTime == rhs.endTime
}
}


183 changes: 141 additions & 42 deletions FastPath/Services/DatabaseService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,79 +6,178 @@
//

import Foundation
import GRDB
import StructuredQueries // Though not directly used in this file, good to have if extending queries

/// Service for handling database operations for fasting records
class DatabaseService {
private let userDefaults = UserDefaults.standard
private let fastingRecordsKey = "fastingRecords"
private let fastingGoalKey = "fastingGoal"
// MARK: - Properties

init() {}
/// A DatabaseQueue to connect to the database.
///
/// Application Support Directory:
/// We use the Application Support directory to store the database file.
/// This directory is backed up by iCloud (if enabled) and is not deleted when the app is updated.
private var dbQueue: DatabaseQueue

// Singleton instance
static let shared = DatabaseService()
// Internal shared instance for testing
static var sharedForTesting: DatabaseService!

// MARK: - Initialization

/// Initializes the DatabaseService for production use.
/// Sets up the database connection to a file in Application Support and creates tables if they don't exist.
private init() {
do {
let fileManager = FileManager.default
let appSupportURL = try fileManager.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let dbURL = appSupportURL.appendingPathComponent("fastpath.sqlite")

self.dbQueue = try DatabaseQueue(path: dbURL.path)

try createTablesIfNeeded()

// Preload default goals if the goals table is empty
Task {
if await getGoal() == nil {
for goal in FastingGoal.predefinedGoals {
try await saveGoal(goal)
}
}
}

} catch {
// TODO: Handle this error more gracefully in a production app
fatalError("Failed to initialize database: \(error)")
}
}

/// Initializes the DatabaseService with a specific DatabaseQueue (for testing).
/// Also creates tables if they don't exist.
internal init(dbQueue: DatabaseQueue) throws {
self.dbQueue = dbQueue
try createTablesIfNeeded()
// Preload default goals for testing consistency if needed, or handle in test setup
Task {
if await getGoal() == nil {
for goal in FastingGoal.predefinedGoals {
try await saveGoal(goal)
}
}
}
}

/// Creates the necessary database tables if they don't already exist.
private func createTablesIfNeeded() throws {
try dbQueue.write { db in
// FastingRecord table
try db.create(table: FastingRecord.databaseTableName, ifNotExists: true) { t in
t.primaryKey("id", .text).notNull() // UUID is stored as TEXT
t.column("startTime", .datetime).notNull()
t.column("endTime", .datetime)
}

// FastingGoal table
try db.create(table: FastingGoal.databaseTableName, ifNotExists: true) { t in
t.primaryKey("name", .text).notNull()
t.column("targetDuration", .double).notNull() // TimeInterval is stored as DOUBLE
t.column("description", .text)
}
}
}

// MARK: - Fasting Records

/// Save a fasting record to the database
/// Save a fasting record to the database.
func save(_ record: FastingRecord) async throws {
var records = await getAllRecords()
records.append(record)
saveAllRecords(records)
try await dbQueue.write { db in
try record.insert(db)
}
}

/// Update an existing fasting record
/// Update an existing fasting record.
func update(_ record: FastingRecord) async throws {
var records = await getAllRecords()
if let index = records.firstIndex(where: { $0.id == record.id }) {
records[index] = record
saveAllRecords(records)
try await dbQueue.write { db in
try record.update(db)
}
}

/// Get all fasting records, ordered by start time (most recent first)
/// Get all fasting records, ordered by start time (most recent first).
func getAllRecords() async -> [FastingRecord] {
guard let data = userDefaults.data(forKey: fastingRecordsKey),
let records = try? JSONDecoder().decode([FastingRecord].self, from: data) else {
do {
return try await dbQueue.read { db in
try FastingRecord.all().orderBy(Column("startTime").desc).fetchAll(db)
}
} catch {
// TODO: Log error or handle more gracefully
print("Error fetching all records: \(error)")
return []
}
return records.sorted(by: { $0.startTime > $1.startTime })
}

/// Get the most recent active fasting record (if any)
/// Get the most recent active fasting record (if any).
func getActiveRecord() async -> FastingRecord? {
let records = await getAllRecords()
return records.first(where: { $0.endTime == nil })
}

/// Delete a fasting record by ID
func deleteRecord(withId id: UUID) async {
var records = await getAllRecords()
records.removeAll(where: { $0.id == id })
saveAllRecords(records)
do {
return try await dbQueue.read { db in
try FastingRecord.filter(Column("endTime") == nil).fetchOne(db)
}
} catch {
// TODO: Log error or handle more gracefully
print("Error fetching active record: \(error)")
return nil
}
}

/// Private helper to save all records
private func saveAllRecords(_ records: [FastingRecord]) {
if let data = try? JSONEncoder().encode(records) {
userDefaults.set(data, forKey: fastingRecordsKey)
/// Delete a fasting record by ID.
func deleteRecord(withId id: UUID) async throws {
try await dbQueue.write { db in
_ = try FastingRecord.deleteOne(db, key: id)
}
}

/// Save a fasting goal
func saveGoal(_ goal: FastingGoal?) async {
if let goal = goal, let data = try? JSONEncoder().encode(goal) {
userDefaults.set(data, forKey: fastingGoalKey)
// MARK: - Fasting Goals

/// Save or update a fasting goal. If the goal with the same name exists, it's updated. Otherwise, it's inserted.
/// If goal is nil, it deletes all existing goals.
func saveGoal(_ goal: FastingGoal?) async throws {
if let goalToSave = goal {
try await dbQueue.write { db in
try goalToSave.save(db) // save() handles insert or update
}
} else {
// If goal is nil, remove the saved goal
userDefaults.removeObject(forKey: fastingGoalKey)
// If goal is nil, remove all saved goals
try await dbQueue.write { db in
_ = try FastingGoal.deleteAll(db)
}
}
}

/// Get the saved fasting goal (if any)
/// Get the saved fasting goal.
/// For simplicity, this returns the first goal found.
/// If specific goal identification is needed (e.g., by name, or a concept of "active" goal),
/// this logic should be updated.
func getGoal() async -> FastingGoal? {
guard let data = userDefaults.data(forKey: fastingGoalKey),
let goal = try? JSONDecoder().decode(FastingGoal.self, from: data) else {
do {
return try await dbQueue.read { db in
try FastingGoal.fetchAll(db).first
}
} catch {
// TODO: Log error or handle more gracefully
print("Error fetching goal: \(error)")
return nil
}
}

/// Fetches a specific goal by its name.
func getGoal(named name: String) async -> FastingGoal? {
do {
return try await dbQueue.read { db in
try FastingGoal.filter(Column("name") == name).fetchOne(db)
}
} catch {
print("Error fetching goal named \(name): \(error)")
return nil
}
return goal
}
}
Loading