Skip to content

Commit 5cbb5d3

Browse files
Multiple feature UI improvements
-Add thumbnail to favorite foods lists -Add Heart icon to action saving as Favorite Food -Add Estimated Confidence percent when using AI -When deleting food item show strikethrough font, add green plus, allow user to add itemback in
1 parent 5aeda3b commit 5cbb5d3

File tree

9 files changed

+522
-59
lines changed

9 files changed

+522
-59
lines changed

Loop/Extensions/UserDefaults+Loop.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ extension UserDefaults {
3131
case foodSearchEnabled = "com.loopkit.Loop.foodSearchEnabled"
3232
case advancedDosingRecommendationsEnabled = "com.loopkit.Loop.advancedDosingRecommendationsEnabled"
3333
case useGPT5ForOpenAI = "com.loopkit.Loop.useGPT5ForOpenAI"
34+
case favoriteFoodImageIDs = "com.loopkit.Loop.favoriteFoodImageIDs"
3435
}
3536

3637
var legacyPumpManagerRawValue: PumpManager.RawValue? {
@@ -415,4 +416,14 @@ MANDATORY REQUIREMENTS:
415416
set(newValue, forKey: Key.useGPT5ForOpenAI.rawValue)
416417
}
417418
}
419+
420+
// Mapping of FavoriteFood.id -> image identifier (filename in image store)
421+
var favoriteFoodImageIDs: [String: String] {
422+
get {
423+
return dictionary(forKey: Key.favoriteFoodImageIDs.rawValue) as? [String: String] ?? [:]
424+
}
425+
set {
426+
set(newValue, forKey: Key.favoriteFoodImageIDs.rawValue)
427+
}
428+
}
418429
}

Loop/Services/AIFoodAnalysis.swift

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,8 @@ struct FoodItemAnalysis {
703703
let fiber: Double?
704704
let protein: Double?
705705
let assessmentNotes: String?
706+
// Optional per-item absorption time (hours) if provided by the AI
707+
let absorptionTimeHours: Double?
706708
}
707709

708710
/// Type of image being analyzed
@@ -1924,7 +1926,11 @@ private func parseOpenAIResponse(content: String) throws -> AIFoodAnalysisResult
19241926
fat: extractNumber(from: itemData, keys: ["fat"]).map { max(0, $0) },
19251927
fiber: extractNumber(from: itemData, keys: ["fiber"]).map { max(0, $0) },
19261928
protein: extractNumber(from: itemData, keys: ["protein"]).map { max(0, $0) },
1927-
assessmentNotes: extractString(from: itemData, keys: ["assessment_notes"])
1929+
assessmentNotes: extractString(from: itemData, keys: ["assessment_notes"]),
1930+
absorptionTimeHours: {
1931+
if let v = extractNumber(from: itemData, keys: ["absorption_time_hours"]) { return min(max(v, 0), 24) }
1932+
return nil
1933+
}()
19281934
)
19291935
detailedFoodItems.append(foodItem)
19301936
}
@@ -2407,7 +2413,8 @@ Use visual references for portion estimates. Compare to USDA serving sizes.
24072413
fat: extractNumber(from: itemData, keys: ["fat"]).map { max(0, $0) }, // Bounds checking
24082414
fiber: extractNumber(from: itemData, keys: ["fiber"]).map { max(0, $0) }, // Bounds checking
24092415
protein: extractNumber(from: itemData, keys: ["protein"]).map { max(0, $0) }, // Bounds checking
2410-
assessmentNotes: extractString(from: itemData, keys: ["assessment_notes"])
2416+
assessmentNotes: extractString(from: itemData, keys: ["assessment_notes"]),
2417+
absorptionTimeHours: nil
24112418
)
24122419
detailedFoodItems.append(foodItem)
24132420
} catch {
@@ -2440,7 +2447,8 @@ Use visual references for portion estimates. Compare to USDA serving sizes.
24402447
fat: totalFat,
24412448
fiber: totalFiber,
24422449
protein: totalProtein,
2443-
assessmentNotes: "Legacy format - combined nutrition values"
2450+
assessmentNotes: "Legacy format - combined nutrition values",
2451+
absorptionTimeHours: nil
24442452
)
24452453
detailedFoodItems = [singleItem]
24462454
}
@@ -2459,7 +2467,8 @@ Use visual references for portion estimates. Compare to USDA serving sizes.
24592467
fat: 10.0,
24602468
fiber: 5.0,
24612469
protein: 15.0,
2462-
assessmentNotes: "Safe fallback nutrition estimate - please verify actual food for accuracy"
2470+
assessmentNotes: "Safe fallback nutrition estimate - please verify actual food for accuracy",
2471+
absorptionTimeHours: nil
24632472
)
24642473
detailedFoodItems = [fallbackItem]
24652474
}
@@ -3161,7 +3170,11 @@ class GoogleGeminiFoodAnalysisService {
31613170
fat: extractNumber(from: itemData, keys: ["fat"]),
31623171
fiber: extractNumber(from: itemData, keys: ["fiber"]),
31633172
protein: extractNumber(from: itemData, keys: ["protein"]),
3164-
assessmentNotes: extractString(from: itemData, keys: ["assessment_notes"])
3173+
assessmentNotes: extractString(from: itemData, keys: ["assessment_notes"]),
3174+
absorptionTimeHours: {
3175+
if let v = extractNumber(from: itemData, keys: ["absorption_time_hours"]) { return min(max(v, 0), 24) }
3176+
return nil
3177+
}()
31653178
)
31663179
detailedFoodItems.append(foodItem)
31673180
} catch {
@@ -3189,7 +3202,11 @@ class GoogleGeminiFoodAnalysisService {
31893202
fat: totalFat,
31903203
fiber: totalFiber,
31913204
protein: totalProtein,
3192-
assessmentNotes: "Legacy format - combined nutrition values"
3205+
assessmentNotes: "Legacy format - combined nutrition values",
3206+
absorptionTimeHours: {
3207+
if let v = extractNumber(from: nutritionData, keys: ["absorption_time_hours"]) { return min(max(v, 0), 24) }
3208+
return nil
3209+
}()
31933210
)
31943211
detailedFoodItems = [singleItem]
31953212
}
@@ -3211,7 +3228,11 @@ class GoogleGeminiFoodAnalysisService {
32113228
fat: 10.0,
32123229
fiber: 5.0,
32133230
protein: 15.0,
3214-
assessmentNotes: "Safe fallback nutrition estimate - check actual food for accuracy"
3231+
assessmentNotes: "Safe fallback nutrition estimate - check actual food for accuracy",
3232+
absorptionTimeHours: {
3233+
if let v = extractNumber(from: nutritionData, keys: ["absorption_time_hours"]) { return min(max(v, 0), 24) }
3234+
return nil
3235+
}()
32153236
)
32163237
detailedFoodItems = [fallbackItem]
32173238
}
@@ -3477,7 +3498,8 @@ class BasicFoodAnalysisService {
34773498
fat: estimateFat(for: selectedFood, portion: portionSize),
34783499
fiber: estimateFiber(for: selectedFood, portion: portionSize),
34793500
protein: estimateProtein(for: selectedFood, portion: portionSize),
3480-
assessmentNotes: "Basic estimate based on typical portions and common nutrition values. For diabetes management, monitor actual blood glucose response."
3501+
assessmentNotes: "Basic estimate based on typical portions and common nutrition values. For diabetes management, monitor actual blood glucose response.",
3502+
absorptionTimeHours: nil
34813503
)
34823504
]
34833505
}
@@ -3843,7 +3865,8 @@ class ClaudeFoodAnalysisService {
38433865
fat: extractClaudeNumber(from: item, keys: ["fat"]).map { max(0, $0) }, // Bounds checking
38443866
fiber: extractClaudeNumber(from: item, keys: ["fiber"]).map { max(0, $0) }, // Bounds checking
38453867
protein: extractClaudeNumber(from: item, keys: ["protein"]).map { max(0, $0) }, // Bounds checking
3846-
assessmentNotes: extractClaudeString(from: item, keys: ["assessment_notes"])
3868+
assessmentNotes: extractClaudeString(from: item, keys: ["assessment_notes"]),
3869+
absorptionTimeHours: nil
38473870
)
38483871
foodItems.append(foodItem)
38493872
} catch {
@@ -3877,7 +3900,8 @@ class ClaudeFoodAnalysisService {
38773900
fat: totalFat.map { max(0, $0) }, // Bounds checking
38783901
fiber: totalFiber.map { max(0, $0) },
38793902
protein: totalProtein.map { max(0, $0) }, // Bounds checking
3880-
assessmentNotes: "Safe fallback nutrition estimate - please verify actual food for accuracy"
3903+
assessmentNotes: "Safe fallback nutrition estimate - please verify actual food for accuracy",
3904+
absorptionTimeHours: nil
38813905
)
38823906
]
38833907
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import UIKit
2+
3+
/// Stores small thumbnails for Favorite Foods and returns identifiers for lookup.
4+
/// Images are stored under Application Support/Favorites/Thumbnails as JPEG.
5+
enum FavoriteFoodImageStore {
6+
private static var thumbnailsDir: URL? = {
7+
do {
8+
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
9+
let dir = base.appendingPathComponent("Favorites/Thumbnails", isDirectory: true)
10+
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
11+
return dir
12+
} catch {
13+
#if DEBUG
14+
print("📂 FavoriteFoodImageStore init error: \(error)")
15+
#endif
16+
return nil
17+
}
18+
}()
19+
20+
/// Save a thumbnail (JPEG) and return its identifier (filename)
21+
static func saveThumbnail(from image: UIImage, maxDimension: CGFloat = 300) -> String? {
22+
guard let dir = thumbnailsDir else { return nil }
23+
let size = computeTargetSize(for: image.size, maxDimension: maxDimension)
24+
let thumb = imageByScaling(image: image, to: size)
25+
guard let data = thumb.jpegData(compressionQuality: 0.8) else { return nil }
26+
let id = UUID().uuidString + ".jpg"
27+
let url = dir.appendingPathComponent(id)
28+
do {
29+
try data.write(to: url, options: .atomic)
30+
return id
31+
} catch {
32+
#if DEBUG
33+
print("💾 Failed to save favorite thumbnail: \(error)")
34+
#endif
35+
return nil
36+
}
37+
}
38+
39+
/// Load thumbnail for identifier
40+
static func loadThumbnail(id: String) -> UIImage? {
41+
guard let dir = thumbnailsDir else { return nil }
42+
let url = dir.appendingPathComponent(id)
43+
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
44+
return UIImage(contentsOfFile: url.path)
45+
}
46+
47+
/// Delete thumbnail for identifier
48+
static func deleteThumbnail(id: String) {
49+
guard let dir = thumbnailsDir else { return }
50+
let url = dir.appendingPathComponent(id)
51+
try? FileManager.default.removeItem(at: url)
52+
}
53+
54+
private static func computeTargetSize(for size: CGSize, maxDimension: CGFloat) -> CGSize {
55+
guard max(size.width, size.height) > maxDimension else { return size }
56+
let scale = maxDimension / max(size.width, size.height)
57+
return CGSize(width: size.width * scale, height: size.height * scale)
58+
}
59+
60+
private static func imageByScaling(image: UIImage, to size: CGSize) -> UIImage {
61+
let format = UIGraphicsImageRendererFormat.default()
62+
format.scale = 1
63+
let renderer = UIGraphicsImageRenderer(size: size, format: format)
64+
return renderer.image { _ in
65+
image.draw(in: CGRect(origin: .zero, size: size))
66+
}
67+
}
68+
}
69+
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import UIKit
2+
3+
enum ImageDownloader {
4+
static func fetchThumbnail(from url: URL, maxDimension: CGFloat = 300) async -> UIImage? {
5+
var req = URLRequest(url: url)
6+
req.timeoutInterval = 10
7+
do {
8+
let (data, response) = try await URLSession.shared.data(for: req)
9+
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { return nil }
10+
// Basic size guard (<= 2 MB)
11+
guard data.count <= 2_000_000 else { return nil }
12+
guard let image = UIImage(data: data) else { return nil }
13+
let size = computeTargetSize(for: image.size, maxDimension: maxDimension)
14+
return scale(image: image, to: size)
15+
} catch {
16+
#if DEBUG
17+
print("🌐 Image download failed: \(error)")
18+
#endif
19+
return nil
20+
}
21+
}
22+
23+
private static func computeTargetSize(for size: CGSize, maxDimension: CGFloat) -> CGSize {
24+
guard max(size.width, size.height) > maxDimension else { return size }
25+
let scale = maxDimension / max(size.width, size.height)
26+
return CGSize(width: size.width * scale, height: size.height * scale)
27+
}
28+
29+
private static func scale(image: UIImage, to size: CGSize) -> UIImage {
30+
let format = UIGraphicsImageRendererFormat.default()
31+
format.scale = 1
32+
let renderer = UIGraphicsImageRenderer(size: size, format: format)
33+
return renderer.image { _ in
34+
image.draw(in: CGRect(origin: .zero, size: size))
35+
}
36+
}
37+
}
38+

Loop/View Models/CarbEntryViewModel.swift

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ final class CarbEntryViewModel: ObservableObject {
153153

154154
/// Store the last AI analysis result for detailed UI display
155155
@Published var lastAIAnalysisResult: AIFoodAnalysisResult? = nil
156+
/// Indices of AI-detected items excluded by the user (soft delete)
157+
@Published var excludedAIItemIndices: Set<Int> = []
156158

157159
/// Store the captured AI image for display
158160
@Published var capturedAIImage: UIImage? = nil
@@ -197,6 +199,7 @@ final class CarbEntryViewModel: ObservableObject {
197199
observeFavoriteFoodIndexChange()
198200
observeLoopUpdates()
199201
observeNumberOfServingsChange()
202+
observeAIExclusionsChange()
200203
setupFoodSearchObservers()
201204
}
202205

@@ -219,6 +222,7 @@ final class CarbEntryViewModel: ObservableObject {
219222
observeFavoriteFoodIndexChange()
220223
observeLoopUpdates()
221224
observeNumberOfServingsChange()
225+
observeAIExclusionsChange()
222226
setupFoodSearchObservers()
223227
}
224228

@@ -323,6 +327,29 @@ final class CarbEntryViewModel: ObservableObject {
323327
// Explicitly persist to avoid race with other view models' sinks
324328
UserDefaults.standard.writeFavoriteFoods(favoriteFoods)
325329
selectedFavoriteFoodIndex = favoriteFoods.count - 1
330+
331+
// Save thumbnail if we have an AI-captured image
332+
if let image = capturedAIImage {
333+
if let id = FavoriteFoodImageStore.saveThumbnail(from: image) {
334+
var map = UserDefaults.standard.favoriteFoodImageIDs
335+
map[newStoredFood.id] = id
336+
UserDefaults.standard.favoriteFoodImageIDs = map
337+
}
338+
} else if let product = selectedFoodProduct {
339+
// Attempt to fetch a thumbnail from product image URLs (text/barcode flows)
340+
let urlStrings: [String] = [product.imageFrontURL, product.imageURL].compactMap { $0 }
341+
if let firstURLString = urlStrings.first, let firstURL = URL(string: firstURLString) {
342+
Task {
343+
if let thumb = await ImageDownloader.fetchThumbnail(from: firstURL) {
344+
if let id = FavoriteFoodImageStore.saveThumbnail(from: thumb) {
345+
var map = UserDefaults.standard.favoriteFoodImageIDs
346+
map[newStoredFood.id] = id
347+
UserDefaults.standard.favoriteFoodImageIDs = map
348+
}
349+
}
350+
}
351+
}
352+
}
326353
}
327354

328355
private func observeFavoriteFoodIndexChange() {
@@ -450,9 +477,45 @@ final class CarbEntryViewModel: ObservableObject {
450477
.sink { [weak self] servings in
451478
print("🥄 numberOfServings changed to: \(servings), recalculating nutrition...")
452479
self?.recalculateCarbsForServings(servings)
480+
self?.recomputeAIAdjustments()
481+
}
482+
.store(in: &cancellables)
483+
}
484+
485+
private func observeAIExclusionsChange() {
486+
$excludedAIItemIndices
487+
.receive(on: RunLoop.main)
488+
.sink { [weak self] _ in
489+
self?.recomputeAIAdjustments()
490+
}
491+
.store(in: &cancellables)
492+
$lastAIAnalysisResult
493+
.receive(on: RunLoop.main)
494+
.sink { [weak self] _ in
495+
self?.recomputeAIAdjustments()
453496
}
454497
.store(in: &cancellables)
455498
}
499+
500+
// Recompute carbs and absorption time based on included AI items
501+
func recomputeAIAdjustments() {
502+
guard let ai = lastAIAnalysisResult else { return }
503+
let included = ai.foodItemsDetailed.enumerated()
504+
.filter { !excludedAIItemIndices.contains($0.offset) }
505+
.map { $0.element }
506+
// Carbs
507+
let baseCarbs = included.reduce(0.0) { $0 + $1.carbohydrates }
508+
let scale = ai.originalServings > 0 ? (numberOfServings / ai.originalServings) : 1.0
509+
let newCarbs = baseCarbs * scale
510+
self.carbsQuantity = newCarbs
511+
512+
// Absorption time: use overall AI time if present (per-item times not available)
513+
if let hours = ai.absorptionTimeHours, hours > 0 {
514+
self.absorptionEditIsProgrammatic = true
515+
self.absorptionTime = TimeInterval(hours * 3600)
516+
self.absorptionTimeWasAIGenerated = true
517+
}
518+
}
456519
}
457520

458521
// MARK: - OpenFoodFacts Food Search Extension
@@ -1592,7 +1655,8 @@ extension CarbEntryViewModel {
15921655
fat: fat,
15931656
fiber: nil,
15941657
protein: protein,
1595-
assessmentNotes: "Text-based nutrition lookup using Google Gemini"
1658+
assessmentNotes: "Text-based nutrition lookup using Google Gemini",
1659+
absorptionTimeHours: nil
15961660
)
15971661

15981662
return AIFoodAnalysisResult(

Loop/View Models/FavoriteFoodsViewModel.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ final class FavoriteFoodsViewModel: ObservableObject {
4343
// Explicitly persist after add
4444
UserDefaults.standard.writeFavoriteFoods(favoriteFoods)
4545
isAddViewActive = false
46+
// Attempt to use any last AI image from carb entry context is not available here;
47+
// List view additions do not capture images, so we skip thumbnail here.
4648
}
4749
else if var selectedFood, let selectedFooxIndex = favoriteFoods.firstIndex(of: selectedFood) {
4850
selectedFood.name = newFood.name
@@ -65,6 +67,13 @@ final class FavoriteFoodsViewModel: ObservableObject {
6567
}
6668
// Explicitly persist after delete
6769
UserDefaults.standard.writeFavoriteFoods(favoriteFoods)
70+
// Remove thumbnail mapping and file if present
71+
var map = UserDefaults.standard.favoriteFoodImageIDs
72+
if let id = map[food.id] {
73+
FavoriteFoodImageStore.deleteThumbnail(id: id)
74+
map.removeValue(forKey: food.id)
75+
UserDefaults.standard.favoriteFoodImageIDs = map
76+
}
6877
}
6978

7079
func onFoodReorder(from: IndexSet, to: Int) {

0 commit comments

Comments
 (0)