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>
This commit is contained in:
@@ -13,4 +13,10 @@
|
||||
|
||||
// PlantNet API key for plant identification
|
||||
// In production, this should be injected by your CI/CD pipeline
|
||||
PLANTNET_API_KEY = your_api_key_here
|
||||
// PlantNet API key for plant identification
|
||||
// Get your key from: https://my.plantnet.org/
|
||||
PLANTNET_API_KEY = 2b10NEGntgT5U4NYWukK63Lu
|
||||
|
||||
// Trefle API token for plant care data
|
||||
// Get your token from: https://trefle.io/
|
||||
TREFLE_API_TOKEN = usr-AfrMS_o4qJ3ZBYML9upiz8UQ8Uv4cJ_tQgkXsK4xt_E
|
||||
|
||||
@@ -221,7 +221,7 @@ final class CoreDataStack: CoreDataStackProtocol, @unchecked Sendable {
|
||||
|
||||
// Configure CloudKit container
|
||||
storeDescription?.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
|
||||
containerIdentifier: "iCloud.com.t-t.PlantGuide"
|
||||
containerIdentifier: "iCloud.com.88oakapps.PlantGuide"
|
||||
)
|
||||
|
||||
// Configure view context for main thread usage
|
||||
|
||||
@@ -13,6 +13,7 @@ struct PlantGuideApp: App {
|
||||
// MARK: - Properties
|
||||
|
||||
@State private var appearanceManager = AppearanceManager()
|
||||
@State private var tabSelection = TabSelection()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -32,6 +33,7 @@ struct PlantGuideApp: App {
|
||||
WindowGroup {
|
||||
MainTabView()
|
||||
.environment(appearanceManager)
|
||||
.environment(tabSelection)
|
||||
.preferredColorScheme(appearanceManager.colorScheme)
|
||||
.task {
|
||||
await initializeDefaultRooms()
|
||||
|
||||
@@ -7,12 +7,19 @@ enum Tab: String, CaseIterable {
|
||||
case settings
|
||||
}
|
||||
|
||||
/// Observable class for sharing tab selection across the app
|
||||
@MainActor @Observable
|
||||
final class TabSelection {
|
||||
var selectedTab: Tab = .camera
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab: Tab = .camera
|
||||
@Environment(TabSelection.self) private var tabSelection
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
@Bindable var tabSelection = tabSelection
|
||||
TabView(selection: $tabSelection.selectedTab) {
|
||||
CameraView()
|
||||
.tabItem {
|
||||
Label("Camera", systemImage: "camera.fill")
|
||||
@@ -43,4 +50,5 @@ struct MainTabView: View {
|
||||
|
||||
#Preview {
|
||||
MainTabView()
|
||||
.environment(TabSelection())
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ struct CameraView: View {
|
||||
|
||||
@State private var viewModel = CameraViewModel()
|
||||
@State private var showIdentification = false
|
||||
@State private var isTransitioningToIdentification = false
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
@@ -60,6 +61,7 @@ struct CameraView: View {
|
||||
.onChange(of: showIdentification) { _, isShowing in
|
||||
if !isShowing {
|
||||
// When returning from identification, allow retaking
|
||||
isTransitioningToIdentification = false
|
||||
Task {
|
||||
await viewModel.retakePhoto()
|
||||
}
|
||||
@@ -127,7 +129,11 @@ struct CameraView: View {
|
||||
.fill(Color.white)
|
||||
.frame(width: 68, height: 68)
|
||||
}
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 80, height: 80)
|
||||
.contentShape(Circle())
|
||||
.disabled(!viewModel.isCameraControlsEnabled)
|
||||
.opacity(viewModel.isCameraControlsEnabled ? 1.0 : 0.5)
|
||||
.accessibilityLabel("Capture photo")
|
||||
@@ -159,21 +165,25 @@ struct CameraView: View {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
Text("Retake")
|
||||
}
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(0.5))
|
||||
.fill(Color.black.opacity(0.7))
|
||||
.shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Capsule())
|
||||
.accessibilityLabel("Retake photo")
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 60)
|
||||
.safeAreaInset(edge: .top) { Color.clear.frame(height: 0) } // Ensure safe area is respected
|
||||
.padding(.top, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -184,18 +194,27 @@ struct CameraView: View {
|
||||
.foregroundColor(.white)
|
||||
|
||||
Button {
|
||||
isTransitioningToIdentification = true
|
||||
showIdentification = true
|
||||
} label: {
|
||||
Text("Use Photo")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(.black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color.white)
|
||||
)
|
||||
HStack(spacing: 8) {
|
||||
if isTransitioningToIdentification {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .black))
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
Text(isTransitioningToIdentification ? "Preparing..." : "Use Photo")
|
||||
}
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(.black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color.white)
|
||||
)
|
||||
}
|
||||
.disabled(isTransitioningToIdentification)
|
||||
.padding(.horizontal, 20)
|
||||
.accessibilityLabel("Use this photo")
|
||||
.accessibilityHint("Proceeds to plant identification with this photo")
|
||||
|
||||
@@ -359,31 +359,43 @@ final class IdentificationViewModel {
|
||||
}
|
||||
|
||||
/// Saves the selected prediction to the user's collection
|
||||
func saveToCollection() {
|
||||
guard let prediction = selectedPrediction else { return }
|
||||
/// - Parameter customName: Optional custom name for the plant. If nil, uses the prediction's common or species name.
|
||||
func saveToCollection(customName: String? = nil) {
|
||||
guard let prediction = selectedPrediction else {
|
||||
saveState = .error("No plant selected. Please select a plant to save.")
|
||||
return
|
||||
}
|
||||
guard let savePlantUseCase = savePlantUseCase else {
|
||||
saveState = .error("Save functionality not available")
|
||||
saveState = .error("Save functionality not available. Please restart the app.")
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await performSave(prediction: prediction, useCase: savePlantUseCase)
|
||||
await performSave(prediction: prediction, customName: customName, useCase: savePlantUseCase)
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs the async save operation
|
||||
private func performSave(
|
||||
prediction: ViewPlantPrediction,
|
||||
customName: String?,
|
||||
useCase: SavePlantUseCaseProtocol
|
||||
) async {
|
||||
saveState = .saving
|
||||
|
||||
// Convert prediction to Plant entity
|
||||
let plant = PredictionToPlantMapper.mapToPlant(
|
||||
var plant = PredictionToPlantMapper.mapToPlant(
|
||||
from: prediction,
|
||||
localDatabaseMatch: localDatabaseMatch
|
||||
)
|
||||
|
||||
// Apply custom name if provided (different from default display name)
|
||||
let trimmedName = customName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let defaultName = prediction.commonName ?? prediction.speciesName
|
||||
if let customName = trimmedName, !customName.isEmpty, customName != defaultName {
|
||||
plant.customName = customName
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await useCase.execute(
|
||||
plant: plant,
|
||||
@@ -392,8 +404,7 @@ final class IdentificationViewModel {
|
||||
preferences: nil
|
||||
)
|
||||
|
||||
let plantName = prediction.commonName ?? prediction.speciesName
|
||||
saveState = .success(plantName: plantName)
|
||||
saveState = .success(plantName: plant.displayName)
|
||||
} catch let error as SavePlantError {
|
||||
saveState = .error(error.localizedDescription ?? "Failed to save plant")
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//
|
||||
// AllTasksView.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-31.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - AllTasksView
|
||||
|
||||
/// Displays all pending care tasks for a plant
|
||||
struct AllTasksView: View {
|
||||
// MARK: - Properties
|
||||
|
||||
let plantName: String
|
||||
let tasks: [CareTask]
|
||||
var onTaskComplete: ((CareTask) -> Void)?
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if tasks.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
taskListView
|
||||
}
|
||||
}
|
||||
.navigationTitle("All Tasks")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
// MARK: - Task List View
|
||||
|
||||
private var taskListView: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Overdue tasks section
|
||||
let overdueTasks = tasks.filter { $0.isOverdue }
|
||||
if !overdueTasks.isEmpty {
|
||||
taskSection(title: "Overdue", tasks: overdueTasks, tintColor: .red)
|
||||
}
|
||||
|
||||
// Today's tasks
|
||||
let todayTasks = tasks.filter { !$0.isOverdue && Calendar.current.isDateInToday($0.scheduledDate) }
|
||||
if !todayTasks.isEmpty {
|
||||
taskSection(title: "Today", tasks: todayTasks, tintColor: .blue)
|
||||
}
|
||||
|
||||
// Upcoming tasks
|
||||
let upcomingTasks = tasks.filter { !$0.isOverdue && !Calendar.current.isDateInToday($0.scheduledDate) }
|
||||
if !upcomingTasks.isEmpty {
|
||||
taskSection(title: "Upcoming", tasks: upcomingTasks, tintColor: .secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Section
|
||||
|
||||
private func taskSection(title: String, tasks: [CareTask], tintColor: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(tintColor)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(tasks.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
ForEach(tasks) { task in
|
||||
TaskRow(task: task, onComplete: {
|
||||
onTaskComplete?(task)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
// MARK: - Empty State View
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "checkmark.circle")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(.green)
|
||||
|
||||
Text("All Caught Up!")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("No pending care tasks for \(plantName)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("All Tasks - With Tasks") {
|
||||
let samplePlantID = UUID()
|
||||
let sampleTasks = [
|
||||
CareTask(
|
||||
plantID: samplePlantID,
|
||||
type: .watering,
|
||||
scheduledDate: Date().addingTimeInterval(-86400), // 1 day ago (overdue)
|
||||
notes: "Check soil moisture"
|
||||
),
|
||||
CareTask(
|
||||
plantID: samplePlantID,
|
||||
type: .fertilizing,
|
||||
scheduledDate: Date(), // Today
|
||||
notes: ""
|
||||
),
|
||||
CareTask(
|
||||
plantID: samplePlantID,
|
||||
type: .pruning,
|
||||
scheduledDate: Date().addingTimeInterval(86400 * 3), // 3 days from now
|
||||
notes: "Remove dead leaves"
|
||||
),
|
||||
CareTask(
|
||||
plantID: samplePlantID,
|
||||
type: .watering,
|
||||
scheduledDate: Date().addingTimeInterval(86400 * 7), // 7 days from now
|
||||
notes: ""
|
||||
)
|
||||
]
|
||||
|
||||
return NavigationStack {
|
||||
AllTasksView(plantName: "Monstera", tasks: sampleTasks) { task in
|
||||
print("Completed task: \(task.type)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("All Tasks - Empty") {
|
||||
NavigationStack {
|
||||
AllTasksView(plantName: "Monstera", tasks: [])
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ struct UpcomingTasksSection: View {
|
||||
|
||||
let tasks: [CareTask]
|
||||
var onTaskComplete: ((CareTask) -> Void)?
|
||||
var onSeeAll: (() -> Void)?
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
@@ -29,7 +30,7 @@ struct UpcomingTasksSection: View {
|
||||
|
||||
if tasks.count > 3 {
|
||||
Button {
|
||||
// TODO: Navigate to full task list
|
||||
onSeeAll?()
|
||||
} label: {
|
||||
Text("See All")
|
||||
.font(.subheadline)
|
||||
|
||||
@@ -68,6 +68,11 @@ final class PlantDetailViewModel {
|
||||
careSchedule?.pendingTasks.prefix(5).map { $0 } ?? []
|
||||
}
|
||||
|
||||
/// All pending care tasks for the plant
|
||||
var allPendingTasks: [CareTask] {
|
||||
careSchedule?.pendingTasks ?? []
|
||||
}
|
||||
|
||||
/// The display name for the plant (common name or scientific name)
|
||||
var displayName: String {
|
||||
plant.commonNames.first ?? plant.scientificName
|
||||
@@ -332,4 +337,38 @@ final class PlantDetailViewModel {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Plant Editing
|
||||
|
||||
/// Updates the plant's custom name and notes
|
||||
/// - Parameters:
|
||||
/// - customName: The new custom name, or nil to clear it
|
||||
/// - notes: The new notes, or nil to clear them
|
||||
func updatePlant(customName: String?, notes: String?) async {
|
||||
plant.customName = customName
|
||||
plant.notes = notes
|
||||
|
||||
do {
|
||||
_ = try await DIContainer.shared.updatePlantUseCase.execute(plant: plant)
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Plant Deletion
|
||||
|
||||
/// Deletes the plant from the collection
|
||||
///
|
||||
/// This removes the plant and all associated data including:
|
||||
/// - Images stored locally
|
||||
/// - Care schedules and tasks
|
||||
/// - Scheduled notifications
|
||||
/// - Cached care information
|
||||
func deletePlant() async {
|
||||
do {
|
||||
try await DIContainer.shared.deletePlantUseCase.execute(plantID: plant.id)
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
150
PlantGuide/Presentation/Scenes/PlantDetail/PlantEditView.swift
Normal file
150
PlantGuide/Presentation/Scenes/PlantDetail/PlantEditView.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// PlantEditView.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-31.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - PlantEditView
|
||||
|
||||
/// A view for editing plant details like custom name and notes.
|
||||
@MainActor
|
||||
struct PlantEditView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// The plant being edited
|
||||
let plant: Plant
|
||||
|
||||
/// Callback when save is tapped
|
||||
let onSave: (String?, String?) async -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var customName: String
|
||||
@State private var notes: String
|
||||
@State private var isSaving = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(plant: Plant, onSave: @escaping (String?, String?) async -> Void) {
|
||||
self.plant = plant
|
||||
self.onSave = onSave
|
||||
_customName = State(initialValue: plant.customName ?? plant.commonNames.first ?? plant.scientificName)
|
||||
_notes = State(initialValue: plant.notes ?? "")
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var isValid: Bool {
|
||||
!customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Plant Name", text: $customName)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("Name")
|
||||
} footer: {
|
||||
Text("Give your plant a custom name to easily identify it.")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextEditor(text: $notes)
|
||||
.frame(minHeight: 100)
|
||||
} header: {
|
||||
Text("Notes")
|
||||
} footer: {
|
||||
Text("Add any notes about your plant, such as where you got it or special care instructions.")
|
||||
}
|
||||
|
||||
Section {
|
||||
plantInfoRow
|
||||
} header: {
|
||||
Text("Plant Info")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Plant")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
isSaving = true
|
||||
Task {
|
||||
let trimmedName = customName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
await onSave(
|
||||
trimmedName.isEmpty ? nil : trimmedName,
|
||||
trimmedNotes.isEmpty ? nil : trimmedNotes
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.disabled(!isValid || isSaving)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(isSaving)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Plant Info Row
|
||||
|
||||
private var plantInfoRow: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Scientific Name")
|
||||
Spacer()
|
||||
Text(plant.scientificName)
|
||||
.foregroundStyle(.secondary)
|
||||
.italic()
|
||||
}
|
||||
|
||||
if !plant.commonNames.isEmpty {
|
||||
HStack {
|
||||
Text("Common Name")
|
||||
Spacer()
|
||||
Text(plant.commonNames.first ?? "")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Family")
|
||||
Spacer()
|
||||
Text(plant.family)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
PlantEditView(
|
||||
plant: Plant(
|
||||
scientificName: "Monstera deliciosa",
|
||||
commonNames: ["Swiss Cheese Plant"],
|
||||
family: "Araceae",
|
||||
genus: "Monstera",
|
||||
imageURLs: [],
|
||||
identificationSource: .onDeviceML,
|
||||
notes: "Got this from the local nursery",
|
||||
customName: "My Monstera"
|
||||
)
|
||||
) { name, notes in
|
||||
print("Save: \(name ?? "nil"), \(notes ?? "nil")")
|
||||
}
|
||||
}
|
||||
@@ -68,12 +68,22 @@ struct RoomsListView: View {
|
||||
.refreshable {
|
||||
await viewModel.loadRooms()
|
||||
}
|
||||
.sheet(isPresented: $showCreateSheet) {
|
||||
.sheet(isPresented: $showCreateSheet, onDismiss: {
|
||||
// Reload rooms after create to ensure UI reflects changes
|
||||
Task {
|
||||
await viewModel.loadRooms()
|
||||
}
|
||||
}) {
|
||||
RoomEditorView(mode: .create) { name, icon in
|
||||
await viewModel.createRoom(name: name, icon: icon)
|
||||
}
|
||||
}
|
||||
.sheet(item: $viewModel.selectedRoom) { room in
|
||||
.sheet(item: $viewModel.selectedRoom, onDismiss: {
|
||||
// Reload rooms after edit to ensure UI reflects changes
|
||||
Task {
|
||||
await viewModel.loadRooms()
|
||||
}
|
||||
}) { room in
|
||||
RoomEditorView(mode: .edit(room)) { name, icon in
|
||||
var updatedRoom = room
|
||||
updatedRoom.name = name
|
||||
|
||||
@@ -192,9 +192,11 @@ final class SettingsViewModel {
|
||||
/// Minimum confidence threshold for identification results (0.5 - 0.95)
|
||||
var minimumConfidence: Double {
|
||||
didSet {
|
||||
let clampedValue = min(max(oldValue, 0.5), 0.95)
|
||||
// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user