- 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>
2033 lines
66 KiB
Markdown
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 |
|