- 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>
487 lines
15 KiB
Swift
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()
|
|
}
|
|
}
|