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:
Trey t
2026-01-31 22:50:04 -06:00
parent fef9552b22
commit 681476a499
12 changed files with 433 additions and 27 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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())
}

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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: [])
}
}

View File

@@ -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)

View File

@@ -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
}
}
}

View 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")")
}
}

View File

@@ -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

View File

@@ -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)
}