Files
PlantGuide/Docs/Phase6_plan.md
Trey t 136dfbae33 Add PlantGuide iOS app with plant identification and care management
- Implement camera capture and plant identification workflow
- Add Core Data persistence for plants, care schedules, and cached API data
- Create collection view with grid/list layouts and filtering
- Build plant detail views with care information display
- Integrate Trefle botanical API for plant care data
- Add local image storage for captured plant photos
- Implement dependency injection container for testability
- Include accessibility support throughout the app

Bug fixes in this commit:
- Fix Trefle API decoding by removing duplicate CodingKeys
- Fix LocalCachedImage to load from correct PlantImages directory
- Set dateAdded when saving plants for proper collection sorting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:18:01 -06:00

2033 lines
66 KiB
Markdown

# Phase 6: Polish & Release
**Goal:** Production-ready application with comprehensive error handling, accessibility, performance optimization, and full test coverage
**Prerequisites:** Phase 5 complete (Plant collection working, Core Data persistence functional, image caching implemented, search/filter operational)
---
## Tasks
### 6.1 Build Settings View
- [ ] Create `Presentation/Scenes/Settings/SettingsView.swift`:
```swift
struct SettingsView: View {
@State private var viewModel: SettingsViewModel
@State private var showingClearCacheAlert = false
@State private var showingDeleteDataAlert = false
var body: some View {
NavigationStack {
List {
identificationSection
cacheSection
apiStatusSection
aboutSection
dangerZoneSection
}
.navigationTitle("Settings")
}
}
private var identificationSection: some View {
Section("Identification") {
Toggle("Offline Mode", isOn: $viewModel.offlineModeEnabled)
Picker("Preferred Method", selection: $viewModel.preferredIdentificationMethod) {
Text("Hybrid (Recommended)").tag(IdentificationMethod.hybrid)
Text("On-Device Only").tag(IdentificationMethod.onDevice)
Text("API Only").tag(IdentificationMethod.apiOnly)
}
HStack {
Text("Minimum Confidence")
Spacer()
Text("\(Int(viewModel.minimumConfidence * 100))%")
.foregroundStyle(.secondary)
}
Slider(value: $viewModel.minimumConfidence, in: 0.5...0.95, step: 0.05)
}
}
private var cacheSection: some View {
Section("Storage") {
HStack {
Text("Image Cache")
Spacer()
Text(viewModel.imageCacheSize)
.foregroundStyle(.secondary)
}
HStack {
Text("Identification Cache")
Spacer()
Text(viewModel.identificationCacheSize)
.foregroundStyle(.secondary)
}
Button("Clear Image Cache") {
showingClearCacheAlert = true
}
.foregroundStyle(.red)
}
}
private var apiStatusSection: some View {
Section("API Status") {
APIStatusRow(
name: "PlantNet API",
status: viewModel.plantNetStatus,
quota: viewModel.plantNetQuota
)
APIStatusRow(
name: "Trefle API",
status: viewModel.trefleStatus,
quota: nil
)
HStack {
Text("Network Status")
Spacer()
NetworkStatusBadge(isConnected: viewModel.isNetworkAvailable)
}
}
}
private var aboutSection: some View {
Section("About") {
HStack {
Text("Version")
Spacer()
Text(viewModel.appVersion)
.foregroundStyle(.secondary)
}
HStack {
Text("Build")
Spacer()
Text(viewModel.buildNumber)
.foregroundStyle(.secondary)
}
HStack {
Text("ML Model")
Spacer()
Text("PlantNet-300K")
.foregroundStyle(.secondary)
}
Link("Privacy Policy", destination: URL(string: "https://example.com/privacy")!)
Link("Terms of Service", destination: URL(string: "https://example.com/terms")!)
Link("Open Source Licenses", destination: URL(string: "https://example.com/licenses")!)
}
}
private var dangerZoneSection: some View {
Section("Danger Zone") {
Button("Delete All Plant Data", role: .destructive) {
showingDeleteDataAlert = true
}
}
}
}
```
- [ ] Create `SettingsViewModel`:
```swift
@Observable
final class SettingsViewModel {
// Preferences
var offlineModeEnabled: Bool {
didSet { UserDefaults.standard.set(offlineModeEnabled, forKey: "offlineModeEnabled") }
}
var preferredIdentificationMethod: IdentificationMethod {
didSet { UserDefaults.standard.set(preferredIdentificationMethod.rawValue, forKey: "identificationMethod") }
}
var minimumConfidence: Double {
didSet { UserDefaults.standard.set(minimumConfidence, forKey: "minimumConfidence") }
}
// Cache info
private(set) var imageCacheSize: String = "Calculating..."
private(set) var identificationCacheSize: String = "Calculating..."
// API status
private(set) var plantNetStatus: APIStatus = .unknown
private(set) var plantNetQuota: APIQuota?
private(set) var trefleStatus: APIStatus = .unknown
private(set) var isNetworkAvailable: Bool = true
// App info
let appVersion: String
let buildNumber: String
private let imageCache: ImageCacheProtocol
private let identificationCache: IdentificationCacheProtocol
private let plantNetService: PlantNetAPIServiceProtocol
private let trefleService: TrefleAPIServiceProtocol
private let networkMonitor: NetworkMonitorProtocol
private let plantRepository: PlantCollectionRepositoryProtocol
init(/* dependencies */) {
// Load from UserDefaults
self.offlineModeEnabled = UserDefaults.standard.bool(forKey: "offlineModeEnabled")
self.preferredIdentificationMethod = IdentificationMethod(
rawValue: UserDefaults.standard.string(forKey: "identificationMethod") ?? ""
) ?? .hybrid
self.minimumConfidence = UserDefaults.standard.double(forKey: "minimumConfidence")
if self.minimumConfidence == 0 { self.minimumConfidence = 0.7 }
// App info
self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
self.buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
// Store dependencies
self.imageCache = imageCache
// ... other dependencies
}
func loadCacheInfo() async {
let imageSize = await imageCache.getCacheSize()
imageCacheSize = ByteCountFormatter.string(fromByteCount: imageSize, countStyle: .file)
let idSize = await identificationCache.getCacheSize()
identificationCacheSize = ByteCountFormatter.string(fromByteCount: idSize, countStyle: .file)
}
func checkAPIStatus() async {
// Check PlantNet
do {
let status = try await plantNetService.checkStatus()
plantNetStatus = .available
plantNetQuota = status.quota
} catch {
plantNetStatus = .unavailable(error.localizedDescription)
}
// Check Trefle
do {
try await trefleService.checkStatus()
trefleStatus = .available
} catch {
trefleStatus = .unavailable(error.localizedDescription)
}
}
func clearImageCache() async throws {
await imageCache.clearAllCache()
await loadCacheInfo()
}
func deleteAllData() async throws {
try await plantRepository.deleteAllPlants()
await imageCache.clearAllCache()
await identificationCache.clearAll()
}
}
enum IdentificationMethod: String, CaseIterable {
case hybrid, onDevice, apiOnly
}
enum APIStatus: Equatable {
case unknown
case available
case unavailable(String)
}
struct APIQuota {
let used: Int
let limit: Int
let resetsAt: Date
}
```
- [ ] Create `APIStatusRow` component:
```swift
struct APIStatusRow: View {
let name: String
let status: APIStatus
let quota: APIQuota?
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(name)
Spacer()
StatusBadge(status: status)
}
if let quota {
ProgressView(value: Double(quota.used), total: Double(quota.limit))
.tint(quotaColor(quota))
Text("\(quota.used)/\(quota.limit) requests today")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private func quotaColor(_ quota: APIQuota) -> Color {
let percentage = Double(quota.used) / Double(quota.limit)
if percentage > 0.9 { return .red }
if percentage > 0.7 { return .orange }
return .green
}
}
```
- [ ] Create `NetworkStatusBadge` component
- [ ] Implement network reachability monitoring with `NWPathMonitor`
- [ ] Add haptic feedback for toggle changes
- [ ] Register SettingsViewModel in DIContainer
- [ ] Write unit tests for settings persistence
**Acceptance Criteria:** Settings view displays all options, changes persist, cache info accurate, API status reflects reality
---
### 6.2 Add Comprehensive Error Handling
- [ ] Create `Presentation/Common/Components/ErrorView.swift`:
```swift
struct ErrorView: View {
let error: AppError
let retryAction: (() async -> Void)?
let dismissAction: (() -> Void)?
@State private var isRetrying = false
var body: some View {
VStack(spacing: 24) {
errorIcon
VStack(spacing: 8) {
Text(error.title)
.font(.title2)
.fontWeight(.semibold)
Text(error.message)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
if let suggestion = error.recoverySuggestion {
Text(suggestion)
.font(.callout)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
actionButtons
}
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
}
@ViewBuilder
private var errorIcon: some View {
ZStack {
Circle()
.fill(error.iconBackgroundColor.opacity(0.1))
.frame(width: 80, height: 80)
Image(systemName: error.iconName)
.font(.system(size: 36))
.foregroundStyle(error.iconBackgroundColor)
}
}
@ViewBuilder
private var actionButtons: some View {
VStack(spacing: 12) {
if let retry = retryAction {
Button {
Task {
isRetrying = true
await retry()
isRetrying = false
}
} label: {
if isRetrying {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text(error.retryButtonTitle)
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(isRetrying)
}
if let dismiss = dismissAction {
Button("Dismiss", action: dismiss)
.buttonStyle(.bordered)
}
}
.padding(.top, 8)
}
}
```
- [ ] Create unified `AppError` type:
```swift
// Core/Errors/AppError.swift
enum AppError: Error, LocalizedError, Sendable {
// Network
case networkUnavailable
case networkTimeout
case serverError(statusCode: Int)
case invalidResponse
// Identification
case cameraAccessDenied
case photoLibraryAccessDenied
case identificationFailed(underlying: Error)
case noPlantDetected
case lowConfidence(Double)
case modelNotLoaded
// API
case apiKeyMissing(service: String)
case rateLimitExceeded(resetsAt: Date?)
case quotaExhausted
case apiUnavailable(service: String)
// Persistence
case saveFailed(underlying: Error)
case fetchFailed(underlying: Error)
case deleteFailed(underlying: Error)
case dataCorrupted
// Care
case notificationPermissionDenied
case scheduleCreationFailed
case careDataUnavailable
// General
case unknown(underlying: Error)
var title: String {
switch self {
case .networkUnavailable: return "No Internet Connection"
case .networkTimeout: return "Connection Timed Out"
case .serverError: return "Server Error"
case .cameraAccessDenied: return "Camera Access Required"
case .photoLibraryAccessDenied: return "Photo Access Required"
case .identificationFailed: return "Identification Failed"
case .noPlantDetected: return "No Plant Detected"
case .lowConfidence: return "Uncertain Result"
case .modelNotLoaded: return "Model Not Ready"
case .rateLimitExceeded: return "Too Many Requests"
case .quotaExhausted: return "Daily Limit Reached"
case .apiUnavailable: return "Service Unavailable"
case .saveFailed: return "Save Failed"
case .fetchFailed: return "Load Failed"
case .deleteFailed: return "Delete Failed"
case .dataCorrupted: return "Data Error"
case .notificationPermissionDenied: return "Notifications Disabled"
case .scheduleCreationFailed: return "Schedule Error"
case .careDataUnavailable: return "Care Info Unavailable"
default: return "Something Went Wrong"
}
}
var message: String {
switch self {
case .networkUnavailable:
return "Please check your internet connection and try again."
case .cameraAccessDenied:
return "Botanica needs camera access to identify plants. Please enable it in Settings."
case .noPlantDetected:
return "We couldn't detect a plant in this image. Try taking a clearer photo of the leaves or flowers."
case .lowConfidence(let confidence):
return "We're only \(Int(confidence * 100))% confident about this identification. Try a different angle or better lighting."
case .rateLimitExceeded(let resetsAt):
if let date = resetsAt {
let formatter = RelativeDateTimeFormatter()
return "Please try again \(formatter.localizedString(for: date, relativeTo: Date()))."
}
return "Please wait a moment before trying again."
case .quotaExhausted:
return "You've reached the daily identification limit. Try again tomorrow or use offline mode."
default:
return "An unexpected error occurred. Please try again."
}
}
var recoverySuggestion: String? {
switch self {
case .networkUnavailable:
return "You can still use offline identification while disconnected."
case .cameraAccessDenied, .photoLibraryAccessDenied:
return "Go to Settings > Botanica > Privacy"
case .noPlantDetected:
return "Make sure the plant fills most of the frame."
case .quotaExhausted:
return "Offline identification is still available."
default:
return nil
}
}
var iconName: String {
switch self {
case .networkUnavailable, .networkTimeout: return "wifi.slash"
case .cameraAccessDenied: return "camera.fill"
case .photoLibraryAccessDenied: return "photo.fill"
case .noPlantDetected, .lowConfidence: return "leaf.fill"
case .rateLimitExceeded, .quotaExhausted: return "clock.fill"
case .saveFailed, .fetchFailed, .deleteFailed, .dataCorrupted: return "externaldrive.fill"
case .notificationPermissionDenied: return "bell.slash.fill"
default: return "exclamationmark.triangle.fill"
}
}
var iconBackgroundColor: Color {
switch self {
case .networkUnavailable, .networkTimeout: return .orange
case .cameraAccessDenied, .photoLibraryAccessDenied: return .blue
case .noPlantDetected, .lowConfidence: return .green
case .rateLimitExceeded, .quotaExhausted: return .purple
case .saveFailed, .fetchFailed, .deleteFailed, .dataCorrupted: return .red
default: return .red
}
}
var retryButtonTitle: String {
switch self {
case .cameraAccessDenied, .photoLibraryAccessDenied, .notificationPermissionDenied:
return "Open Settings"
case .networkUnavailable:
return "Try Again"
default:
return "Retry"
}
}
var isRetryable: Bool {
switch self {
case .networkUnavailable, .networkTimeout, .serverError, .identificationFailed,
.saveFailed, .fetchFailed, .apiUnavailable:
return true
default:
return false
}
}
}
```
- [ ] Create inline error banner for non-blocking errors:
```swift
struct ErrorBanner: View {
let error: AppError
let dismiss: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: error.iconName)
.foregroundStyle(error.iconBackgroundColor)
VStack(alignment: .leading, spacing: 2) {
Text(error.title)
.font(.subheadline)
.fontWeight(.medium)
Text(error.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
Button(action: dismiss) {
Image(systemName: "xmark")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding()
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 4)
.padding(.horizontal)
}
}
```
- [ ] Create error handling view modifier:
```swift
struct ErrorHandlingModifier: ViewModifier {
@Binding var error: AppError?
let retryAction: (() async -> Void)?
func body(content: Content) -> some View {
content
.alert(
error?.title ?? "Error",
isPresented: Binding(
get: { error != nil },
set: { if !$0 { error = nil } }
),
presenting: error
) { error in
if error.isRetryable, let retry = retryAction {
Button("Retry") {
Task { await retry() }
}
}
Button("Dismiss", role: .cancel) {}
} message: { error in
Text(error.message)
}
}
}
extension View {
func errorAlert(_ error: Binding<AppError?>, retry: (() async -> Void)? = nil) -> some View {
modifier(ErrorHandlingModifier(error: error, retryAction: retry))
}
}
```
- [ ] Add error logging service:
```swift
protocol ErrorLoggingServiceProtocol: Sendable {
func log(_ error: Error, context: [String: Any]?)
func logWarning(_ message: String, context: [String: Any]?)
}
final class ErrorLoggingService: ErrorLoggingServiceProtocol, Sendable {
func log(_ error: Error, context: [String: Any]?) {
#if DEBUG
print("ERROR: \(error.localizedDescription)")
if let context {
print("Context: \(context)")
}
#endif
// In production, send to crash reporting service
}
}
```
- [ ] Update all ViewModels to use `AppError`
- [ ] Add error recovery actions (open settings, retry, etc.)
- [ ] Write unit tests for error mapping
**Acceptance Criteria:** All errors display user-friendly messages, retry works for retryable errors, error context logged
---
### 6.3 Implement Loading States with Shimmer Effects
- [ ] Create `ShimmerModifier`:
```swift
struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = 0
let animation: Animation
let gradient: Gradient
init(
animation: Animation = .linear(duration: 1.5).repeatForever(autoreverses: false),
gradient: Gradient = Gradient(colors: [.clear, .white.opacity(0.5), .clear])
) {
self.animation = animation
self.gradient = gradient
}
func body(content: Content) -> some View {
content
.overlay {
GeometryReader { geometry in
LinearGradient(
gradient: gradient,
startPoint: .leading,
endPoint: .trailing
)
.frame(width: geometry.size.width * 3)
.offset(x: -geometry.size.width + phase * geometry.size.width * 3)
}
.clipped()
}
.onAppear {
withAnimation(animation) {
phase = 1
}
}
}
}
extension View {
func shimmer() -> some View {
modifier(ShimmerModifier())
}
}
```
- [ ] Create skeleton components:
```swift
struct SkeletonShape: View {
let width: CGFloat?
let height: CGFloat
var body: some View {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
.frame(width: width, height: height)
.shimmer()
}
}
struct PlantCardSkeleton: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
SkeletonShape(width: nil, height: 150)
.clipShape(RoundedRectangle(cornerRadius: 12))
SkeletonShape(width: 120, height: 20)
SkeletonShape(width: 80, height: 14)
}
.padding(8)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
struct CollectionSkeletonView: View {
private let columns = [
GridItem(.adaptive(minimum: 150, maximum: 200), spacing: 16)
]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(0..<6, id: \.self) { _ in
PlantCardSkeleton()
}
}
.padding()
}
}
}
struct IdentificationResultSkeleton: View {
var body: some View {
VStack(spacing: 16) {
SkeletonShape(width: nil, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 16))
VStack(alignment: .leading, spacing: 8) {
SkeletonShape(width: 180, height: 24)
SkeletonShape(width: 140, height: 16)
SkeletonShape(width: nil, height: 12)
SkeletonShape(width: nil, height: 12)
}
HStack(spacing: 16) {
SkeletonShape(width: nil, height: 44)
SkeletonShape(width: nil, height: 44)
}
}
.padding()
}
}
struct PlantDetailSkeleton: View {
var body: some View {
ScrollView {
VStack(spacing: 20) {
SkeletonShape(width: nil, height: 300)
VStack(alignment: .leading, spacing: 12) {
SkeletonShape(width: 200, height: 28)
SkeletonShape(width: 150, height: 18)
Divider()
SkeletonShape(width: 100, height: 20)
SkeletonShape(width: nil, height: 80)
SkeletonShape(width: 100, height: 20)
SkeletonShape(width: nil, height: 60)
}
.padding(.horizontal)
}
}
}
}
struct CareScheduleSkeleton: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
ForEach(0..<4, id: \.self) { _ in
HStack(spacing: 12) {
SkeletonShape(width: 44, height: 44)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 4) {
SkeletonShape(width: 120, height: 16)
SkeletonShape(width: 80, height: 12)
}
Spacer()
SkeletonShape(width: 60, height: 24)
}
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.padding()
}
}
```
- [ ] Create loading state view modifier:
```swift
struct LoadingStateModifier<Loading: View>: ViewModifier {
let isLoading: Bool
let loadingView: Loading
func body(content: Content) -> some View {
if isLoading {
loadingView
} else {
content
}
}
}
extension View {
func loading<L: View>(
_ isLoading: Bool,
@ViewBuilder placeholder: () -> L
) -> some View {
modifier(LoadingStateModifier(isLoading: isLoading, loadingView: placeholder()))
}
}
```
- [ ] Add button loading states:
```swift
struct LoadingButton: View {
let title: String
let isLoading: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
ZStack {
Text(title)
.opacity(isLoading ? 0 : 1)
if isLoading {
ProgressView()
.tint(.white)
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(isLoading)
}
}
```
- [ ] Create pull-to-refresh with custom animation
- [ ] Add transition animations between loading and content
- [ ] Implement progressive loading for large collections
- [ ] Write snapshot tests for skeleton views
**Acceptance Criteria:** All loading states have skeleton placeholders, shimmer animation is smooth, transitions are seamless
---
### 6.4 Add Accessibility Support
- [ ] Add accessibility labels to all interactive elements:
```swift
// Example: PlantGridCard with full accessibility
struct PlantGridCard: View {
let plant: Plant
var body: some View {
VStack(alignment: .leading, spacing: 8) {
CachedAsyncImage(url: plant.imageURLs.first, plantID: plant.id)
.frame(height: 150)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(alignment: .topTrailing) {
if plant.isFavorite {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.padding(8)
.accessibilityHidden(true) // Part of card label
}
}
.accessibilityHidden(true) // Image described in card label
Text(plant.displayName)
.font(.headline)
.lineLimit(1)
Text(plant.scientificName)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.italic()
}
.padding(8)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel)
.accessibilityHint("Double tap to view details")
.accessibilityAddTraits(.isButton)
}
private var accessibilityLabel: String {
var label = plant.displayName
if plant.displayName != plant.scientificName {
label += ", scientific name: \(plant.scientificName)"
}
if plant.isFavorite {
label += ", favorite"
}
return label
}
}
```
- [ ] Support Dynamic Type throughout app:
```swift
// Use semantic fonts everywhere
Text(plant.displayName)
.font(.headline) // Will scale with Dynamic Type
// For custom sizes, use relativeTo:
Text("Scientific Name")
.font(.system(.caption, design: .serif))
// Ensure layouts adapt to larger text
@ScaledMetric(relativeTo: .body) var iconSize: CGFloat = 24
```
- [ ] Add VoiceOver support:
```swift
// Custom accessibility actions
struct PlantCard: View {
var body: some View {
cardContent
.accessibilityAction(named: "Toggle Favorite") {
toggleFavorite()
}
.accessibilityAction(named: "Delete") {
delete()
}
.accessibilityAction(named: "View Care Schedule") {
showCareSchedule()
}
}
}
// Announce changes
func identificationComplete(_ plant: Plant) {
UIAccessibility.post(
notification: .announcement,
argument: "Identified as \(plant.displayName) with \(Int(plant.confidenceScore ?? 0 * 100)) percent confidence"
)
}
```
- [ ] Implement accessibility rotor for quick navigation:
```swift
struct CollectionView: View {
var body: some View {
ScrollView {
// ... content
}
.accessibilityRotor("Favorites") {
ForEach(plants.filter(\.isFavorite)) { plant in
AccessibilityRotorEntry(plant.displayName, id: plant.id)
}
}
.accessibilityRotor("Needs Watering") {
ForEach(plantsNeedingWater) { plant in
AccessibilityRotorEntry(plant.displayName, id: plant.id)
}
}
}
}
```
- [ ] Support Reduce Motion:
```swift
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
content
.animation(reduceMotion ? nil : .spring(), value: isExpanded)
}
// Or use a wrapper
extension Animation {
static func accessibleSpring(_ reduceMotion: Bool) -> Animation? {
reduceMotion ? nil : .spring()
}
}
```
- [ ] Support Reduce Transparency:
```swift
@Environment(\.accessibilityReduceTransparency) var reduceTransparency
var body: some View {
content
.background(reduceTransparency ? Color(.systemBackground) : .ultraThinMaterial)
}
```
- [ ] Add accessibility identifiers for UI testing:
```swift
enum AccessibilityID {
static let cameraButton = "camera_capture_button"
static let collectionGrid = "plant_collection_grid"
static let searchField = "collection_search_field"
static let plantCard = { (id: UUID) in "plant_card_\(id.uuidString)" }
static let favoriteButton = { (id: UUID) in "favorite_button_\(id.uuidString)" }
static let deleteButton = { (id: UUID) in "delete_button_\(id.uuidString)" }
}
// Usage
Button("Capture") { capture() }
.accessibilityIdentifier(AccessibilityID.cameraButton)
```
- [ ] Support Bold Text preference
- [ ] Support Increase Contrast preference
- [ ] Test with VoiceOver on device
- [ ] Test with Dynamic Type at all sizes
- [ ] Write accessibility audit checklist
- [ ] Document accessibility features
**Acceptance Criteria:** App fully usable with VoiceOver, all text scales with Dynamic Type, motion respects user preferences
---
### 6.5 Performance Optimization Pass
- [ ] Profile app with Instruments:
- Time Profiler for CPU bottlenecks
- Allocations for memory usage
- Leaks for memory leaks
- Core Animation for rendering issues
- Network for API efficiency
- [ ] Optimize image loading:
```swift
// Thumbnail generation for grid
actor ThumbnailGenerator {
private var cache: [String: UIImage] = [:]
func thumbnail(for image: UIImage, size: CGSize) async -> UIImage {
let key = "\(image.hash)_\(size.width)x\(size.height)"
if let cached = cache[key] {
return cached
}
let thumbnail = await Task.detached(priority: .userInitiated) {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
}.value
cache[key] = thumbnail
return thumbnail
}
}
// Downsampling for large images
func downsample(imageAt url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else {
return nil
}
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
] as CFDictionary
guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else {
return nil
}
return UIImage(cgImage: downsampledImage)
}
```
- [ ] Optimize Core ML inference:
```swift
// Batch predictions when possible
// Use GPU for inference
let config = MLModelConfiguration()
config.computeUnits = .all // Use Neural Engine when available
// Warm up model on launch
func warmUpModel() {
Task.detached(priority: .background) {
_ = try? await classificationService.predict(UIImage())
}
}
```
- [ ] Optimize Core Data queries:
```swift
// Use fetch limits
let request = PlantMO.fetchRequest()
request.fetchLimit = 20
request.fetchBatchSize = 20
// Only fetch needed properties
request.propertiesToFetch = ["id", "scientificName", "commonNames", "imageURLs"]
// Use NSFetchedResultsController for live updates
lazy var fetchedResultsController: NSFetchedResultsController<PlantMO> = {
let request = PlantMO.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \PlantMO.dateAdded, ascending: false)]
request.fetchBatchSize = 20
return NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: viewContext,
sectionNameKeyPath: nil,
cacheName: "PlantCollection"
)
}()
```
- [ ] Implement view recycling for large collections:
```swift
// LazyVGrid already handles this, but ensure no expensive operations in body
struct OptimizedPlantGrid: View {
let plants: [Plant]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(plants) { plant in
PlantGridCard(plant: plant)
.id(plant.id) // Stable identity for recycling
}
}
}
}
}
```
- [ ] Reduce app launch time:
```swift
// Defer non-critical initialization
@main
struct BotanicaApp: App {
init() {
// Register value transformers synchronously (required)
ValueTransformers.registerAll()
}
var body: some Scene {
WindowGroup {
ContentView()
.task {
// Defer these to after first frame
await DeferredInitialization.perform()
}
}
}
}
enum DeferredInitialization {
static func perform() async {
// Warm up ML model
await MLModelWarmup.warmUp()
// Pre-fetch common data
await DataPrefetcher.prefetch()
// Initialize analytics
Analytics.initialize()
}
}
```
- [ ] Optimize network requests:
```swift
// Implement request deduplication
actor RequestDeduplicator {
private var inFlightRequests: [String: Task<Data, Error>] = [:]
func deduplicated<T>(
key: String,
request: @escaping () async throws -> T
) async throws -> T {
if let existing = inFlightRequests[key] {
return try await existing.value as! T
}
let task = Task {
defer { inFlightRequests[key] = nil }
return try await request()
}
inFlightRequests[key] = task as! Task<Data, Error>
return try await task.value
}
}
```
- [ ] Add performance monitoring:
```swift
struct PerformanceMonitor {
static func measure<T>(_ label: String, _ block: () async throws -> T) async rethrows -> T {
let start = CFAbsoluteTimeGetCurrent()
defer {
let duration = CFAbsoluteTimeGetCurrent() - start
#if DEBUG
print("⏱ \(label): \(String(format: "%.3f", duration * 1000))ms")
#endif
}
return try await block()
}
}
```
- [ ] Document performance benchmarks
- [ ] Create performance regression tests
**Acceptance Criteria:** App launch < 2 seconds, ML inference < 500ms, smooth 60fps scrolling, memory < 150MB typical usage
---
### 6.6 Write Unit Tests for Use Cases and Services
- [ ] Set up testing infrastructure:
```swift
// Tests/Mocks/MockPlantCollectionRepository.swift
final class MockPlantCollectionRepository: PlantCollectionRepositoryProtocol {
var plants: [Plant] = []
var addPlantCalled = false
var deletePlantCalled = false
var lastDeletedID: UUID?
var shouldThrowError = false
var errorToThrow: Error?
func addPlant(_ plant: Plant) async throws {
if shouldThrowError { throw errorToThrow ?? TestError.mock }
addPlantCalled = true
plants.append(plant)
}
func deletePlant(id: UUID) async throws {
if shouldThrowError { throw errorToThrow ?? TestError.mock }
deletePlantCalled = true
lastDeletedID = id
plants.removeAll { $0.id == id }
}
// ... implement all protocol methods
}
```
- [ ] Test identification use cases:
```swift
final class IdentifyPlantOnDeviceUseCaseTests: XCTestCase {
var sut: IdentifyPlantOnDeviceUseCase!
var mockClassificationService: MockPlantClassificationService!
var mockLabelService: MockPlantLabelService!
override func setUp() {
super.setUp()
mockClassificationService = MockPlantClassificationService()
mockLabelService = MockPlantLabelService()
sut = IdentifyPlantOnDeviceUseCase(
classificationService: mockClassificationService,
labelService: mockLabelService
)
}
func testIdentifyReturnsTopResults() async throws {
// Given
let testImage = UIImage.testPlantImage
mockClassificationService.stubbedResults = [
ClassificationResult(classIndex: 0, confidence: 0.85),
ClassificationResult(classIndex: 1, confidence: 0.10),
]
mockLabelService.stubbedLabels = ["Monstera deliciosa", "Philodendron"]
// When
let results = try await sut.execute(image: testImage)
// Then
XCTAssertEqual(results.count, 2)
XCTAssertEqual(results[0].scientificName, "Monstera deliciosa")
XCTAssertEqual(results[0].confidence, 0.85, accuracy: 0.001)
}
func testIdentifyThrowsWhenNoResults() async {
// Given
mockClassificationService.stubbedResults = []
// When/Then
await XCTAssertThrowsError(
try await sut.execute(image: UIImage.testPlantImage)
) { error in
XCTAssertEqual(error as? AppError, .noPlantDetected)
}
}
func testIdentifyFiltersLowConfidenceResults() async throws {
// Given
mockClassificationService.stubbedResults = [
ClassificationResult(classIndex: 0, confidence: 0.02),
]
// When
let results = try await sut.execute(image: UIImage.testPlantImage, minimumConfidence: 0.05)
// Then
XCTAssertTrue(results.isEmpty)
}
}
```
- [ ] Test collection use cases:
```swift
final class SavePlantUseCaseTests: XCTestCase {
var sut: SavePlantUseCase!
var mockRepository: MockPlantCollectionRepository!
var mockCareScheduleUseCase: MockCreateCareScheduleUseCase!
var mockNotificationService: MockNotificationService!
var mockImageStorage: MockImageStorage!
func testSavePlantAddsToRepository() async throws {
// Given
let plant = Plant.mock()
// When
let saved = try await sut.execute(plant: plant, capturedImage: nil, careInfo: nil, preferences: nil)
// Then
XCTAssertTrue(mockRepository.addPlantCalled)
XCTAssertNotNil(saved.dateAdded)
}
func testSavePlantWithCareInfoCreatesSchedule() async throws {
// Given
let plant = Plant.mock()
let careInfo = PlantCareInfo.mock()
mockCareScheduleUseCase.stubbedSchedule = PlantCareSchedule.mock()
// When
_ = try await sut.execute(plant: plant, capturedImage: nil, careInfo: careInfo, preferences: nil)
// Then
XCTAssertTrue(mockCareScheduleUseCase.executeCalled)
XCTAssertTrue(mockNotificationService.scheduleReminderCalled)
}
func testSavePlantWithImageStoresLocally() async throws {
// Given
let plant = Plant.mock()
let image = UIImage.testPlantImage
mockImageStorage.stubbedPath = "/local/path/image.jpg"
// When
let saved = try await sut.execute(plant: plant, capturedImage: image, careInfo: nil, preferences: nil)
// Then
XCTAssertTrue(mockImageStorage.saveCalled)
XCTAssertEqual(saved.localImagePaths.first, "/local/path/image.jpg")
}
}
```
- [ ] Test API services:
```swift
final class PlantNetAPIServiceTests: XCTestCase {
var sut: PlantNetAPIService!
var mockNetworkService: MockNetworkService!
func testIdentifyReturnsSpecies() async throws {
// Given
let responseData = PlantNetIdentifyResponseDTO.mockJSON.data(using: .utf8)!
mockNetworkService.stubbedResponse = (responseData, HTTPURLResponse.ok)
// When
let response = try await sut.identify(image: UIImage.testPlantImage)
// Then
XCTAssertFalse(response.results.isEmpty)
XCTAssertEqual(response.results[0].species.scientificName, "Monstera deliciosa")
}
func testIdentifyThrowsOnRateLimit() async {
// Given
mockNetworkService.stubbedResponse = (Data(), HTTPURLResponse.tooManyRequests)
// When/Then
await XCTAssertThrowsError(
try await sut.identify(image: UIImage.testPlantImage)
) { error in
XCTAssertEqual(error as? AppError, .rateLimitExceeded(resetsAt: nil))
}
}
}
```
- [ ] Test Core Data storage:
```swift
final class CoreDataPlantStorageTests: XCTestCase {
var sut: CoreDataPlantStorage!
var inMemoryStack: CoreDataStack!
override func setUp() {
super.setUp()
inMemoryStack = CoreDataStack.inMemoryForTesting()
sut = CoreDataPlantStorage(coreDataStack: inMemoryStack)
}
func testSaveAndFetch() async throws {
// Given
let plant = Plant.mock()
// When
try await sut.save(plant)
let fetched = try await sut.fetch(id: plant.id)
// Then
XCTAssertNotNil(fetched)
XCTAssertEqual(fetched?.scientificName, plant.scientificName)
}
func testDeleteRemovesPlant() async throws {
// Given
let plant = Plant.mock()
try await sut.save(plant)
// When
try await sut.delete(id: plant.id)
let fetched = try await sut.fetch(id: plant.id)
// Then
XCTAssertNil(fetched)
}
func testSearchFindsMatchingPlants() async throws {
// Given
let monstera = Plant.mock(scientificName: "Monstera deliciosa", commonNames: ["Swiss cheese plant"])
let pothos = Plant.mock(scientificName: "Epipremnum aureum", commonNames: ["Golden pothos"])
try await sut.save(monstera)
try await sut.save(pothos)
// When
let results = try await sut.search(query: "cheese")
// Then
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results[0].id, monstera.id)
}
}
```
- [ ] Test image cache:
```swift
final class ImageCacheTests: XCTestCase {
var sut: ImageCache!
var tempDirectory: URL!
override func setUp() {
super.setUp()
tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
sut = ImageCache(directory: tempDirectory)
}
override func tearDown() {
try? FileManager.default.removeItem(at: tempDirectory)
super.tearDown()
}
func testCacheAndRetrieve() async throws {
// Given
let plantID = UUID()
let url = URL(string: "https://example.com/plant.jpg")!
// Mock network response...
// When
try await sut.cacheImage(from: url, for: plantID)
let cached = await sut.getCachedImage(for: plantID, urlHash: url.absoluteString.sha256Hash)
// Then
XCTAssertNotNil(cached)
}
func testClearCacheRemovesImages() async throws {
// Given
let plantID = UUID()
// ... cache some images
// When
await sut.clearCache(for: plantID)
// Then
let cached = await sut.getCachedImage(for: plantID, urlHash: "test")
XCTAssertNil(cached)
}
}
```
- [ ] Test error mapping
- [ ] Achieve >80% code coverage for business logic
- [ ] Add test utilities and fixtures
**Acceptance Criteria:** >80% coverage on use cases and services, all critical paths tested, mocks for all dependencies
---
### 6.7 Write UI Tests for Critical Flows
- [ ] Set up UI testing infrastructure:
```swift
// BotanicaUITests/Helpers/XCUIApplication+Launch.swift
extension XCUIApplication {
func launchWithCleanState() {
launchArguments = ["--uitesting", "--reset-state"]
launch()
}
func launchWithMockData() {
launchArguments = ["--uitesting", "--mock-data"]
launch()
}
func launchOffline() {
launchArguments = ["--uitesting", "--offline"]
launch()
}
}
```
- [ ] Test camera capture flow:
```swift
final class CameraFlowUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchWithMockData()
}
func testCapturePhotoShowsIdentificationResults() {
// Navigate to camera
app.tabBars.buttons["Camera"].tap()
// Capture photo (mock camera provides test image)
let captureButton = app.buttons[AccessibilityID.cameraButton]
XCTAssertTrue(captureButton.waitForExistence(timeout: 5))
captureButton.tap()
// Wait for identification results
let resultsView = app.otherElements["identification_results"]
XCTAssertTrue(resultsView.waitForExistence(timeout: 10))
// Verify results displayed
XCTAssertTrue(app.staticTexts["Monstera deliciosa"].exists)
}
func testCanSavePlantFromIdentification() {
// ... navigate and capture
// Tap save button
app.buttons["Add to Collection"].tap()
// Verify plant appears in collection
app.tabBars.buttons["Collection"].tap()
XCTAssertTrue(app.staticTexts["Monstera deliciosa"].waitForExistence(timeout: 5))
}
}
```
- [ ] Test collection management:
```swift
final class CollectionUITests: XCTestCase {
func testSearchFiltersPlants() {
app.launchWithMockData()
app.tabBars.buttons["Collection"].tap()
// Search for plant
let searchField = app.searchFields[AccessibilityID.searchField]
searchField.tap()
searchField.typeText("Monstera")
// Verify filtered results
XCTAssertTrue(app.staticTexts["Monstera deliciosa"].exists)
XCTAssertFalse(app.staticTexts["Philodendron"].exists)
}
func testCanDeletePlant() {
app.launchWithMockData()
app.tabBars.buttons["Collection"].tap()
// Find plant card
let plantCard = app.otherElements["plant_card_mock_uuid"]
// Swipe to delete
plantCard.swipeLeft()
app.buttons["Delete"].tap()
// Confirm deletion
app.alerts.buttons["Delete"].tap()
// Verify plant removed
XCTAssertFalse(plantCard.exists)
}
func testCanToggleFavorite() {
app.launchWithMockData()
app.tabBars.buttons["Collection"].tap()
// Tap favorite button
let favoriteButton = app.buttons["favorite_button_mock_uuid"]
favoriteButton.tap()
// Verify heart icon appears
XCTAssertTrue(app.images["heart.fill"].exists)
}
}
```
- [ ] Test care schedule flow:
```swift
final class CareScheduleUITests: XCTestCase {
func testViewCareScheduleFromPlantDetail() {
app.launchWithMockData()
// Navigate to plant detail
app.tabBars.buttons["Collection"].tap()
app.otherElements["plant_card_mock_uuid"].tap()
// Tap care schedule section
app.buttons["View Care Schedule"].tap()
// Verify schedule displayed
XCTAssertTrue(app.staticTexts["Watering"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["Every 7 days"].exists)
}
func testCanMarkTaskComplete() {
// ... navigate to care schedule
// Find task and complete it
let wateringTask = app.cells["care_task_watering"]
wateringTask.buttons["Complete"].tap()
// Verify checkmark appears
XCTAssertTrue(wateringTask.images["checkmark.circle.fill"].exists)
}
}
```
- [ ] Test settings flow:
```swift
final class SettingsUITests: XCTestCase {
func testOfflineModeToggle() {
app.launchWithCleanState()
app.tabBars.buttons["Settings"].tap()
// Toggle offline mode
let toggle = app.switches["offline_mode_toggle"]
toggle.tap()
// Verify toggle state changed
XCTAssertEqual(toggle.value as? String, "1")
}
func testClearCache() {
app.launchWithMockData()
app.tabBars.buttons["Settings"].tap()
// Tap clear cache
app.buttons["Clear Image Cache"].tap()
// Confirm in alert
app.alerts.buttons["Clear"].tap()
// Verify cache size updates
XCTAssertTrue(app.staticTexts["0 bytes"].waitForExistence(timeout: 5))
}
}
```
- [ ] Test offline mode:
```swift
final class OfflineModeUITests: XCTestCase {
func testOfflineIdentificationWorks() {
app.launchOffline()
// Capture photo
app.tabBars.buttons["Camera"].tap()
app.buttons[AccessibilityID.cameraButton].tap()
// Verify on-device identification works
XCTAssertTrue(app.staticTexts["Identified (Offline)"].waitForExistence(timeout: 10))
}
func testCachedImagesDisplayOffline() {
// First, cache some data online
app.launchWithMockData()
app.tabBars.buttons["Collection"].tap()
// Wait for images to cache
sleep(2)
// Relaunch offline
app.terminate()
app.launchOffline()
// Verify cached images display
app.tabBars.buttons["Collection"].tap()
let plantImage = app.images["plant_image_mock_uuid"]
XCTAssertTrue(plantImage.waitForExistence(timeout: 5))
}
}
```
- [ ] Test error states:
```swift
final class ErrorHandlingUITests: XCTestCase {
func testNetworkErrorShowsRetry() {
app.launchArguments.append("--simulate-network-error")
app.launch()
// Trigger network request
app.tabBars.buttons["Camera"].tap()
app.buttons[AccessibilityID.cameraButton].tap()
// Verify error view appears
XCTAssertTrue(app.staticTexts["No Internet Connection"].waitForExistence(timeout: 10))
XCTAssertTrue(app.buttons["Try Again"].exists)
}
}
```
- [ ] Test accessibility:
```swift
final class AccessibilityUITests: XCTestCase {
func testVoiceOverLabelsExist() {
app.launch()
app.tabBars.buttons["Collection"].tap()
// Verify accessibility labels
let plantCard = app.otherElements["plant_card_mock_uuid"]
XCTAssertTrue(plantCard.label.contains("Monstera deliciosa"))
}
func testDynamicTypeSupported() {
// Set large text size via launch argument or UI
app.launchArguments.append("-UIPreferredContentSizeCategoryName")
app.launchArguments.append("UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge")
app.launch()
// Verify app doesn't crash and elements are visible
XCTAssertTrue(app.tabBars.buttons["Collection"].exists)
}
}
```
- [ ] Add screenshot tests for visual regression
- [ ] Create test data fixtures
**Acceptance Criteria:** All critical user flows covered, tests pass consistently, <5 min total runtime
---
### 6.8 Final QA and Bug Fixes
- [ ] Create comprehensive QA checklist:
```markdown
## Pre-Release QA Checklist
### Installation & Launch
- [ ] Fresh install on iOS 17 device
- [ ] Fresh install on iOS 18 device
- [ ] App launches without crash
- [ ] App launches in under 2 seconds
- [ ] All tabs accessible
### Camera & Identification
- [ ] Camera permission requested correctly
- [ ] Camera preview displays
- [ ] Photo capture works
- [ ] On-device identification returns results
- [ ] API identification returns results (online)
- [ ] Hybrid identification works
- [ ] Results display with confidence scores
- [ ] Low confidence warning shown when appropriate
- [ ] No plant detected message shown for non-plant images
### Plant Collection
- [ ] Save plant to collection
- [ ] View collection (grid mode)
- [ ] View collection (list mode)
- [ ] Search by common name
- [ ] Search by scientific name
- [ ] Filter by favorites
- [ ] Sort by date added
- [ ] Sort by name
- [ ] Delete plant (swipe)
- [ ] Delete plant (context menu)
- [ ] Toggle favorite
- [ ] Empty state displayed when no plants
- [ ] Pull to refresh works
### Plant Detail
- [ ] View plant detail from collection
- [ ] View plant detail from identification
- [ ] All plant info displayed
- [ ] Images load (online)
- [ ] Cached images load (offline)
- [ ] Edit plant notes
- [ ] View identification history
### Care Schedule
- [ ] Care schedule created for new plant
- [ ] Upcoming tasks displayed
- [ ] Mark task complete
- [ ] Notifications scheduled
- [ ] Notification fires at correct time
- [ ] Tap notification opens correct plant
### Settings
- [ ] Offline mode toggle works
- [ ] Cache size displayed correctly
- [ ] Clear cache works
- [ ] API status displayed
- [ ] Network status updates
- [ ] App version displayed
### Offline Mode
- [ ] On-device identification works offline
- [ ] Collection displays offline
- [ ] Cached images display offline
- [ ] Offline indicator shown
- [ ] Graceful degradation of API features
### Performance
- [ ] App launch < 2 seconds
- [ ] Identification < 500ms (on-device)
- [ ] Smooth scrolling (60fps)
- [ ] No memory warnings
- [ ] No excessive battery drain
### Accessibility
- [ ] VoiceOver navigation works
- [ ] Dynamic Type scales correctly
- [ ] Reduce Motion respected
- [ ] Color contrast sufficient
- [ ] All buttons have labels
### Edge Cases
- [ ] Very long plant names display
- [ ] 500+ plants in collection
- [ ] Rapid capture attempts
- [ ] Background/foreground transitions
- [ ] Low storage device
- [ ] Interrupted network during API call
- [ ] App update migration
```
- [ ] Fix critical bugs discovered during QA
- [ ] Fix high-priority bugs
- [ ] Document known issues for v1.0
- [ ] Create App Store screenshots
- [ ] Write App Store description
- [ ] Create privacy policy page
- [ ] Prepare App Store review notes
- [ ] Final build and archive
- [ ] TestFlight distribution
- [ ] Beta tester feedback round
- [ ] Address beta feedback
- [ ] Final submission build
**Acceptance Criteria:** All QA checklist items pass, no critical/high bugs remaining, app approved for release
---
## End-of-Phase Validation
### Functional Verification
| Test | Steps | Expected Result | Status |
|------|-------|-----------------|--------|
| Settings Persist | Change setting → restart app | Setting retained | [ ] |
| Offline Mode | Enable offline → identify plant | Uses on-device only | [ ] |
| Clear Cache | Clear cache | Cache size shows 0 bytes | [ ] |
| API Status | View settings with network | Shows "Available" | [ ] |
| API Status Offline | View settings without network | Shows "Unavailable" | [ ] |
| Error View | Trigger network error | Error view with retry button | [ ] |
| Error Retry | Tap retry on error | Action retries | [ ] |
| Error Dismiss | Tap dismiss on error | Error view closes | [ ] |
| Loading Skeleton | Load collection | Skeleton shown during load | [ ] |
| Shimmer Animation | View skeleton | Smooth shimmer effect | [ ] |
| VoiceOver Navigation | Navigate with VoiceOver | All elements accessible | [ ] |
| Dynamic Type Large | Set accessibility large text | UI scales correctly | [ ] |
| Dynamic Type Small | Set accessibility small text | UI scales correctly | [ ] |
| Reduce Motion | Enable reduce motion | No animations | [ ] |
| Unit Tests Pass | Run unit tests | All pass | [ ] |
| UI Tests Pass | Run UI tests | All pass | [ ] |
| No Memory Leaks | Profile with Instruments | No leaks detected | [ ] |
| No Crashes | Extended usage session | No crashes | [ ] |
### Code Quality Verification
| Check | Criteria | Status |
|-------|----------|--------|
| Build | Project builds with zero warnings | [ ] |
| SwiftLint | No lint warnings | [ ] |
| Unit Test Coverage | >80% on business logic | [ ] |
| UI Test Coverage | All critical flows | [ ] |
| Accessibility Audit | All elements have labels | [ ] |
| Error Handling | All errors use AppError | [ ] |
| Loading States | All async operations have loading state | [ ] |
| Skeleton Views | All lists have skeleton placeholders | [ ] |
| Settings Persistence | All settings use UserDefaults correctly | [ ] |
| Memory Management | No retain cycles | [ ] |
| Thread Safety | No data races | [ ] |
| API Error Handling | All API errors handled gracefully | [ ] |
| Offline Handling | All features degrade gracefully | [ ] |
### Performance Verification
| Metric | Target | Actual | Status |
|--------|--------|--------|--------|
| Cold Launch | < 2 seconds | | [ ] |
| Warm Launch | < 1 second | | [ ] |
| Time to First Frame | < 400ms | | [ ] |
| ML Model Load | < 500ms | | [ ] |
| On-Device Inference | < 500ms | | [ ] |
| API Identification | < 3 seconds | | [ ] |
| Collection Load (100 plants) | < 500ms | | [ ] |
| Search Response | < 200ms | | [ ] |
| Image Cache Hit | < 20ms | | [ ] |
| Memory (typical usage) | < 150MB | | [ ] |
| Memory (500 plants) | < 250MB | | [ ] |
| Battery (1 hour usage) | < 10% | | [ ] |
| Scroll Frame Rate | 60fps | | [ ] |
| UI Test Suite | < 5 minutes | | [ ] |
| Unit Test Suite | < 30 seconds | | [ ] |
### Accessibility Verification
| Test | Steps | Expected Result | Status |
|------|-------|-----------------|--------|
| VoiceOver Camera | Navigate camera with VO | Capture button announced | [ ] |
| VoiceOver Collection | Navigate collection with VO | All plants announced | [ ] |
| VoiceOver Plant Detail | Navigate detail with VO | All info read | [ ] |
| VoiceOver Care Schedule | Navigate schedule with VO | Tasks announced | [ ] |
| VoiceOver Settings | Navigate settings with VO | All options announced | [ ] |
| Dynamic Type XXL | Use accessibility XXL size | All text readable | [ ] |
| Dynamic Type XS | Use accessibility XS size | UI not broken | [ ] |
| Bold Text | Enable bold text | Text becomes bold | [ ] |
| Reduce Motion | Enable reduce motion | No spring animations | [ ] |
| Reduce Transparency | Enable reduce transparency | Solid backgrounds | [ ] |
| Increase Contrast | Enable increase contrast | Higher contrast | [ ] |
| Color Blind (Protanopia) | Simulate color blindness | UI distinguishable | [ ] |
| Color Blind (Deuteranopia) | Simulate color blindness | UI distinguishable | [ ] |
### Error Handling Verification
| Test | Trigger | Expected Result | Status |
|------|---------|-----------------|--------|
| Network Unavailable | Disable network | Error view with retry | [ ] |
| Network Timeout | Slow network simulation | Timeout error shown | [ ] |
| API Rate Limit | Exceed rate limit | Rate limit message | [ ] |
| API Quota Exceeded | Use all daily quota | Quota message | [ ] |
| Camera Denied | Deny camera permission | Permission error | [ ] |
| Photos Denied | Deny photos permission | Permission error | [ ] |
| Notification Denied | Deny notifications | Notification error | [ ] |
| Save Failed | Simulate save error | Save error message | [ ] |
| Model Not Loaded | Access model before load | Model error | [ ] |
| Invalid Image | Provide corrupt image | Invalid image error | [ ] |
| No Plant Detected | Photo of non-plant | No plant message | [ ] |
| Low Confidence | Ambiguous plant photo | Low confidence warning | [ ] |
### Release Readiness Verification
| Item | Criteria | Status |
|------|----------|--------|
| App Icon | All sizes provided | [ ] |
| Launch Screen | Displays correctly | [ ] |
| App Store Screenshots | All device sizes | [ ] |
| App Store Description | Written and reviewed | [ ] |
| Privacy Policy | URL accessible | [ ] |
| Terms of Service | URL accessible | [ ] |
| API Keys | Production keys configured | [ ] |
| Bundle ID | Production ID set | [ ] |
| Version Number | Set to 1.0.0 | [ ] |
| Build Number | Incremented | [ ] |
| Code Signing | Production certificate | [ ] |
| Provisioning | App Store profile | [ ] |
| Archive | Builds successfully | [ ] |
| App Store Connect | App record created | [ ] |
| TestFlight | Build uploaded | [ ] |
| Beta Testing | Feedback addressed | [ ] |
| App Review Notes | Written | [ ] |
---
## Phase 6 Completion Checklist
- [ ] All 8 tasks completed
- [ ] All functional tests pass
- [ ] All code quality checks pass
- [ ] All performance targets met
- [ ] All accessibility tests pass
- [ ] All error handling tests pass
- [ ] Settings view complete with all options
- [ ] Error handling comprehensive with user-friendly messages
- [ ] Loading states with shimmer animations
- [ ] Full accessibility support (VoiceOver, Dynamic Type, Reduce Motion)
- [ ] Performance optimized (launch, inference, scrolling)
- [ ] Unit tests >80% coverage on business logic
- [ ] UI tests cover all critical flows
- [ ] QA checklist completed
- [ ] All critical/high bugs fixed
- [ ] App Store assets prepared
- [ ] TestFlight beta completed
- [ ] Ready for App Store submission
---
## Error Handling Summary
### Settings Errors
```swift
enum SettingsError: Error, LocalizedError {
case cacheCleanupFailed
case dataDeleteFailed
case preferenceSaveFailed
var errorDescription: String? {
switch self {
case .cacheCleanupFailed: return "Failed to clear cache"
case .dataDeleteFailed: return "Failed to delete data"
case .preferenceSaveFailed: return "Failed to save preference"
}
}
}
```
---
## Notes
- Error messages should be actionable and user-friendly
- Loading skeletons should match the approximate layout of real content
- Accessibility is not optional - test thoroughly with real VoiceOver users if possible
- Performance testing should be done on oldest supported device (iPhone 8 / iOS 17)
- UI tests should be deterministic - avoid flaky tests
- Beta testers should include diverse users (different devices, usage patterns)
- App Store review may take 24-48 hours - plan accordingly
- Keep crash reporting enabled even after release for production monitoring
---
## Dependencies
| Dependency | Type | Notes |
|------------|------|-------|
| NWPathMonitor | System | Network reachability |
| UIAccessibility | System | VoiceOver announcements |
| XCTest | System | Unit testing |
| XCUITest | System | UI testing |
| UserDefaults | System | Settings persistence |
| Bundle | System | App version info |
---
## Risk Mitigation
| Risk | Mitigation |
|------|------------|
| App Store rejection | Follow all guidelines, provide complete review notes |
| Performance regression | Automated performance tests in CI |
| Accessibility issues | Test with real VoiceOver users |
| Flaky UI tests | Use explicit waits, deterministic test data |
| Missing error cases | Comprehensive error type coverage |
| Memory leaks | Regular Instruments profiling |
| Beta feedback overload | Prioritize critical issues, defer nice-to-haves |
| Last-minute bugs | Freeze features early, focus on stability |
| App Review delays | Submit early, have contingency timeline |
| Certificate expiry | Verify certificates before submission |