1use anyhow::{anyhow, Result};
2use chrono::{NaiveDate, Timelike, Utc};
3use serde_json::{json, Value};
4
5use crate::auth::FirebaseAuth;
6use crate::firestore::{
7 parse_document, parse_firestore_fields, to_firestore_fields,
8 FirestoreClient,
9};
10use crate::models::*;
11
12#[derive(Clone)]
13pub struct MacroFactorClient {
14 pub auth: FirebaseAuth,
15 pub firestore: FirestoreClient,
16 user_id: Option<String>,
17}
18
19impl MacroFactorClient {
20 pub fn new(refresh_token: String) -> Self {
21 let auth = FirebaseAuth::new(refresh_token);
22 let firestore = FirestoreClient::new(auth.clone());
23 Self {
24 auth,
25 firestore,
26 user_id: None,
27 }
28 }
29
30 pub async fn login(email: &str, password: &str) -> Result<Self> {
32 let auth = FirebaseAuth::sign_in_with_email(email, password).await?;
33 let firestore = FirestoreClient::new(auth.clone());
34 Ok(Self {
35 auth,
36 firestore,
37 user_id: None,
38 })
39 }
40
41 pub async fn get_user_id(&mut self) -> Result<String> {
42 if let Some(ref uid) = self.user_id {
43 return Ok(uid.clone());
44 }
45 let uid = self.auth.get_user_id().await?;
46 self.user_id = Some(uid.clone());
47 Ok(uid)
48 }
49
50 pub async fn get_profile(&mut self) -> Result<Value> {
52 let uid = self.get_user_id().await?;
53 let doc = self.firestore.get_document(&format!("users/{}", uid)).await?;
54 Ok(parse_document(&doc))
55 }
56
57 pub async fn list_subcollections(&self, document_path: &str) -> Result<Vec<String>> {
59 self.firestore
60 .list_collection_ids(Some(document_path))
61 .await
62 }
63
64 pub async fn sample_collection(
66 &self,
67 collection_path: &str,
68 limit: u32,
69 ) -> Result<Vec<Value>> {
70 let (docs, _) = self
71 .firestore
72 .list_documents(collection_path, Some(limit), None)
73 .await?;
74 Ok(docs.iter().map(parse_document).collect())
75 }
76
77 pub async fn get_raw_document(&self, path: &str) -> Result<Value> {
79 let doc = self.firestore.get_document(path).await?;
80 Ok(parse_document(&doc))
81 }
82
83 pub async fn get_weight_entries(
86 &mut self,
87 start: NaiveDate,
88 end: NaiveDate,
89 ) -> Result<Vec<ScaleEntry>> {
90 let uid = self.get_user_id().await?;
91 let mut entries = Vec::new();
92
93 let start_year = start.format("%Y").to_string().parse::<i32>()?;
95 let end_year = end.format("%Y").to_string().parse::<i32>()?;
96
97 for year in start_year..=end_year {
98 let path = format!("users/{}/scale/{}", uid, year);
99 let doc = match self.firestore.get_document(&path).await {
100 Ok(d) => d,
101 Err(_) => continue,
102 };
103
104 if let Some(ref fields) = doc.fields {
105 let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
106 if let Some(map) = parsed.as_object() {
107 for (key, val) in map {
108 if key.starts_with('_') || key.len() != 4 {
109 continue;
110 }
111 let month: u32 = key[..2].parse().unwrap_or(0);
113 let day: u32 = key[2..].parse().unwrap_or(0);
114 if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
115 if date >= start && date <= end {
116 if let Some(obj) = val.as_object() {
117 let weight = obj
118 .get("w")
119 .and_then(|v| v.as_f64())
120 .unwrap_or(0.0);
121 let body_fat = obj.get("f").and_then(|v| v.as_f64());
122 let source = obj
123 .get("s")
124 .and_then(|v| v.as_str())
125 .map(String::from);
126
127 entries.push(ScaleEntry {
128 date,
129 weight,
130 body_fat,
131 source,
132 });
133 }
134 }
135 }
136 }
137 }
138 }
139 }
140
141 entries.sort_by_key(|e| e.date);
142 Ok(entries)
143 }
144
145 pub async fn get_nutrition(
148 &mut self,
149 start: NaiveDate,
150 end: NaiveDate,
151 ) -> Result<Vec<NutritionSummary>> {
152 let uid = self.get_user_id().await?;
153 let mut entries = Vec::new();
154
155 let start_year = start.format("%Y").to_string().parse::<i32>()?;
156 let end_year = end.format("%Y").to_string().parse::<i32>()?;
157
158 for year in start_year..=end_year {
159 let path = format!("users/{}/nutrition/{}", uid, year);
160 let doc = match self.firestore.get_document(&path).await {
161 Ok(d) => d,
162 Err(_) => continue,
163 };
164
165 if let Some(ref fields) = doc.fields {
166 let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
167 if let Some(map) = parsed.as_object() {
168 for (key, val) in map {
169 if key.starts_with('_') || key.len() != 4 {
170 continue;
171 }
172 let month: u32 = key[..2].parse().unwrap_or(0);
173 let day: u32 = key[2..].parse().unwrap_or(0);
174 if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
175 if date >= start && date <= end {
176 if let Some(obj) = val.as_object() {
177 let parse_num = |k: &str| -> Option<f64> {
178 obj.get(k).and_then(|v| {
179 v.as_f64().or_else(|| {
180 v.as_str().and_then(|s| s.parse().ok())
181 })
182 })
183 };
184
185 entries.push(NutritionSummary {
186 date,
187 calories: parse_num("k"),
188 protein: parse_num("p"),
189 carbs: parse_num("c"),
190 fat: parse_num("f"),
191 sugar: parse_num("269"),
192 fiber: parse_num("291"),
193 source: obj
194 .get("s")
195 .and_then(|v| v.as_str())
196 .map(String::from),
197 });
198 }
199 }
200 }
201 }
202 }
203 }
204 }
205
206 entries.sort_by_key(|e| e.date);
207 Ok(entries)
208 }
209
210 pub async fn get_food_log(&mut self, date: NaiveDate) -> Result<Vec<FoodEntry>> {
213 let uid = self.get_user_id().await?;
214 let date_str = date.format("%Y-%m-%d").to_string();
215 let path = format!("users/{}/food/{}", uid, date_str);
216
217 let doc = match self.firestore.get_document(&path).await {
218 Ok(d) => d,
219 Err(e) if e.to_string().contains("404") => return Ok(Vec::new()),
220 Err(e) => return Err(e),
221 };
222 let mut entries = Vec::new();
223
224 if let Some(ref fields) = doc.fields {
225 let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
226 if let Some(map) = parsed.as_object() {
227 for (key, val) in map {
228 if key.starts_with('_') {
229 continue;
230 }
231 if let Some(obj) = val.as_object() {
232 let parse_num = |k: &str| -> Option<f64> {
233 obj.get(k).and_then(|v| {
234 v.as_f64()
235 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
236 })
237 };
238 let parse_str =
239 |k: &str| obj.get(k).and_then(|v| v.as_str()).map(String::from);
240
241 let serving_grams = parse_num("g");
242 let user_qty = parse_num("y");
243 let unit_weight = parse_num("w");
244
245 entries.push(FoodEntry {
246 date,
247 entry_id: key.clone(),
248 name: parse_str("t"),
249 brand: parse_str("b"),
250 calories_raw: parse_num("c"),
251 protein_raw: parse_num("p"),
252 carbs_raw: parse_num("e"),
253 fat_raw: parse_num("f"),
254 serving_grams,
255 user_qty,
256 unit_weight,
257 quantity: parse_num("q"),
258 serving_unit: parse_str("s"),
259 hour: parse_str("h"),
260 minute: parse_str("mi"),
261 source_type: parse_str("k"),
262 food_id: parse_str("id"),
263 });
264 }
265 }
266 }
267 }
268
269 entries.sort_by(|a, b| {
271 let time_a = (
272 a.hour.as_deref().unwrap_or("0").parse::<u32>().unwrap_or(0),
273 a.minute
274 .as_deref()
275 .unwrap_or("0")
276 .parse::<u32>()
277 .unwrap_or(0),
278 );
279 let time_b = (
280 b.hour.as_deref().unwrap_or("0").parse::<u32>().unwrap_or(0),
281 b.minute
282 .as_deref()
283 .unwrap_or("0")
284 .parse::<u32>()
285 .unwrap_or(0),
286 );
287 time_a.cmp(&time_b)
288 });
289
290 Ok(entries)
291 }
292
293 pub async fn get_steps(
296 &mut self,
297 start: NaiveDate,
298 end: NaiveDate,
299 ) -> Result<Vec<StepEntry>> {
300 let uid = self.get_user_id().await?;
301 let mut entries = Vec::new();
302
303 let start_year = start.format("%Y").to_string().parse::<i32>()?;
304 let end_year = end.format("%Y").to_string().parse::<i32>()?;
305
306 for year in start_year..=end_year {
307 let path = format!("users/{}/steps/{}", uid, year);
308 let doc = match self.firestore.get_document(&path).await {
309 Ok(d) => d,
310 Err(_) => continue,
311 };
312
313 if let Some(ref fields) = doc.fields {
314 let parsed = parse_firestore_fields(&Value::Object(fields.clone()));
315 if let Some(map) = parsed.as_object() {
316 for (key, val) in map {
317 if key.starts_with('_') || key.len() != 4 {
318 continue;
319 }
320 let month: u32 = key[..2].parse().unwrap_or(0);
321 let day: u32 = key[2..].parse().unwrap_or(0);
322 if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
323 if date >= start && date <= end {
324 if let Some(obj) = val.as_object() {
325 let steps = obj
326 .get("st")
327 .and_then(|v| {
328 v.as_u64().or_else(|| {
329 v.as_str().and_then(|s| s.parse().ok())
330 })
331 })
332 .unwrap_or(0);
333 let source = obj
334 .get("s")
335 .and_then(|v| v.as_str())
336 .map(String::from);
337
338 entries.push(StepEntry {
339 date,
340 steps,
341 source,
342 });
343 }
344 }
345 }
346 }
347 }
348 }
349 }
350
351 entries.sort_by_key(|e| e.date);
352 Ok(entries)
353 }
354
355 pub async fn get_goals(&mut self) -> Result<Goals> {
357 let profile = self.get_profile().await?;
358
359 let planner = profile
360 .get("planner")
361 .ok_or_else(|| anyhow!("No planner field in user profile"))?;
362
363 let parse_vec = |key: &str| -> Vec<f64> {
364 planner
365 .get(key)
366 .and_then(|v| v.as_array())
367 .map(|arr| {
368 arr.iter()
369 .filter_map(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
370 .collect()
371 })
372 .unwrap_or_default()
373 };
374
375 Ok(Goals {
376 calories: parse_vec("calories"),
377 protein: parse_vec("protein"),
378 carbs: parse_vec("carbs"),
379 fat: parse_vec("fat"),
380 tdee: planner
381 .get("tdeeValue")
382 .and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))),
383 program_style: planner
384 .get("programStyle")
385 .and_then(|v| v.as_str())
386 .map(String::from),
387 program_type: planner
388 .get("programType")
389 .and_then(|v| v.as_str())
390 .map(String::from),
391 })
392 }
393
394 pub async fn log_food(
399 &mut self,
400 date: NaiveDate,
401 name: &str,
402 calories: f64,
403 protein: f64,
404 carbs: f64,
405 fat: f64,
406 ) -> Result<()> {
407 let uid = self.get_user_id().await?;
408 let date_str = date.format("%Y-%m-%d").to_string();
409 let path = format!("users/{}/food/{}", uid, date_str);
410
411 let now = Utc::now();
412 let entry_id = format!(
413 "{}",
414 now.timestamp_millis() * 1000 + now.timestamp_subsec_micros() as i64 % 1000
415 );
416 let food_id = format!(
417 "{}",
418 now.timestamp_millis() * 1000 + (now.timestamp_subsec_micros() as i64 % 1000) + 10
419 );
420
421 let hour = now.hour().to_string();
422 let minute = now.minute().to_string();
423
424 let entry = json!({
425 "t": name,
426 "b": "Quick Add",
427 "c": format!("{:.1}", calories),
428 "p": format!("{:.1}", protein),
429 "e": format!("{:.1}", carbs),
430 "f": format!("{:.1}", fat),
431 "w": "100.0",
432 "g": "100.0",
433 "q": "1.0",
434 "y": "1.0",
435 "s": "serving",
436 "u": "serving",
437 "h": hour,
438 "mi": minute,
439 "k": "n",
440 "id": food_id,
441 "ca": &entry_id,
442 "ua": &entry_id,
443 "ef": true,
444 "d": false,
445 "x": "13",
446 "m": [{"m": "serving", "q": "1.0", "w": "100.0"}]
447 });
448
449 let fields = to_firestore_fields(&json!({ &entry_id: entry }));
450
451 let field_mask = format!("`{}`", entry_id);
453 self.firestore
454 .patch_document(&path, fields, &[&field_mask])
455 .await?;
456
457 Ok(())
458 }
459
460 pub async fn log_weight(
463 &mut self,
464 date: NaiveDate,
465 weight_kg: f64,
466 body_fat: Option<f64>,
467 ) -> Result<()> {
468 let uid = self.get_user_id().await?;
469 let year = date.format("%Y").to_string();
470 let mmdd = date.format("%m%d").to_string();
471 let path = format!("users/{}/scale/{}", uid, year);
472
473 let mut entry = json!({
474 "w": weight_kg,
475 "s": "m",
476 "do": null
477 });
478 if let Some(bf) = body_fat {
479 entry["f"] = json!(bf);
480 } else {
481 entry["f"] = Value::Null;
482 }
483
484 let fields = to_firestore_fields(&json!({ &mmdd: entry }));
485
486 self.firestore
487 .patch_document(&path, fields, &[&mmdd])
488 .await?;
489
490 Ok(())
491 }
492
493 pub async fn log_nutrition(
495 &mut self,
496 date: NaiveDate,
497 calories: f64,
498 protein: Option<f64>,
499 carbs: Option<f64>,
500 fat: Option<f64>,
501 ) -> Result<()> {
502 let uid = self.get_user_id().await?;
503 let year = date.format("%Y").to_string();
504 let mmdd = date.format("%m%d").to_string();
505 let path = format!("users/{}/nutrition/{}", uid, year);
506
507 let entry = json!({
508 "k": format!("{:.0}", calories),
509 "p": protein.map(|v| format!("{:.0}", v)).unwrap_or_default(),
510 "c": carbs.map(|v| format!("{:.0}", v)).unwrap_or_default(),
511 "f": fat.map(|v| format!("{:.0}", v)).unwrap_or_default(),
512 "s": "m",
513 "do": null
514 });
515
516 let fields = to_firestore_fields(&json!({ &mmdd: entry }));
517
518 self.firestore
519 .patch_document(&path, fields, &[&mmdd])
520 .await?;
521
522 Ok(())
523 }
524}