Files
PlantGuide/PlantGuide/Presentation/Scenes/Settings/SettingsViewModel.swift
Trey t 681476a499 WIP: Various UI and feature improvements
- Add AllTasksView and PlantEditView components
- Update CoreDataStack CloudKit container ID
- Improve CameraView and IdentificationViewModel
- Update MainTabView, RoomsListView, UpcomingTasksSection
- Minor fixes to PlantGuideApp and SettingsViewModel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:50:04 -06:00

487 lines
15 KiB
Swift

//
// SettingsViewModel.swift
// PlantGuide
//
// Created on 2026-01-21.
//
import Foundation
import SwiftUI
// MARK: - IdentificationMethod
/// Represents the preferred method for plant identification.
enum IdentificationMethod: String, CaseIterable, Sendable {
/// Use both on-device ML and API, preferring on-device
case hybrid
/// Use only on-device ML model
case onDevice
/// Use only PlantNet API
case apiOnly
/// Display name for the method
var displayName: String {
switch self {
case .hybrid:
return "Hybrid (Recommended)"
case .onDevice:
return "On-Device Only"
case .apiOnly:
return "API Only"
}
}
/// Description of how the method works
var description: String {
switch self {
case .hybrid:
return "Uses on-device ML first, falls back to API for better accuracy"
case .onDevice:
return "Works offline, faster but less accurate"
case .apiOnly:
return "Most accurate, requires internet connection"
}
}
}
// MARK: - APIStatus
/// Represents the status of an external API service.
enum APIStatus: String, Sendable {
/// Status has not been checked yet
case unknown
/// API is available and responding
case available
/// API is unavailable or erroring
case unavailable
/// SF Symbol name for the status
var iconName: String {
switch self {
case .unknown:
return "questionmark.circle"
case .available:
return "checkmark.circle.fill"
case .unavailable:
return "xmark.circle.fill"
}
}
/// Color for the status indicator
var color: Color {
switch self {
case .unknown:
return .secondary
case .available:
return .green
case .unavailable:
return .red
}
}
/// Display text for the status
var displayText: String {
switch self {
case .unknown:
return "Unknown"
case .available:
return "Available"
case .unavailable:
return "Unavailable"
}
}
}
// MARK: - APIQuota
/// Represents the usage quota for an API service.
struct APIQuota: Sendable, Equatable {
/// Number of requests used in the current period
let used: Int
/// Maximum number of requests allowed in the period
let limit: Int
/// When the quota resets
let resetsAt: Date
/// Percentage of quota used (0.0 - 1.0)
var usagePercentage: Double {
guard limit > 0 else { return 0 }
return Double(used) / Double(limit)
}
/// Number of requests remaining
var remaining: Int {
max(0, limit - used)
}
/// Formatted string showing usage
var formattedUsage: String {
"\(remaining) / \(limit) remaining"
}
/// Formatted time until reset
var formattedResetTime: String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: resetsAt, relativeTo: Date())
}
/// Creates an unknown/default quota
static let unknown = APIQuota(used: 0, limit: 500, resetsAt: Date())
}
// MARK: - UserDefaults Keys
private enum SettingsKeys {
static let offlineModeEnabled = "settings_offline_mode_enabled"
static let preferredIdentificationMethod = "settings_preferred_identification_method"
static let minimumConfidence = "settings_minimum_confidence"
static let notificationTimeHour = "settings_notification_time_hour"
static let notificationTimeMinute = "settings_notification_time_minute"
}
// MARK: - SettingsViewModel
/// View model for the Settings screen.
///
/// Manages user preferences, cache information, and API status for the
/// PlantGuide app. All settings are persisted to UserDefaults.
///
/// ## Example Usage
/// ```swift
/// @State private var viewModel = SettingsViewModel()
///
/// var body: some View {
/// SettingsView()
/// .environment(viewModel)
/// }
/// ```
@MainActor
@Observable
final class SettingsViewModel {
// MARK: - Dependencies
private let networkMonitor: NetworkMonitor
private let imageCache: ImageCacheProtocol
private let identificationCache: IdentificationCacheProtocol
private let rateLimitTracker: RateLimitTrackerProtocol
private let coreDataStack: CoreDataStack
private let imageStorage: LocalImageStorage
private let userDefaults: UserDefaults
/// Observation token for rate limit update notifications
nonisolated(unsafe) private var rateLimitObservation: (any NSObjectProtocol)?
// MARK: - Settings Properties
/// Whether offline mode is enabled (disables API calls)
var offlineModeEnabled: Bool {
didSet {
userDefaults.set(offlineModeEnabled, forKey: SettingsKeys.offlineModeEnabled)
}
}
/// The preferred method for plant identification
var preferredIdentificationMethod: IdentificationMethod {
didSet {
userDefaults.set(preferredIdentificationMethod.rawValue, forKey: SettingsKeys.preferredIdentificationMethod)
}
}
/// Minimum confidence threshold for identification results (0.5 - 0.95)
var minimumConfidence: Double {
didSet {
// Clamp the new value to valid range
let clampedValue = min(max(minimumConfidence, 0.5), 0.95)
if clampedValue != minimumConfidence {
minimumConfidence = clampedValue
return // Avoid double-save when clamping triggers another didSet
}
userDefaults.set(minimumConfidence, forKey: SettingsKeys.minimumConfidence)
}
}
/// The time of day for care reminder notifications
var notificationTime: Date {
didSet {
let calendar = Calendar.current
let components = calendar.dateComponents([.hour, .minute], from: notificationTime)
userDefaults.set(components.hour ?? 8, forKey: SettingsKeys.notificationTimeHour)
userDefaults.set(components.minute ?? 0, forKey: SettingsKeys.notificationTimeMinute)
}
}
// MARK: - Cache Properties
/// Size of the image cache in bytes
private(set) var imageCacheSize: Int64 = 0
/// Number of entries in the identification cache
private(set) var identificationCacheSize: Int = 0
/// Size of locally stored plant images in bytes
private(set) var localImageStorageSize: Int64 = 0
// MARK: - API Status Properties
/// Status of the PlantNet API
private(set) var plantNetStatus: APIStatus = .unknown
/// Status of the Trefle API
private(set) var trefleStatus: APIStatus = .unknown
/// PlantNet API quota information
private(set) var plantNetQuota: APIQuota = .unknown
// MARK: - Loading States
/// Whether API status is being checked
private(set) var isCheckingAPIStatus: Bool = false
/// Whether cache is being cleared
private(set) var isClearingCache: Bool = false
/// Whether all data is being deleted
private(set) var isDeletingAllData: Bool = false
/// Error message to display
private(set) var errorMessage: String?
// MARK: - Computed Properties
/// Whether the device has network connectivity
var isNetworkAvailable: Bool {
networkMonitor.isConnected
}
/// The current network connection type
var connectionType: ConnectionType {
networkMonitor.connectionType
}
/// The app version string (e.g., "1.0.0")
var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
}
/// The build number string (e.g., "42")
var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
}
/// The ML model version (placeholder for future implementation)
var mlModelVersion: String {
"PlantNet v1.0"
}
/// Formatted image cache size string
var formattedImageCacheSize: String {
ByteCountFormatter.string(fromByteCount: imageCacheSize, countStyle: .file)
}
/// Formatted local storage size string
var formattedLocalStorageSize: String {
ByteCountFormatter.string(fromByteCount: localImageStorageSize, countStyle: .file)
}
/// Formatted total storage size string
var formattedTotalStorageSize: String {
let total = imageCacheSize + localImageStorageSize
return ByteCountFormatter.string(fromByteCount: total, countStyle: .file)
}
/// Formatted minimum confidence as percentage
var formattedMinimumConfidence: String {
String(format: "%.0f%%", minimumConfidence * 100)
}
// MARK: - Initialization
/// Creates a new SettingsViewModel with the specified dependencies.
///
/// - Parameters:
/// - networkMonitor: The network monitor for connectivity status.
/// - imageCache: The image cache service.
/// - identificationCache: The identification cache service.
/// - rateLimitTracker: The rate limit tracker for API quotas.
/// - coreDataStack: The Core Data stack for data deletion.
/// - imageStorage: The local image storage service.
/// - userDefaults: UserDefaults for persisting settings.
init(
networkMonitor: NetworkMonitor? = nil,
imageCache: ImageCacheProtocol? = nil,
identificationCache: IdentificationCacheProtocol? = nil,
rateLimitTracker: RateLimitTrackerProtocol? = nil,
coreDataStack: CoreDataStack? = nil,
imageStorage: LocalImageStorage? = nil,
userDefaults: UserDefaults = .standard
) {
let container = DIContainer.shared
self.networkMonitor = networkMonitor ?? container.networkMonitor
self.imageCache = imageCache ?? container.imageCache
self.identificationCache = identificationCache ?? container.identificationCache
self.rateLimitTracker = rateLimitTracker ?? container.rateLimitTracker
self.coreDataStack = coreDataStack ?? CoreDataStack.shared
self.imageStorage = (imageStorage ?? container.imageStorage) as! LocalImageStorage
self.userDefaults = userDefaults
// Load persisted settings
self.offlineModeEnabled = userDefaults.bool(forKey: SettingsKeys.offlineModeEnabled)
if let methodString = userDefaults.string(forKey: SettingsKeys.preferredIdentificationMethod),
let method = IdentificationMethod(rawValue: methodString) {
self.preferredIdentificationMethod = method
} else {
self.preferredIdentificationMethod = .hybrid
}
let storedConfidence = userDefaults.double(forKey: SettingsKeys.minimumConfidence)
if storedConfidence >= 0.5 && storedConfidence <= 0.95 {
self.minimumConfidence = storedConfidence
} else {
self.minimumConfidence = 0.7 // Default value
}
// Load notification time (default to 8:00 AM)
let storedHour = userDefaults.object(forKey: SettingsKeys.notificationTimeHour) as? Int ?? 8
let storedMinute = userDefaults.object(forKey: SettingsKeys.notificationTimeMinute) as? Int ?? 0
var components = DateComponents()
components.hour = storedHour
components.minute = storedMinute
self.notificationTime = Calendar.current.date(from: components) ?? Date()
// Observe rate limit updates to refresh quota display
rateLimitObservation = NotificationCenter.default.addObserver(
forName: .rateLimitDidUpdate,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
await self?.checkAPIStatus()
}
}
}
deinit {
if let observation = rateLimitObservation {
NotificationCenter.default.removeObserver(observation)
}
}
// MARK: - Public Methods
/// Loads cache size information from all cache services.
func loadCacheInfo() async {
imageCacheSize = await imageCache.getCacheSize()
identificationCacheSize = await identificationCache.entryCount
localImageStorageSize = await imageStorage.getStorageSize()
}
/// Checks the status of external APIs.
///
/// Updates `plantNetStatus`, `trefleStatus`, and `plantNetQuota` based on
/// the current API availability and rate limit status.
func checkAPIStatus() async {
guard !isCheckingAPIStatus else { return }
isCheckingAPIStatus = true
errorMessage = nil
// Check PlantNet status via rate limit tracker
let canMakeRequest = await rateLimitTracker.canMakeRequest()
let remainingRequests = await rateLimitTracker.remainingRequests
let resetDate = await rateLimitTracker.resetDate
if canMakeRequest {
plantNetStatus = .available
} else {
plantNetStatus = .unavailable
}
// Update quota info
let usedRequests = 500 - remainingRequests // PlantNet free tier limit
plantNetQuota = APIQuota(
used: usedRequests,
limit: 500,
resetsAt: resetDate
)
// For Trefle, we assume available if we have network
// In a real implementation, you might ping the API
if isNetworkAvailable {
trefleStatus = .available
} else {
trefleStatus = .unavailable
}
isCheckingAPIStatus = false
}
/// Clears the image cache.
///
/// Removes all cached remote images. Local plant photos are preserved.
func clearImageCache() async {
guard !isClearingCache else { return }
isClearingCache = true
errorMessage = nil
await imageCache.clearAllCache()
await identificationCache.clear()
// Reload cache info
await loadCacheInfo()
isClearingCache = false
}
/// Deletes all app data including plants, images, and caches.
///
/// - Warning: This action is irreversible and removes all user data.
func deleteAllData() async {
guard !isDeletingAllData else { return }
isDeletingAllData = true
errorMessage = nil
do {
// Clear all caches
await imageCache.clearAllCache()
await identificationCache.clear()
// Reset Core Data
try coreDataStack.resetStack()
// Clear UserDefaults settings (except system ones)
let domain = Bundle.main.bundleIdentifier ?? "PlantGuide"
userDefaults.removePersistentDomain(forName: domain)
// Reload default settings
offlineModeEnabled = false
preferredIdentificationMethod = .hybrid
minimumConfidence = 0.7
// Reload cache info
await loadCacheInfo()
} catch {
errorMessage = "Failed to delete all data: \(error.localizedDescription)"
}
isDeletingAllData = false
}
/// Clears any displayed error message.
func clearError() {
errorMessage = nil
}
/// Called when the view appears.
func onAppear() async {
await loadCacheInfo()
await checkAPIStatus()
}
}