Skip to content
Merged
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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ sleep, exercise-timing, fasting-window, and goal guidance without using AI.

The app is local-first: **Start on this device** works without an account and
keeps the journal in versioned browser or iPhone storage. Google sign-in is
optional on the web. The native client can explicitly link Sign in with Apple
to an existing Google-backed journal and enable private Cloudflare D1 sync when
the cloud bindings are configured.
optional on the web. The native client can connect that same Google-backed
journal directly for private Cloudflare D1 sync and optionally link Sign in
with Apple as another way to reopen it.

Production target: `https://calorie.significanthobbies.com`

Expand Down Expand Up @@ -63,7 +63,8 @@ Production uses a dedicated Google web client with
callback. Only the standard OpenID Connect identity scopes are requested.
Native Apple ID tokens are verified for `com.significanthobbies.calorie`.
Implicit email linking is disabled; existing owners authenticate their Google
account first, then link the verified Apple provider identity explicitly.
account to sync directly, then may explicitly link the verified Apple provider
identity without changing journals.

## How recommendations work

Expand Down
4 changes: 2 additions & 2 deletions ios/RELEASE_METADATA.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Calorie is a small, private food journal for the moments right after you eat. Re

Daily targets can be entered manually or estimated with a published equation profile. Every timing suggestion names its recorded inputs and rule, and clearly remains an estimate—not medical advice.

Logging works locally without an account. Optionally connect an existing Calorie journal, link Sign in with Apple, and reconcile cloud and device records with an explicit preview. Review days and weeks, meal timing, trends, familiar and custom foods, and export your journal whenever you choose.
Logging works locally without an account. Optionally connect an existing Calorie journal and reconcile cloud and device records with an explicit preview. Sign in with Apple can be linked later as another way to open the same journal. Review days and weeks, meal timing, trends, familiar and custom foods, and export your journal whenever you choose.

**Keywords**
food journal,calories,protein,macros,nutrition,meal log,water,timing,weight
Expand Down Expand Up @@ -75,7 +75,7 @@ Confirm the rating produced by App Store Connect's current questionnaire.

## Review notes draft

The app can be used without an account. A fresh journal contains reusable food templates but no fabricated personal meals, water, weight, routines, or notes. Existing web users choose **Connect existing Calorie data**, authenticate with Google once, link Apple explicitly, and then choose cloud, device, or merge. Medication is limited to user-named routines and a daily checkbox; the app does not store dosage or provide medication guidance.
The app can be used without an account. A fresh journal contains reusable food templates but no fabricated personal meals, water, weight, routines, or notes. Existing web users choose **Connect existing Calorie data**, authenticate with Google once, and then choose cloud, device, or merge. Apple sign-in is an optional additional login for that same journal. Medication is limited to user-named routines and a daily checkbox; the app does not store dosage or provide medication guidance.

## Screenshots and release

Expand Down
89 changes: 55 additions & 34 deletions ios/Sources/Calorie/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@ final class AppModel {
private(set) var cloudSnapshot: CloudJournalSnapshot?
var isReconciliationPresented = false
private(set) var pendingSyncCount = 0
private(set) var isSyncing = false

private let store: CalorieStore
private let accountClient: NativeAccountClient
private let accountClient: any NativeAccountServing
private let syncStore: SyncIntentStore
private let webAuthentication = WebAuthenticationCoordinator()

init(
store: CalorieStore = CalorieStore(),
accountClient: NativeAccountClient = NativeAccountClient(),
accountClient: any NativeAccountServing = NativeAccountClient(),
syncStore: SyncIntentStore = SyncIntentStore()
) {
self.store = store
Expand Down Expand Up @@ -60,10 +61,12 @@ final class AppModel {
if ProcessInfo.processInfo.arguments.contains("--quick-log-demo") { isQuickLogPresented = true }
account = try? await accountClient.restoreAccount()
pendingSyncCount = (try? await syncStore.pending().count) ?? 0
if account?.hasApple == true, document.syncState == .localOnly {
await prepareCloudReconciliation()
} else if account != nil, pendingSyncCount > 0 {
await syncNow()
if account != nil {
if document.syncState == .localOnly {
await prepareCloudReconciliation()
} else {
await syncNow()
}
}
} catch {
document = .starter
Expand All @@ -78,7 +81,7 @@ final class AppModel {
}

func delete(_ entry: FoodEntry) async {
await mutate(deletions: [.deleteFoodEntry(entry.id)]) { document in
await mutate { document in
lastDeletedEntry = try document.deleteEntry(entry.id)
}
message = "Entry removed. Undo is available below."
Expand Down Expand Up @@ -119,10 +122,7 @@ final class AppModel {
}

func toggleRoutine(_ routine: MedicationRoutine) async {
let deletion = document.routineCheckIns.first {
$0.routineID == routine.id && Calendar.current.isDate($0.date, inSameDayAs: selectedDate)
}.map { SyncOperation.deleteRoutineCheckIn($0.id) }
await mutate(deletions: deletion.map { [$0] } ?? []) {
await mutate {
$0.toggleRoutine(routine.id, on: selectedDate)
}
}
Expand Down Expand Up @@ -155,10 +155,7 @@ final class AppModel {
}

func saveDailyContext(weightKilograms: Double?, note: String, cycle: CycleContext) async {
let removedWeights = document.weightEntries
.filter { Calendar.current.isDate($0.date, inSameDayAs: selectedDate) }
.map { SyncOperation.deleteWeightEntry($0.id) }
await mutate(deletions: removedWeights) { document in
await mutate { document in
let calendar = Calendar.current
document.weightEntries.removeAll { calendar.isDate($0.date, inSameDayAs: selectedDate) }
if let weightKilograms, weightKilograms > 0 {
Expand Down Expand Up @@ -212,10 +209,13 @@ final class AppModel {
func confirmImport() async {
guard let importPreview else { return }
do {
let previous = document
try await store.replace(with: importPreview)
document = importPreview
if account != nil {
try await syncStore.enqueue(.snapshot(importPreview))
for operation in CloudJournalDiff.operations(from: previous, to: importPreview) {
try await syncStore.enqueue(operation)
}
await syncNow()
}
self.importPreview = nil
Expand Down Expand Up @@ -248,7 +248,8 @@ final class AppModel {
let code = components.queryItems?.first(where: { $0.name == "code" })?.value
else { throw NativeAccountError.invalidCallback }
account = try await accountClient.exchangeGoogleHandoff(code)
accountNotice = "Existing journal connected. Add Apple once so future Apple sign-ins open this journal."
accountNotice = "Cloud journal connected. Apple sign-in is optional."
await prepareCloudReconciliation()
} catch {
message = accountErrorMessage(error, recovery: "Try connecting your existing journal again.")
}
Expand Down Expand Up @@ -310,7 +311,9 @@ final class AppModel {
try await store.save(next)
document = next
if choice != .keepCloud {
try await syncStore.enqueue(.snapshot(next))
for operation in CloudJournalDiff.operations(from: cloudSnapshot.document, to: next) {
try await syncStore.enqueue(operation)
}
await syncNow()
}
self.cloudSnapshot = nil
Expand Down Expand Up @@ -352,47 +355,65 @@ final class AppModel {
}

func syncNow() async {
guard account != nil else { return }
guard account != nil, !isSyncing else { return }
if document.syncState == .conflict {
await resumeReconciliation()
return
}
isSyncing = true
defer { isSyncing = false }
do {
let pending = try await syncStore.pending()
pendingSyncCount = pending.count
for intent in pending {
try await accountClient.apply(intent)
try await syncStore.complete(intent.id)
pendingSyncCount -= 1
while true {
let pending = try await syncStore.pending()
pendingSyncCount = pending.count
for intent in pending {
try await accountClient.apply(intent)
try await syncStore.complete(intent.id)
pendingSyncCount -= 1
}
guard try await syncStore.pending().isEmpty else { continue }
let cloud = try CloudJournalMapper.decode(await accountClient.cloudExport())
guard try await syncStore.pending().isEmpty else { continue }
let refreshed = CloudJournalMapper.reconcile(
local: document,
cloud: cloud,
choice: .keepCloud
)
document = refreshed
try await store.save(refreshed)
guard try await syncStore.pending().isEmpty else { continue }
break
}
document.syncState = .synced
document.lastSyncedAt = .now
try await store.save(document)
} catch {
pendingSyncCount = (try? await syncStore.pending().count) ?? pendingSyncCount
document.syncState = pendingSyncCount > 0 ? .pending : .failed
try? await store.save(document)
message = accountErrorMessage(error, recovery: "Your changes are saved on this device and cloud sync can be retried.")
}
}

func refreshFromCloud() async {
guard !isLoading, account != nil else { return }
await syncNow()
}

private func accountErrorMessage(_ error: Error, recovery: String) -> String {
let detail = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
return "\(detail) \(recovery)"
}

private func mutate(
deletions: [SyncOperation] = [],
_ operation: (inout CalorieDocument) throws -> Void
) async {
private func mutate(_ operation: (inout CalorieDocument) throws -> Void) async {
do {
let previous = document
var next = document
try operation(&next)
if account != nil { next.syncState = .pending }
try await store.save(next)
document = next
if account != nil {
for deletion in deletions { try await syncStore.enqueue(deletion) }
try await syncStore.enqueue(.snapshot(next))
for operation in CloudJournalDiff.operations(from: previous, to: next) {
try await syncStore.enqueue(operation)
}
pendingSyncCount = (try await syncStore.pending()).count
await syncNow()
}
Expand Down
5 changes: 5 additions & 0 deletions ios/Sources/Calorie/CalorieApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SwiftUI

@main
struct CalorieApp: App {
@Environment(\.scenePhase) private var scenePhase
@State private var model = AppModel()

var body: some Scene {
Expand All @@ -11,6 +12,10 @@ struct CalorieApp: App {
.environment(model)
.preferredColorScheme(model.preferredColorScheme)
.task { await model.load() }
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
Task { await model.refreshFromCloud() }
}
}
}
}
Loading
Loading