From 681476a499ef0e64796f995a77ab94b178597a9b Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 31 Jan 2026 22:50:04 -0600 Subject: [PATCH] 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 --- PlantGuide/Configuration/Release.xcconfig | 8 +- .../Local/CoreData/CoreDataStack.swift | 2 +- PlantGuide/PlantGuideApp.swift | 2 + .../Presentation/Navigation/MainTabView.swift | 12 +- .../Scenes/Camera/CameraView.swift | 43 +++-- .../IdentificationViewModel.swift | 25 ++- .../PlantDetail/Components/AllTasksView.swift | 158 ++++++++++++++++++ .../Components/UpcomingTasksSection.swift | 3 +- .../PlantDetail/PlantDetailViewModel.swift | 39 +++++ .../Scenes/PlantDetail/PlantEditView.swift | 150 +++++++++++++++++ .../Scenes/Rooms/RoomsListView.swift | 14 +- .../Scenes/Settings/SettingsViewModel.swift | 4 +- 12 files changed, 433 insertions(+), 27 deletions(-) create mode 100644 PlantGuide/Presentation/Scenes/PlantDetail/Components/AllTasksView.swift create mode 100644 PlantGuide/Presentation/Scenes/PlantDetail/PlantEditView.swift diff --git a/PlantGuide/Configuration/Release.xcconfig b/PlantGuide/Configuration/Release.xcconfig index 86a8bc3..a0d3c82 100644 --- a/PlantGuide/Configuration/Release.xcconfig +++ b/PlantGuide/Configuration/Release.xcconfig @@ -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 diff --git a/PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift b/PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift index d92eda6..8bdb29c 100644 --- a/PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift +++ b/PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift @@ -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 diff --git a/PlantGuide/PlantGuideApp.swift b/PlantGuide/PlantGuideApp.swift index 5f20cdd..587e45e 100644 --- a/PlantGuide/PlantGuideApp.swift +++ b/PlantGuide/PlantGuideApp.swift @@ -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() diff --git a/PlantGuide/Presentation/Navigation/MainTabView.swift b/PlantGuide/Presentation/Navigation/MainTabView.swift index f83baed..cd0472b 100644 --- a/PlantGuide/Presentation/Navigation/MainTabView.swift +++ b/PlantGuide/Presentation/Navigation/MainTabView.swift @@ -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()) } diff --git a/PlantGuide/Presentation/Scenes/Camera/CameraView.swift b/PlantGuide/Presentation/Scenes/Camera/CameraView.swift index 3dbaa92..0772d95 100644 --- a/PlantGuide/Presentation/Scenes/Camera/CameraView.swift +++ b/PlantGuide/Presentation/Scenes/Camera/CameraView.swift @@ -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") diff --git a/PlantGuide/Presentation/Scenes/Identification/IdentificationViewModel.swift b/PlantGuide/Presentation/Scenes/Identification/IdentificationViewModel.swift index 721d756..b2fe787 100644 --- a/PlantGuide/Presentation/Scenes/Identification/IdentificationViewModel.swift +++ b/PlantGuide/Presentation/Scenes/Identification/IdentificationViewModel.swift @@ -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 { diff --git a/PlantGuide/Presentation/Scenes/PlantDetail/Components/AllTasksView.swift b/PlantGuide/Presentation/Scenes/PlantDetail/Components/AllTasksView.swift new file mode 100644 index 0000000..b026622 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/PlantDetail/Components/AllTasksView.swift @@ -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: []) + } +} diff --git a/PlantGuide/Presentation/Scenes/PlantDetail/Components/UpcomingTasksSection.swift b/PlantGuide/Presentation/Scenes/PlantDetail/Components/UpcomingTasksSection.swift index c3c5a90..1a210c8 100644 --- a/PlantGuide/Presentation/Scenes/PlantDetail/Components/UpcomingTasksSection.swift +++ b/PlantGuide/Presentation/Scenes/PlantDetail/Components/UpcomingTasksSection.swift @@ -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) diff --git a/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift b/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift index f01ea9a..30ee049 100644 --- a/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift +++ b/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift @@ -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 + } + } } diff --git a/PlantGuide/Presentation/Scenes/PlantDetail/PlantEditView.swift b/PlantGuide/Presentation/Scenes/PlantDetail/PlantEditView.swift new file mode 100644 index 0000000..854ebb5 --- /dev/null +++ b/PlantGuide/Presentation/Scenes/PlantDetail/PlantEditView.swift @@ -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")") + } +} diff --git a/PlantGuide/Presentation/Scenes/Rooms/RoomsListView.swift b/PlantGuide/Presentation/Scenes/Rooms/RoomsListView.swift index 72d1644..769674f 100644 --- a/PlantGuide/Presentation/Scenes/Rooms/RoomsListView.swift +++ b/PlantGuide/Presentation/Scenes/Rooms/RoomsListView.swift @@ -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 diff --git a/PlantGuide/Presentation/Scenes/Settings/SettingsViewModel.swift b/PlantGuide/Presentation/Scenes/Settings/SettingsViewModel.swift index 5a9f11c..ecd435e 100644 --- a/PlantGuide/Presentation/Scenes/Settings/SettingsViewModel.swift +++ b/PlantGuide/Presentation/Scenes/Settings/SettingsViewModel.swift @@ -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) }