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
|
// PlantNet API key for plant identification
|
||||||
// In production, this should be injected by your CI/CD pipeline
|
// 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
|
// Configure CloudKit container
|
||||||
storeDescription?.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
|
storeDescription?.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
|
||||||
containerIdentifier: "iCloud.com.t-t.PlantGuide"
|
containerIdentifier: "iCloud.com.88oakapps.PlantGuide"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Configure view context for main thread usage
|
// Configure view context for main thread usage
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct PlantGuideApp: App {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
@State private var appearanceManager = AppearanceManager()
|
@State private var appearanceManager = AppearanceManager()
|
||||||
|
@State private var tabSelection = TabSelection()
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ struct PlantGuideApp: App {
|
|||||||
WindowGroup {
|
WindowGroup {
|
||||||
MainTabView()
|
MainTabView()
|
||||||
.environment(appearanceManager)
|
.environment(appearanceManager)
|
||||||
|
.environment(tabSelection)
|
||||||
.preferredColorScheme(appearanceManager.colorScheme)
|
.preferredColorScheme(appearanceManager.colorScheme)
|
||||||
.task {
|
.task {
|
||||||
await initializeDefaultRooms()
|
await initializeDefaultRooms()
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ enum Tab: String, CaseIterable {
|
|||||||
case settings
|
case settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Observable class for sharing tab selection across the app
|
||||||
|
@MainActor @Observable
|
||||||
|
final class TabSelection {
|
||||||
|
var selectedTab: Tab = .camera
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@State private var selectedTab: Tab = .camera
|
@Environment(TabSelection.self) private var tabSelection
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
@Bindable var tabSelection = tabSelection
|
||||||
|
TabView(selection: $tabSelection.selectedTab) {
|
||||||
CameraView()
|
CameraView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Camera", systemImage: "camera.fill")
|
Label("Camera", systemImage: "camera.fill")
|
||||||
@@ -43,4 +50,5 @@ struct MainTabView: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
MainTabView()
|
MainTabView()
|
||||||
|
.environment(TabSelection())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct CameraView: View {
|
|||||||
|
|
||||||
@State private var viewModel = CameraViewModel()
|
@State private var viewModel = CameraViewModel()
|
||||||
@State private var showIdentification = false
|
@State private var showIdentification = false
|
||||||
|
@State private var isTransitioningToIdentification = false
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ struct CameraView: View {
|
|||||||
.onChange(of: showIdentification) { _, isShowing in
|
.onChange(of: showIdentification) { _, isShowing in
|
||||||
if !isShowing {
|
if !isShowing {
|
||||||
// When returning from identification, allow retaking
|
// When returning from identification, allow retaking
|
||||||
|
isTransitioningToIdentification = false
|
||||||
Task {
|
Task {
|
||||||
await viewModel.retakePhoto()
|
await viewModel.retakePhoto()
|
||||||
}
|
}
|
||||||
@@ -127,7 +129,11 @@ struct CameraView: View {
|
|||||||
.fill(Color.white)
|
.fill(Color.white)
|
||||||
.frame(width: 68, height: 68)
|
.frame(width: 68, height: 68)
|
||||||
}
|
}
|
||||||
|
.contentShape(Circle())
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
.contentShape(Circle())
|
||||||
.disabled(!viewModel.isCameraControlsEnabled)
|
.disabled(!viewModel.isCameraControlsEnabled)
|
||||||
.opacity(viewModel.isCameraControlsEnabled ? 1.0 : 0.5)
|
.opacity(viewModel.isCameraControlsEnabled ? 1.0 : 0.5)
|
||||||
.accessibilityLabel("Capture photo")
|
.accessibilityLabel("Capture photo")
|
||||||
@@ -159,21 +165,25 @@ struct CameraView: View {
|
|||||||
Image(systemName: "arrow.counterclockwise")
|
Image(systemName: "arrow.counterclockwise")
|
||||||
Text("Retake")
|
Text("Retake")
|
||||||
}
|
}
|
||||||
.font(.system(size: 17, weight: .medium))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
Capsule()
|
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")
|
.accessibilityLabel("Retake photo")
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, 60)
|
.safeAreaInset(edge: .top) { Color.clear.frame(height: 0) } // Ensure safe area is respected
|
||||||
|
.padding(.top, 16)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -184,18 +194,27 @@ struct CameraView: View {
|
|||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
|
isTransitioningToIdentification = true
|
||||||
showIdentification = true
|
showIdentification = true
|
||||||
} label: {
|
} label: {
|
||||||
Text("Use Photo")
|
HStack(spacing: 8) {
|
||||||
.font(.system(size: 17, weight: .semibold))
|
if isTransitioningToIdentification {
|
||||||
.foregroundColor(.black)
|
ProgressView()
|
||||||
.frame(maxWidth: .infinity)
|
.progressViewStyle(CircularProgressViewStyle(tint: .black))
|
||||||
.padding(.vertical, 16)
|
.scaleEffect(0.8)
|
||||||
.background(
|
}
|
||||||
RoundedRectangle(cornerRadius: 14)
|
Text(isTransitioningToIdentification ? "Preparing..." : "Use Photo")
|
||||||
.fill(Color.white)
|
}
|
||||||
)
|
.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)
|
.padding(.horizontal, 20)
|
||||||
.accessibilityLabel("Use this photo")
|
.accessibilityLabel("Use this photo")
|
||||||
.accessibilityHint("Proceeds to plant identification with 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
|
/// Saves the selected prediction to the user's collection
|
||||||
func saveToCollection() {
|
/// - Parameter customName: Optional custom name for the plant. If nil, uses the prediction's common or species name.
|
||||||
guard let prediction = selectedPrediction else { return }
|
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 {
|
guard let savePlantUseCase = savePlantUseCase else {
|
||||||
saveState = .error("Save functionality not available")
|
saveState = .error("Save functionality not available. Please restart the app.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await performSave(prediction: prediction, useCase: savePlantUseCase)
|
await performSave(prediction: prediction, customName: customName, useCase: savePlantUseCase)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs the async save operation
|
/// Performs the async save operation
|
||||||
private func performSave(
|
private func performSave(
|
||||||
prediction: ViewPlantPrediction,
|
prediction: ViewPlantPrediction,
|
||||||
|
customName: String?,
|
||||||
useCase: SavePlantUseCaseProtocol
|
useCase: SavePlantUseCaseProtocol
|
||||||
) async {
|
) async {
|
||||||
saveState = .saving
|
saveState = .saving
|
||||||
|
|
||||||
// Convert prediction to Plant entity
|
// Convert prediction to Plant entity
|
||||||
let plant = PredictionToPlantMapper.mapToPlant(
|
var plant = PredictionToPlantMapper.mapToPlant(
|
||||||
from: prediction,
|
from: prediction,
|
||||||
localDatabaseMatch: localDatabaseMatch
|
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 {
|
do {
|
||||||
_ = try await useCase.execute(
|
_ = try await useCase.execute(
|
||||||
plant: plant,
|
plant: plant,
|
||||||
@@ -392,8 +404,7 @@ final class IdentificationViewModel {
|
|||||||
preferences: nil
|
preferences: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
let plantName = prediction.commonName ?? prediction.speciesName
|
saveState = .success(plantName: plant.displayName)
|
||||||
saveState = .success(plantName: plantName)
|
|
||||||
} catch let error as SavePlantError {
|
} catch let error as SavePlantError {
|
||||||
saveState = .error(error.localizedDescription ?? "Failed to save plant")
|
saveState = .error(error.localizedDescription ?? "Failed to save plant")
|
||||||
} catch {
|
} 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]
|
let tasks: [CareTask]
|
||||||
var onTaskComplete: ((CareTask) -> Void)?
|
var onTaskComplete: ((CareTask) -> Void)?
|
||||||
|
var onSeeAll: (() -> Void)?
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ struct UpcomingTasksSection: View {
|
|||||||
|
|
||||||
if tasks.count > 3 {
|
if tasks.count > 3 {
|
||||||
Button {
|
Button {
|
||||||
// TODO: Navigate to full task list
|
onSeeAll?()
|
||||||
} label: {
|
} label: {
|
||||||
Text("See All")
|
Text("See All")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ final class PlantDetailViewModel {
|
|||||||
careSchedule?.pendingTasks.prefix(5).map { $0 } ?? []
|
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)
|
/// The display name for the plant (common name or scientific name)
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
plant.commonNames.first ?? plant.scientificName
|
plant.commonNames.first ?? plant.scientificName
|
||||||
@@ -332,4 +337,38 @@ final class PlantDetailViewModel {
|
|||||||
self.error = error
|
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 {
|
.refreshable {
|
||||||
await viewModel.loadRooms()
|
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
|
RoomEditorView(mode: .create) { name, icon in
|
||||||
await viewModel.createRoom(name: name, icon: icon)
|
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
|
RoomEditorView(mode: .edit(room)) { name, icon in
|
||||||
var updatedRoom = room
|
var updatedRoom = room
|
||||||
updatedRoom.name = name
|
updatedRoom.name = name
|
||||||
|
|||||||
@@ -192,9 +192,11 @@ final class SettingsViewModel {
|
|||||||
/// Minimum confidence threshold for identification results (0.5 - 0.95)
|
/// Minimum confidence threshold for identification results (0.5 - 0.95)
|
||||||
var minimumConfidence: Double {
|
var minimumConfidence: Double {
|
||||||
didSet {
|
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 {
|
if clampedValue != minimumConfidence {
|
||||||
minimumConfidence = clampedValue
|
minimumConfidence = clampedValue
|
||||||
|
return // Avoid double-save when clamping triggers another didSet
|
||||||
}
|
}
|
||||||
userDefaults.set(minimumConfidence, forKey: SettingsKeys.minimumConfidence)
|
userDefaults.set(minimumConfidence, forKey: SettingsKeys.minimumConfidence)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user