From 34ce28d513040e47218ef37853a0c92214b2c149 Mon Sep 17 00:00:00 2001 From: Tobias Fu Date: Wed, 24 Jun 2026 14:47:27 -0700 Subject: [PATCH 1/6] Add profile badges and progress --- Fieldnote/FieldnoteApp.swift | 10 +- Fieldnote/Models/Achievement.swift | 24 + Fieldnote/Models/BadgeCatalog.swift | 59 +++ Fieldnote/Models/FieldProfile.swift | 51 ++ .../Screens/Capture/CaptureReviewSheet.swift | 4 + Fieldnote/Screens/MainTabView.swift | 8 +- Fieldnote/Screens/Profile/ProfileView.swift | 488 ++++++++++++++++++ Fieldnote/Services/GamificationService.swift | 184 +++++++ Fieldnote/Store/AppStore.swift | 1 + Fieldnote/Store/AppStoreEnvironment.swift | 13 + Fieldnote/Theme/FieldColor.swift | 22 + 11 files changed, 858 insertions(+), 6 deletions(-) create mode 100644 Fieldnote/Models/Achievement.swift create mode 100644 Fieldnote/Models/BadgeCatalog.swift create mode 100644 Fieldnote/Models/FieldProfile.swift create mode 100644 Fieldnote/Screens/Profile/ProfileView.swift create mode 100644 Fieldnote/Services/GamificationService.swift diff --git a/Fieldnote/FieldnoteApp.swift b/Fieldnote/FieldnoteApp.swift index 5604472..def5ff5 100644 --- a/Fieldnote/FieldnoteApp.swift +++ b/Fieldnote/FieldnoteApp.swift @@ -15,10 +15,11 @@ struct FieldnoteApp: App { @State private var onboardingStore = OnboardingStore() @State private var subscriptionStore = SubscriptionStore() @State private var syncStore = SyncStore() + @State private var gamificationService: GamificationService @State private var showPremiumPromo = false init() { - let schema = Schema([Plant.self, Encounter.self]) + let schema = Schema([Plant.self, Encounter.self, FieldProfile.self, Achievement.self]) // Try CloudKit first, fall back to local if unavailable var container: ModelContainer? @@ -49,7 +50,11 @@ struct FieldnoteApp: App { } self.sharedModelContainer = container! - self._appStore = State(initialValue: AppStore(modelContext: container!.mainContext)) + let appStoreInstance = AppStore(modelContext: container!.mainContext) + self._appStore = State(initialValue: appStoreInstance) + self._gamificationService = State( + initialValue: GamificationService(modelContext: container!.mainContext, appStore: appStoreInstance) + ) } var body: some Scene { @@ -65,6 +70,7 @@ struct FieldnoteApp: App { .environment(\.onboardingStore, onboardingStore) .environment(\.subscriptionStore, subscriptionStore) .environment(\.syncStore, syncStore) + .environment(\.gamificationService, gamificationService) .animation(.easeInOut(duration: 0.4), value: onboardingStore.shouldShowOnboarding) .preferredColorScheme(.light) .task { diff --git a/Fieldnote/Models/Achievement.swift b/Fieldnote/Models/Achievement.swift new file mode 100644 index 0000000..e4f2f40 --- /dev/null +++ b/Fieldnote/Models/Achievement.swift @@ -0,0 +1,24 @@ +// +// Achievement.swift +// Fieldnote +// +// Persisted unlock record for a badge (Wave 2). Badge *definitions* are static +// (see BadgeCatalog); these rows only track unlock state, mirroring how +// CatalogPlant definitions are static while user Plants are persisted. +// All stored properties are defaulted for CloudKit (.automatic) compatibility. +// + +import Foundation +import SwiftData + +@Model +final class Achievement { + var identifier: String = "" + var unlockedAt: Date? + var seen: Bool = false + + init(identifier: String = "", unlockedAt: Date? = nil) { + self.identifier = identifier + self.unlockedAt = unlockedAt + } +} diff --git a/Fieldnote/Models/BadgeCatalog.swift b/Fieldnote/Models/BadgeCatalog.swift new file mode 100644 index 0000000..510b317 --- /dev/null +++ b/Fieldnote/Models/BadgeCatalog.swift @@ -0,0 +1,59 @@ +// +// BadgeCatalog.swift +// Fieldnote +// +// Static badge definitions (Wave 2). Evaluated against a live stats snapshot by +// GamificationService; unlock state lives in Achievement rows. Mirrors the +// CatalogPlant.catalog pattern (bundled definitions, persisted user state). +// + +import Foundation + +enum BadgeCriterion { + case totalFinds(Int) + case uniqueSpecies(Int) + case uniqueFamilies(Int) + case uniqueLocations(Int) + case collectionPercent(Int) + case streak(Int) + case seasonalFinds(season: String, count: Int) +} + +struct BadgeDefinition: Identifiable { + let id: String + let title: String + let detail: String + let symbol: String + let criterion: BadgeCriterion + + /// Numeric target used for progress display. + var target: Int { + switch criterion { + case .totalFinds(let n), .uniqueSpecies(let n), .uniqueFamilies(let n), + .uniqueLocations(let n), .collectionPercent(let n), .streak(let n): + return n + case .seasonalFinds(_, let n): + return n + } + } +} + +enum BadgeCatalog { + static let all: [BadgeDefinition] = [ + .init(id: "first_find", title: "First Light", detail: "Log your first observation", symbol: "leaf.fill", criterion: .totalFinds(1)), + .init(id: "ten_finds", title: "Field Notes", detail: "Log 10 observations", symbol: "books.vertical.fill", criterion: .totalFinds(10)), + .init(id: "fifty_finds", title: "Dedicated Observer", detail: "Log 50 observations", symbol: "text.book.closed.fill", criterion: .totalFinds(50)), + .init(id: "species_5", title: "Curious Eye", detail: "Discover 5 species", symbol: "sparkles", criterion: .uniqueSpecies(5)), + .init(id: "species_25", title: "Naturalist", detail: "Discover 25 species", symbol: "leaf.circle.fill", criterion: .uniqueSpecies(25)), + .init(id: "families_5", title: "Branching Out", detail: "Record 5 plant families", symbol: "tree.fill", criterion: .uniqueFamilies(5)), + .init(id: "families_10", title: "Taxonomist", detail: "Record 10 plant families", symbol: "tree.circle.fill", criterion: .uniqueFamilies(10)), + .init(id: "locations_3", title: "Wanderer", detail: "Observe at 3 locations", symbol: "mappin.and.ellipse", criterion: .uniqueLocations(3)), + .init(id: "locations_10", title: "Cartographer", detail: "Observe at 10 locations", symbol: "map.fill", criterion: .uniqueLocations(10)), + .init(id: "collection_25", title: "Collector", detail: "Discover 25% of the catalog", symbol: "circle.dotted", criterion: .collectionPercent(25)), + .init(id: "collection_50", title: "Curator", detail: "Discover half the catalog", symbol: "circle.lefthalf.filled", criterion: .collectionPercent(50)), + .init(id: "collection_100", title: "Completionist", detail: "Discover the entire catalog", symbol: "circle.fill", criterion: .collectionPercent(100)), + .init(id: "streak_7", title: "Steadfast", detail: "A 7-day observation streak", symbol: "laurel.leading", criterion: .streak(7)), + .init(id: "streak_30", title: "Devoted", detail: "A 30-day observation streak", symbol: "rosette", criterion: .streak(30)), + .init(id: "winter_botanist", title: "Winter Botanist", detail: "Find 5 plants in winter", symbol: "snowflake", criterion: .seasonalFinds(season: "Winter", count: 5)) + ] +} diff --git a/Fieldnote/Models/FieldProfile.swift b/Fieldnote/Models/FieldProfile.swift new file mode 100644 index 0000000..d9c1a8b --- /dev/null +++ b/Fieldnote/Models/FieldProfile.swift @@ -0,0 +1,51 @@ +// +// FieldProfile.swift +// Fieldnote +// +// Persisted gamification profile (Wave 2). A single row, reconciled from +// encounter history so it's always recoverable after a CloudKit sync loss. +// All stored properties are defaulted for CloudKit (.automatic) compatibility. +// + +import Foundation +import SwiftData + +@Model +final class FieldProfile { + var totalXP: Int = 0 + var currentStreak: Int = 0 + var longestStreak: Int = 0 + var lastObservationDate: Date? + var createdAt: Date = Date.now + + init() {} + + // MARK: - Derived + + var level: Int { GamificationMath.level(forXP: totalXP) } + + var xpIntoLevel: Int { totalXP - GamificationMath.xpThreshold(forLevel: level) } + + var xpForNextLevel: Int { + GamificationMath.xpThreshold(forLevel: level + 1) - GamificationMath.xpThreshold(forLevel: level) + } + + var levelProgress: Double { + xpForNextLevel > 0 ? min(1, Double(xpIntoLevel) / Double(xpForNextLevel)) : 0 + } +} + +/// Level curve: cumulative XP to reach level L is `50 · (L-1) · L`. +/// L1 = 0, L2 = 100, L3 = 300, L4 = 600, L5 = 1000 … +enum GamificationMath { + static func xpThreshold(forLevel level: Int) -> Int { + guard level > 1 else { return 0 } + return 50 * (level - 1) * level + } + + static func level(forXP xp: Int) -> Int { + var level = 1 + while xpThreshold(forLevel: level + 1) <= xp { level += 1 } + return level + } +} diff --git a/Fieldnote/Screens/Capture/CaptureReviewSheet.swift b/Fieldnote/Screens/Capture/CaptureReviewSheet.swift index 7312115..254d1ae 100644 --- a/Fieldnote/Screens/Capture/CaptureReviewSheet.swift +++ b/Fieldnote/Screens/Capture/CaptureReviewSheet.swift @@ -15,6 +15,7 @@ struct CaptureReviewSheet: View { var captureMode: CaptureMode @Environment(\.dismiss) private var dismiss + @Environment(\.gamificationService) private var gamification @State private var commonName: String @State private var scientificName: String @@ -541,6 +542,9 @@ struct CaptureReviewSheet: View { } } + // Update streak, XP, and badge unlocks from the new observation. + gamification?.recordObservation() + isSaving = false // Dismiss and reset diff --git a/Fieldnote/Screens/MainTabView.swift b/Fieldnote/Screens/MainTabView.swift index 9f7c8dd..cd3c981 100644 --- a/Fieldnote/Screens/MainTabView.swift +++ b/Fieldnote/Screens/MainTabView.swift @@ -57,14 +57,14 @@ struct MainTabView: View { } .tag(Tab.explore) - // Settings Tab + // Profile Tab NavigationStack { - SettingsView() + ProfileView() } .tabItem { - Label("Settings", systemImage: "gearshape.fill") + Label("Profile", systemImage: "person.crop.circle.fill") } - .tag(Tab.settings) + .tag(Tab.profile) } .tint(FieldColor.accent) diff --git a/Fieldnote/Screens/Profile/ProfileView.swift b/Fieldnote/Screens/Profile/ProfileView.swift new file mode 100644 index 0000000..eb1a11b --- /dev/null +++ b/Fieldnote/Screens/Profile/ProfileView.swift @@ -0,0 +1,488 @@ +// +// ProfileView.swift +// Fieldnote +// +// Profile tab (Wave 2): the gamification home — level/XP, streak, collection, +// and the badge grid — with the existing Settings reachable below. +// + +import SwiftUI +import SwiftData + +struct ProfileView: View { + @Environment(\.gamificationService) private var gamification + @State private var isBadgeGridExpanded = false + + var body: some View { + ScrollView { + VStack(spacing: FieldSpace.lg) { + if let gamification { + let stats = gamification.snapshot() + let profile = gamification.profile() + + levelHero(profile: profile, stats: stats) + statTiles(stats) + badgeGrid(gamification: gamification, stats: stats) + } + settingsLink + } + .padding(FieldSpace.md) + .padding(.bottom, 40) + } + .background( + LinearGradient(colors: [FieldColor.canvasTop, FieldColor.canvasBottom], + startPoint: .top, endPoint: .bottom) + .ignoresSafeArea() + ) + .navigationTitle("Profile") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Level hero + + private func levelHero(profile: FieldProfile, stats: GamificationService.Stats) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text("LEVEL") + .font(.system(size: 11, weight: .semibold)) + .tracking(2) + .foregroundStyle(.white.opacity(0.75)) + Text("\(profile.level)") + .font(FieldType.displayTitle) + .foregroundStyle(.white) + } + Spacer() + HStack(spacing: 4) { + Image(systemName: "laurel.leading") + Text("\(profile.currentStreak)") + .monospacedDigit() + Image(systemName: "laurel.trailing") + } + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 12).padding(.vertical, 7) + .background(.white.opacity(0.18), in: Capsule()) + } + + VStack(alignment: .leading, spacing: 7) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.25)) + Capsule().fill(.white) + .frame(width: max(8, geo.size.width * profile.levelProgress)) + } + } + .frame(height: 8) + Text("\(profile.xpIntoLevel) / \(profile.xpForNextLevel) XP to level \(profile.level + 1)") + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(.white.opacity(0.85)) + } + } + .padding(20) + .background( + LinearGradient(colors: [FieldColor.accent, FieldColor.accentDeep], + startPoint: .topLeading, endPoint: .bottomTrailing), + in: RoundedRectangle(cornerRadius: 24, style: .continuous) + ) + .fieldShadow(FieldShadow.cardHover) + } + + // MARK: - Stat tiles + + private func statTiles(_ stats: GamificationService.Stats) -> some View { + HStack(spacing: FieldSpace.sm) { + statTile(value: stats.uniqueSpecies, label: "Species") + statTile(value: stats.uniqueFamilies, label: "Families") + statTile(value: stats.uniqueLocations, label: "Places") + statTile(value: stats.collectionPercent, label: "Catalog", suffix: "%") + } + } + + private func statTile(value: Int, label: String, suffix: String = "") -> some View { + VStack(spacing: 3) { + Text("\(value)\(suffix)") + .font(FieldType.title3) + .foregroundStyle(FieldColor.ink) + Text(label) + .font(FieldType.caption) + .foregroundStyle(FieldColor.mutedInk) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(FieldColor.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .fieldShadow(FieldShadow.card) + } + + // MARK: - Badges + + private func badgeGrid(gamification: GamificationService, stats: GamificationService.Stats) -> some View { + let unlockDates = gamification.achievements().reduce(into: [String: Date]()) { dates, achievement in + guard let unlockedAt = achievement.unlockedAt else { return } + dates[achievement.identifier] = max(dates[achievement.identifier] ?? .distantPast, unlockedAt) + } + let items = BadgeCatalog.all.map { badge in + BadgeDisplayItem( + badge: badge, + progress: gamification.progress(for: badge, stats: stats), + unlockedAt: unlockDates[badge.id] + ) + } + let recentlyEarned = items + .filter { $0.unlockedAt != nil } + .sorted(by: BadgeDisplayItem.wasEarnedMoreRecently) + let closestLocked = items + .filter { $0.unlockedAt == nil } + .sorted(by: BadgeDisplayItem.isCloserToCompletion) + + var featured = Array(recentlyEarned.prefix(2)) + if let closest = closestLocked.first { + featured.append(closest) + } + if featured.count < 3 { + let featuredIDs = Set(featured.map(\.id)) + featured.append( + contentsOf: items + .filter { !featuredIDs.contains($0.id) } + .prefix(3 - featured.count) + ) + } + let featuredIDs = Set(featured.map(\.id)) + let remaining = items.filter { !featuredIDs.contains($0.id) } + let unlockedCount = items.filter(\.isUnlocked).count + let columns = [GridItem(.adaptive(minimum: 96), spacing: FieldSpace.md)] + + return VStack(spacing: 0) { + HStack(alignment: .firstTextBaseline) { + Text("Badges") + .font(FieldType.title3) + .foregroundStyle(FieldColor.ink) + + Spacer() + + Text("\(unlockedCount) / \(BadgeCatalog.all.count)") + .font(FieldType.footnote.weight(.semibold)) + .foregroundStyle(FieldColor.mutedInk) + + Button(action: toggleBadgeGrid) { + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(FieldColor.mutedInk) + .rotationEffect(.degrees(isBadgeGridExpanded ? 180 : 0)) + .frame(width: 30, height: 30) + .background(FieldColor.separator.opacity(0.55), in: Circle()) + } + .buttonStyle(.plain) + .frame(width: 44, height: 44) + .contentShape(.circle) + .accessibilityLabel(isBadgeGridExpanded ? "Hide all badges" : "Show all badges") + .accessibilityHint(isBadgeGridExpanded ? "Collapses the remaining badges" : "Expands the remaining badges") + } + .padding(.horizontal, FieldSpace.md) + .padding(.vertical, 10) + + HStack(alignment: .top, spacing: FieldSpace.sm) { + ForEach(featured) { item in + FeaturedBadgeTile(item: item) + } + } + .padding(.horizontal, 12) + + if isBadgeGridExpanded { + + LazyVGrid(columns: columns, spacing: FieldSpace.md) { + ForEach(remaining) { item in + BadgeCell( + badge: item.badge, + isUnlocked: item.isUnlocked, + progress: item.progress + ) + } + } + .padding(.horizontal, FieldSpace.sm) + .padding(.vertical, FieldSpace.sm) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .background(FieldColor.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .fieldShadow(FieldShadow.card) + .animation(.snappy(duration: 0.25), value: isBadgeGridExpanded) + } + + private func toggleBadgeGrid() { + isBadgeGridExpanded.toggle() + } + + // MARK: - Settings link + + private var settingsLink: some View { + NavigationLink { + SettingsView() + } label: { + HStack(spacing: FieldSpace.sm) { + Image(systemName: "gearshape.fill") + .foregroundStyle(FieldColor.mutedInk) + Text("Settings & Storage") + .font(FieldType.body) + .foregroundStyle(FieldColor.ink) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(FieldColor.tertiaryInk) + } + .padding(16) + .background(FieldColor.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .fieldShadow(FieldShadow.card) + } + .buttonStyle(.plain) + } +} + +// MARK: - Featured badges + +private struct BadgeDisplayItem: Identifiable { + let badge: BadgeDefinition + let progress: Double + let unlockedAt: Date? + + var id: String { badge.id } + var isUnlocked: Bool { unlockedAt != nil } + + nonisolated static func wasEarnedMoreRecently(_ lhs: Self, _ rhs: Self) -> Bool { + let lhsDate = lhs.unlockedAt ?? .distantPast + let rhsDate = rhs.unlockedAt ?? .distantPast + if lhsDate != rhsDate { + return lhsDate > rhsDate + } + return lhs.badge.title < rhs.badge.title + } + + nonisolated static func isCloserToCompletion(_ lhs: Self, _ rhs: Self) -> Bool { + if lhs.progress != rhs.progress { + return lhs.progress > rhs.progress + } + return lhs.badge.title < rhs.badge.title + } +} + +private struct FeaturedBadgeTile: View { + let item: BadgeDisplayItem + + var body: some View { + VStack(spacing: FieldSpace.sm) { + ZStack { + Circle() + .fill(item.isUnlocked ? FieldColor.accent.opacity(0.16) : FieldColor.separator.opacity(0.65)) + + if !item.isUnlocked { + Circle() + .stroke(FieldColor.separator, lineWidth: 3) + Circle() + .trim(from: 0, to: item.progress) + .stroke( + FieldColor.accent.opacity(0.8), + style: StrokeStyle(lineWidth: 3, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + } + + Image(systemName: item.badge.symbol) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(item.isUnlocked ? FieldColor.accentDeep : FieldColor.tertiaryInk) + } + .frame(width: 52, height: 52) + + Text(item.badge.title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(item.isUnlocked ? FieldColor.ink : FieldColor.mutedInk) + .multilineTextAlignment(.center) + .lineLimit(2) + .frame(maxWidth: .infinity, minHeight: 30, alignment: .top) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + } +} + +// MARK: - Badge cell + +private struct BadgeCell: View { + let badge: BadgeDefinition + let isUnlocked: Bool + let progress: Double + + var body: some View { + VStack(spacing: 8) { + ZStack { + Circle() + .fill(isUnlocked ? FieldColor.accent.opacity(0.16) : FieldColor.separator.opacity(0.6)) + Image(systemName: badge.symbol) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(isUnlocked ? FieldColor.accentDeep : FieldColor.tertiaryInk) + } + .frame(width: 58, height: 58) + + Text(badge.title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(isUnlocked ? FieldColor.ink : FieldColor.mutedInk) + .multilineTextAlignment(.center) + .lineLimit(2) + .frame(height: 30, alignment: .top) + + if !isUnlocked { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(FieldColor.separator) + Capsule().fill(FieldColor.accent.opacity(0.7)) + .frame(width: max(0, geo.size.width * progress)) + } + } + .frame(height: 4) + .padding(.horizontal, 6) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .opacity(isUnlocked ? 1 : 0.85) + } +} + +// MARK: - Preview + +#if DEBUG +@MainActor +private struct ProfilePreviewHost: View { + @State private var appStore: AppStore + @State private var gamification: GamificationService + + private let container: ModelContainer + + init() { + let schema = Schema([Plant.self, Encounter.self, FieldProfile.self, Achievement.self]) + let configuration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: true, + cloudKitDatabase: .none + ) + let container = try! ModelContainer(for: schema, configurations: [configuration]) + let context = container.mainContext + + for plant in Self.samplePlants() { + context.insert(plant) + } + + let earnedBadges: [(id: String, daysAgo: Int)] = [ + ("locations_3", 2), + ("families_5", 5), + ("species_5", 8), + ("ten_finds", 12), + ("first_find", 20), + ("streak_7", 30) + ] + for earnedBadge in earnedBadges { + context.insert( + Achievement( + identifier: earnedBadge.id, + unlockedAt: Date.now.addingTimeInterval(-Double(earnedBadge.daysAgo) * 86_400) + ) + ) + } + try! context.save() + + let appStore = AppStore(modelContext: context) + let gamification = GamificationService(modelContext: context, appStore: appStore) + + self.container = container + _appStore = State(initialValue: appStore) + _gamification = State(initialValue: gamification) + } + + var body: some View { + NavigationStack { + ProfileView() + } + .environment(\.appStore, appStore) + .environment(\.gamificationService, gamification) + .modelContainer(container) + .preferredColorScheme(.light) + } + + private static func samplePlants() -> [Plant] { + func encounter(daysAgo: Int, location: String, symbol: String) -> Encounter { + Encounter( + date: Date.now.addingTimeInterval(-Double(daysAgo) * 86_400), + locationLabel: location, + photoPlaceholder: symbol, + confidence: 0.9 + ) + } + + return [ + Plant( + commonName: "Red Maple", + scientificName: "Acer rubrum", + family: "Sapindaceae", + encounters: [ + encounter(daysAgo: 20, location: "Riverside Park", symbol: "leaf.fill"), + encounter(daysAgo: 28, location: "Botanical Garden", symbol: "leaf.fill") + ] + ), + Plant( + commonName: "Black-eyed Susan", + scientificName: "Rudbeckia hirta", + family: "Asteraceae", + encounters: [ + encounter(daysAgo: 21, location: "Meadow Loop", symbol: "sun.max.fill"), + encounter(daysAgo: 32, location: "Riverside Park", symbol: "sun.max.fill") + ] + ), + Plant( + commonName: "Common Milkweed", + scientificName: "Asclepias syriaca", + family: "Apocynaceae", + encounters: [ + encounter(daysAgo: 23, location: "Meadow Loop", symbol: "allergens.fill"), + encounter(daysAgo: 35, location: "Lakeside Trail", symbol: "allergens.fill") + ] + ), + Plant( + commonName: "Queen Anne's Lace", + scientificName: "Daucus carota", + family: "Apiaceae", + encounters: [ + encounter(daysAgo: 24, location: "Lakeside Trail", symbol: "camera.macro") + ] + ), + Plant( + commonName: "New England Aster", + scientificName: "Symphyotrichum novae-angliae", + family: "Asteraceae", + encounters: [ + encounter(daysAgo: 26, location: "Botanical Garden", symbol: "sparkles") + ] + ), + Plant( + commonName: "Garden Sage", + scientificName: "Salvia officinalis", + family: "Lamiaceae", + encounters: [ + encounter(daysAgo: 29, location: "Botanical Garden", symbol: "leaf.circle.fill") + ] + ), + Plant( + commonName: "Calendula", + scientificName: "Calendula officinalis", + family: "Asteraceae", + encounters: [ + encounter(daysAgo: 31, location: "Riverside Park", symbol: "sun.max.circle.fill") + ] + ) + ] + } +} + +#Preview("Profile — Mock Data") { + ProfilePreviewHost() +} +#endif diff --git a/Fieldnote/Services/GamificationService.swift b/Fieldnote/Services/GamificationService.swift new file mode 100644 index 0000000..d48c9bb --- /dev/null +++ b/Fieldnote/Services/GamificationService.swift @@ -0,0 +1,184 @@ +// +// GamificationService.swift +// Fieldnote +// +// Wave 2 gamification engine. Derives streak / XP / collection stats from the +// live AppStore signals (so they're always recomputable), persists a FieldProfile +// cache + Achievement unlock rows, and exposes a single recordObservation() hook +// called from the capture save flow. +// + +import Foundation +import SwiftUI +import SwiftData + +@MainActor +@Observable +final class GamificationService { + private let modelContext: ModelContext + private unowned let appStore: AppStore + + /// Badges newly unlocked this session, awaiting a celebration toast. + var pendingCelebrations: [BadgeDefinition] = [] + + init(modelContext: ModelContext, appStore: AppStore) { + self.modelContext = modelContext + self.appStore = appStore + reconcile() + } + + // MARK: - Stats snapshot + + struct Stats { + var totalFinds: Int + var uniqueSpecies: Int + var uniqueFamilies: Int + var uniqueLocations: Int + var collectionPercent: Int + var currentStreak: Int + var season: String + var seasonalFinds: Int + } + + func snapshot() -> Stats { + let encounters = appStore.allEncounters + let catalogTotal = appStore.catalogPlants.count + let discovered = appStore.discoveredCatalogPlants.count + let percent = catalogTotal > 0 ? Int((Double(discovered) / Double(catalogTotal) * 100).rounded()) : 0 + let season = Self.seasonName(.now) + let year = Calendar.current.component(.year, from: .now) + let seasonalFinds = encounters.filter { + Self.seasonName($0.date) == season && + Calendar.current.component(.year, from: $0.date) == year + }.count + + return Stats( + totalFinds: encounters.count, + uniqueSpecies: appStore.plants.count, + uniqueFamilies: appStore.uniqueFamilies.count, + uniqueLocations: appStore.uniqueLocations.count, + collectionPercent: percent, + currentStreak: Self.streak(from: encounters), + season: season, + seasonalFinds: seasonalFinds + ) + } + + // MARK: - Persisted reads + + func profile() -> FieldProfile { + if let existing = (try? modelContext.fetch(FetchDescriptor()))?.first { + return existing + } + let created = FieldProfile() + modelContext.insert(created) + return created + } + + func achievements() -> [Achievement] { + (try? modelContext.fetch(FetchDescriptor())) ?? [] + } + + private func unlockedIdentifiers() -> Set { + Set(achievements().filter { $0.unlockedAt != nil }.map(\.identifier)) + } + + // MARK: - Hook + + /// Called once from the capture save flow after an encounter is added. + func recordObservation() { + reconcile(celebrate: true) + } + + func markCelebrationsSeen() { + pendingCelebrations.removeAll() + } + + // MARK: - Reconcile + + /// Recompute derived stats, unlock any newly-satisfied badges, and persist. + func reconcile(celebrate: Bool = false) { + let stats = snapshot() + var unlocked = unlockedIdentifiers() + + for badge in BadgeCatalog.all where !unlocked.contains(badge.id) { + if Self.isSatisfied(badge.criterion, stats: stats) { + modelContext.insert(Achievement(identifier: badge.id, unlockedAt: .now)) + unlocked.insert(badge.id) + if celebrate { pendingCelebrations.append(badge) } + } + } + + let p = profile() + p.currentStreak = stats.currentStreak + p.longestStreak = max(p.longestStreak, stats.currentStreak) + p.totalXP = Self.xp(for: stats, unlockedCount: unlocked.count) + p.lastObservationDate = appStore.allEncounters.first?.date + + try? modelContext.save() + } + + // MARK: - Badge evaluation + + static func isSatisfied(_ criterion: BadgeCriterion, stats: Stats) -> Bool { + switch criterion { + case .totalFinds(let n): return stats.totalFinds >= n + case .uniqueSpecies(let n): return stats.uniqueSpecies >= n + case .uniqueFamilies(let n): return stats.uniqueFamilies >= n + case .uniqueLocations(let n): return stats.uniqueLocations >= n + case .collectionPercent(let n): return stats.collectionPercent >= n + case .streak(let n): return stats.currentStreak >= n + case .seasonalFinds(let season, let count): + return stats.season == season && stats.seasonalFinds >= count + } + } + + func progress(for badge: BadgeDefinition, stats: Stats) -> Double { + let current: Int + switch badge.criterion { + case .totalFinds: current = stats.totalFinds + case .uniqueSpecies: current = stats.uniqueSpecies + case .uniqueFamilies: current = stats.uniqueFamilies + case .uniqueLocations: current = stats.uniqueLocations + case .collectionPercent: current = stats.collectionPercent + case .streak: current = stats.currentStreak + case .seasonalFinds(let season, _): current = stats.season == season ? stats.seasonalFinds : 0 + } + return badge.target > 0 ? min(1, Double(current) / Double(badge.target)) : 0 + } + + // MARK: - Derivation helpers + + static func xp(for stats: Stats, unlockedCount: Int) -> Int { + stats.totalFinds * 10 + stats.uniqueSpecies * 15 + stats.uniqueFamilies * 20 + unlockedCount * 50 + } + + static func seasonName(_ date: Date) -> String { + switch Calendar.current.component(.month, from: date) { + case 12, 1, 2: return "Winter" + case 3, 4, 5: return "Spring" + case 6, 7, 8: return "Summer" + default: return "Autumn" + } + } + + static func streak(from encounters: [Encounter]) -> Int { + let cal = Calendar.current + let days = Set(encounters.map { cal.startOfDay(for: $0.date) }) + guard !days.isEmpty else { return 0 } + + var day = cal.startOfDay(for: .now) + if !days.contains(day) { + guard let yesterday = cal.date(byAdding: .day, value: -1, to: day), + days.contains(yesterday) else { return 0 } + day = yesterday + } + var streak = 0 + while days.contains(day) { + streak += 1 + guard let prev = cal.date(byAdding: .day, value: -1, to: day) else { break } + day = prev + } + return streak + } +} diff --git a/Fieldnote/Store/AppStore.swift b/Fieldnote/Store/AppStore.swift index 31c1544..394af6f 100644 --- a/Fieldnote/Store/AppStore.swift +++ b/Fieldnote/Store/AppStore.swift @@ -15,6 +15,7 @@ enum Tab: Int { case capture case explore case settings + case profile } @MainActor diff --git a/Fieldnote/Store/AppStoreEnvironment.swift b/Fieldnote/Store/AppStoreEnvironment.swift index 4c88173..12727dd 100644 --- a/Fieldnote/Store/AppStoreEnvironment.swift +++ b/Fieldnote/Store/AppStoreEnvironment.swift @@ -56,3 +56,16 @@ extension EnvironmentValues { set { self[SyncStoreKey.self] = newValue } } } + +// Environment key for GamificationService +// Note: injected at app level with the actual ModelContext + AppStore. +private struct GamificationServiceKey: EnvironmentKey { + @MainActor static let defaultValue: GamificationService? = nil +} + +extension EnvironmentValues { + var gamificationService: GamificationService? { + get { self[GamificationServiceKey.self] } + set { self[GamificationServiceKey.self] = newValue } + } +} diff --git a/Fieldnote/Theme/FieldColor.swift b/Fieldnote/Theme/FieldColor.swift index 3cf495a..84a7fa1 100644 --- a/Fieldnote/Theme/FieldColor.swift +++ b/Fieldnote/Theme/FieldColor.swift @@ -96,6 +96,28 @@ struct FieldColor { /// Success state static let successGreen = confidenceHigh + + // MARK: - Modern Redesign (2026) — "Blend" direction + // Additive tokens for the modernized, glass-forward look. Light "Herbarium" + // base with an immersive "Dusk" treatment reserved for full-bleed photo/detail/map. + + /// Deep botanical green — gradient base for rings, progress, the Capture button. + static let accentDeep = Color(red: 0.23, green: 0.48, blue: 0.36) // #3B7A5C + + /// Bright botanical green — accents on dark immersive surfaces. + static let accentBright = Color(red: 0.56, green: 0.78, blue: 0.49) // #8FC77D + + /// Warm canvas gradient start (modern app background). + static let canvasTop = Color(red: 0.985, green: 0.965, blue: 0.945) // #FBF6F1 + + /// Warm canvas gradient end (modern app background). + static let canvasBottom = Color(red: 0.945, green: 0.918, blue: 0.875) // #F1EADF + + /// Near-black ink used for full-bleed photo scrims (immersive "Dusk" treatment). + static let photoScrim = Color(red: 0.07, green: 0.06, blue: 0.04) // #121009 + + /// Warm ember — streak accent. + static let ember = Color(red: 0.76, green: 0.38, blue: 0.17) // #C2602B } // MARK: - Helper for Confidence Colors From 74fceb50a1da89f4190a0192fe1536a812e9af5f Mon Sep 17 00:00:00 2001 From: Tobias Fu Date: Wed, 24 Jun 2026 16:10:29 -0700 Subject: [PATCH 2/6] Locale-aware catalog PR1: local relevance data layer Milestone 1 data layer for locale-aware discovery (client-side, no backend): - CatalogPlant: optional gbifTaxonKey / inaturalistTaxonID / monthlyAffinity plus a scientific-name join helper for matching external taxa. - INaturalistService: keyless species_counts client returning nearby + monthly taxa with default-photo license metadata. - LocalityProfile: coarse grid cell (privacy-preserving) + month/hemisphere. - LocalCatalogCache: per cell+month cache of species counts with freshness. - LocalRankingService: pure Stage-1 ecological ranking (log-scaled occurrence + seasonal affinity) producing explainable LocalCatalogItems, plus a visual-dominant identification reranker. - PlantIDAPIService: returns top-N candidates (PlantIdentificationCandidate) with optional GBIF key; single-result identify() delegates to it. See Docs/LocaleAwareCatalogImplementationPlan.md. Co-Authored-By: Claude Opus 4.8 --- .../LocaleAwareCatalogImplementationPlan.md | 140 ++++++++++ Fieldnote/Models/LocalityProfile.swift | 77 ++++++ Fieldnote/Models/PlantCatalog.swift | 57 +++- Fieldnote/Services/INaturalistService.swift | 180 ++++++++++++ Fieldnote/Services/LocalCatalogCache.swift | 85 ++++++ Fieldnote/Services/LocalRankingService.swift | 261 ++++++++++++++++++ Fieldnote/Services/PlantIDAPIService.swift | 73 ++++- 7 files changed, 862 insertions(+), 11 deletions(-) create mode 100644 Fieldnote/Docs/LocaleAwareCatalogImplementationPlan.md create mode 100644 Fieldnote/Models/LocalityProfile.swift create mode 100644 Fieldnote/Services/INaturalistService.swift create mode 100644 Fieldnote/Services/LocalCatalogCache.swift create mode 100644 Fieldnote/Services/LocalRankingService.swift diff --git a/Fieldnote/Docs/LocaleAwareCatalogImplementationPlan.md b/Fieldnote/Docs/LocaleAwareCatalogImplementationPlan.md new file mode 100644 index 0000000..6ac9f05 --- /dev/null +++ b/Fieldnote/Docs/LocaleAwareCatalogImplementationPlan.md @@ -0,0 +1,140 @@ +# Locale-Aware Catalog — Implementation Plan (Milestone 1) + +Plan date: June 24, 2026 +Companion to: `LocaleAwareCatalogResearch.md` + +## Scope + +Milestone 1 only: **prove local relevance, client-side**. No backend. +iNaturalist `species_counts` is free and keyless; the Pl@ntNet key already ships +in `Config.plist`. Backend, region packs, and key-proxying are deferred to +Milestone 2. + +Goal test (from research §Success measures): users in different cities see +visibly different Explore screens before recording anything. + +## Current state (grounding) + +- `CatalogPlant.catalog` — static 50 items, identity by random `UUID`, matched to + results by normalized name. `Fieldnote/Models/PlantCatalog.swift`, + `Fieldnote/Models/Mock/PlantCatalog+Mock.swift`. +- `ExploreView.browseSections` — Recently Encountered → Custom Plants → full + catalog in bundled order. Commented-out `NearMeSection` + a GPS TODO. + `Fieldnote/Screens/Explore/ExploreView.swift`. +- `PlantIDAPIService.identify` — keeps only `results.first`; accepts `location` + but never sends it. `Fieldnote/Services/PlantIDAPIService.swift:153`. +- `LocationService` — one-shot fetcher for capture tagging only; does not feed the + catalog. `Fieldnote/Services/LocationService.swift`. +- `AppStore` — exposes `catalogPlants`, `isDiscovered`, `undiscoveredPlants`, + `refresh()`. `Fieldnote/Store/AppStore.swift`. + +## Privacy posture + +Send only a **coarse cell** (rounded lat/lng grid) to iNaturalist. Precise +encounter coordinates stay local to the observation. Request approximate +location; offer manual region as fallback. + +--- + +## Workstream A — Data layer (first PR, UI untouched) + +### A1. Stable taxon identity +`Fieldnote/Models/PlantCatalog.swift` +- Add optional fields to `CatalogPlant`: `gbifTaxonKey: Int?`, + `inaturalistTaxonID: Int?`, `monthlyAffinity: [Double]?` (12 entries). +- Keep `UUID` identity for now (don't break bundled data or discovery matching). +- Add a normalized-scientific-name join helper so iNat taxa map to catalog + entries by `scientificName` when IDs are absent (reuse existing `normalize`). + +### A2. iNaturalist client +`Fieldnote/Services/INaturalistService.swift` (new) — `actor`, shaped like +`PlantIDAPIService`. +- `func speciesCounts(lat:lng:radiusKm:month:) async throws -> [INatSpeciesCount]` +- Endpoint: `GET /v1/observations/species_counts`, `quality_grade=research`, + `taxon_id=47126` (plants), `radius`, `month`, `lat`, `lng`. +- Response model: taxon id, accepted name, common name, count, default photo URL. +- Respect ~1 req/sec, 10k/day — one fetch per cell-change or month is enough. + +### A3. Locality profile + cache +`Fieldnote/Models/LocalityProfile.swift` (new) +- `coarseCellID` (rounded grid), `displayRegion`, `countryCode`, `hemisphere`, + `currentMonth`, `generatedAt`. +`Fieldnote/Services/LocalCatalogCache.swift` (new) +- Persist last `species_counts` keyed by `coarseCellID + month` with freshness + date. Codable file in Application Support (pattern: `PhotoStorageService`). + +### A4. Stage-1 ranking (pure, testable) +`Fieldnote/Services/LocalRankingService.swift` (new) +- Pure functions, no I/O. Produces `rankScore` + `explanationCodes` + (`nearby_now`, `seasonal_peak`, `easy_first_find`) per catalog taxon. +- **Log-scale** occurrence counts so urban weeds don't dominate. +- Stage-1 weights only (occurrence / seasonal affinity / recency). Defer the + personal + editorial Stage-2 model to Milestone 2. +- `LocalCatalogItem` view model: `taxonID`, `nearbyObservationCount`, + `rankScore`, `explanationCodes`. + +### A5. Candidate reranking for identification +`Fieldnote/Services/PlantIDAPIService.swift` +- Return top **3–5** candidates, not `results.first` (new candidates result + type or extend the existing one). +- Actually forward `location` to the Pl@ntNet request. +`HybridPlantIdentificationService` already plumbs `location` — stop dropping it. +- In `LocalRankingService`, combine `visualLikelihood × localPrior × + seasonalPrior`, **visual signal dominant** (research §291). + +### A6. Tests (Swift Testing) +- `LocalRankingService`: log-scaling, explanation codes, visual-dominant combine. +- iNat → catalog name join. + +--- + +## Workstream B — Explore + identification UI (second PR) + +### B1. AppStore wiring +`Fieldnote/Store/AppStore.swift` +- Observable state: `localityProfile`, `localCatalogItems`, + `catalogFreshnessDate`. +- `func refreshLocalCatalog() async`; call from `refresh()` and `.refreshable`. + +### B2. Location-value prompt +- Pre-permission explainer ("See plants reported near you and what's active this + season") with **Use Approximate Location** / **Choose a City**. +- Update `NSLocationWhenInUseUsageDescription` (Info.plist) to mention local + discovery, not just observation tagging. + +### B3. Explore sections +`Fieldnote/Screens/Explore/ExploreView.swift` + `Components/` +- Replace `browseSections` with ecology-led order: **Near You Now**, + **Reported This Month**, then existing catalog as fallback. Reuse + `NearMeSection`. +- Per-card **"Why this plant?"** line from `explanationCodes`. Use + **"reported nearby"** wording — no abundance claims. +- Minimal **region picker**: current location vs. one chosen city (enough to + prove the travel case). Changing Explore region must NOT change the region + stored on an observation. + +### B4. Capture review alternatives +`Fieldnote/Screens/Capture/CaptureReviewSheet.swift` +- Show reranked top candidate plus alternatives when scores are close, instead of + presenting `results.first` as certainty. + +--- + +## Sequencing + +1. **PR 1 — data layer:** A1–A6, behind the existing Explore UI (no visible + change). Independently reviewable. +2. **PR 2 — UI:** B1–B4. + +## Out of scope (Milestone 2+) + +Canonical taxon IDs as primary identity, backend scheduler + versioned region +manifests, offline region packs, saved/travel regions, curated collections, +key-proxying off-device, illustration factory, WeatherKit/ecoregion enrichment. + +## Risks / safeguards (carried from research) + +- Crowdsourcing bias → "reported nearby," never abundance claims. +- Location privacy → coarse cell to iNat, precise coords stay local. +- Rate limits → cache per cell + month, refresh on change only. +- Vendor dependency → keep the bundled 50-item fallback catalog working offline. diff --git a/Fieldnote/Models/LocalityProfile.swift b/Fieldnote/Models/LocalityProfile.swift new file mode 100644 index 0000000..d480d6c --- /dev/null +++ b/Fieldnote/Models/LocalityProfile.swift @@ -0,0 +1,77 @@ +// +// LocalityProfile.swift +// Fieldnote +// +// A coarse, privacy-preserving description of "where + when" used to fetch and +// rank a locale-aware catalog. We deliberately round coordinates to a grid cell +// so precise encounter coordinates never leave the device. +// See LocaleAwareCatalogImplementationPlan.md. +// + +import Foundation +import CoreLocation + +struct LocalityProfile: Codable, Hashable { + /// Grid resolution in degrees. ~0.1° ≈ 11 km at the equator — coarse enough + /// to avoid pinpointing the user while still being locally meaningful. + static let cellSizeDegrees = 0.1 + + /// Stable identifier for the rounded grid cell, e.g. "37.8,-122.4". + let coarseCellID: String + /// Cell-center latitude (rounded). Safe to send to iNaturalist. + let latitude: Double + /// Cell-center longitude (rounded). Safe to send to iNaturalist. + let longitude: Double + /// Optional human-readable region for display, e.g. "San Francisco Bay". + var displayRegion: String? + /// ISO country code when known. + var countryCode: String? + /// Northern/southern hemisphere — drives seasonal interpretation. + let hemisphere: Hemisphere + /// Calendar month 1...12 the profile was generated for. + let currentMonth: Int + /// When this profile was created. + let generatedAt: Date + + enum Hemisphere: String, Codable { + case northern + case southern + } + + /// The coarse cell center as a coordinate, suitable for an iNaturalist query. + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} + +extension LocalityProfile { + /// Builds a profile by snapping a precise coordinate to the coarse grid. + /// The precise coordinate is consumed here and never stored. + static func make( + from coordinate: CLLocationCoordinate2D, + displayRegion: String? = nil, + countryCode: String? = nil, + now: Date = .now, + calendar: Calendar = .current + ) -> LocalityProfile { + let cell = Self.cellSizeDegrees + let lat = (coordinate.latitude / cell).rounded() * cell + let lng = (coordinate.longitude / cell).rounded() * cell + let id = String(format: "%.1f,%.1f", lat, lng) + let month = calendar.component(.month, from: now) + + return LocalityProfile( + coarseCellID: id, + latitude: lat, + longitude: lng, + displayRegion: displayRegion, + countryCode: countryCode, + hemisphere: lat >= 0 ? .northern : .southern, + currentMonth: month, + generatedAt: now + ) + } + + /// Cache key combining cell + month, since seasonality is month-specific. + var cacheKey: String { "\(coarseCellID)@\(currentMonth)" } +} diff --git a/Fieldnote/Models/PlantCatalog.swift b/Fieldnote/Models/PlantCatalog.swift index f47b389..ff1daee 100644 --- a/Fieldnote/Models/PlantCatalog.swift +++ b/Fieldnote/Models/PlantCatalog.swift @@ -18,6 +18,16 @@ struct CatalogPlant: Identifiable, Codable, Hashable { let traits: [String] let defaultPlaceholder: String // SF Symbol name + // MARK: Locale-aware identity (optional; see LocaleAwareCatalogImplementationPlan.md) + + /// Canonical GBIF taxon key, when known. Stable across name changes. + let gbifTaxonKey: Int? + /// iNaturalist taxon ID, used to join against `observations/species_counts`. + let inaturalistTaxonID: Int? + /// Per-month seasonal affinity, 12 entries (Jan...Dec), normalized 0...1. + /// `nil` when no seasonal data has been computed for this taxon yet. + let monthlyAffinity: [Double]? + init( id: UUID = UUID(), commonName: String, @@ -27,7 +37,10 @@ struct CatalogPlant: Identifiable, Codable, Hashable { nativeRange: String = "", summary: String = "", traits: [String], - defaultPlaceholder: String = "leaf.fill" + defaultPlaceholder: String = "leaf.fill", + gbifTaxonKey: Int? = nil, + inaturalistTaxonID: Int? = nil, + monthlyAffinity: [Double]? = nil ) { self.id = id self.commonName = commonName @@ -38,6 +51,9 @@ struct CatalogPlant: Identifiable, Codable, Hashable { self.summary = summary self.traits = traits self.defaultPlaceholder = defaultPlaceholder + self.gbifTaxonKey = gbifTaxonKey + self.inaturalistTaxonID = inaturalistTaxonID + self.monthlyAffinity = monthlyAffinity } } @@ -112,3 +128,42 @@ extension CatalogPlant { .replacingOccurrences(of: "'", with: "") } } + +// MARK: - Locale Join + +extension CatalogPlant { + /// Normalized scientific name used to join external taxa (e.g. iNaturalist) + /// to this catalog entry when no provider ID is present. + var scientificNameKey: String { + Self.scientificNameKey(scientificName) + } + + /// Normalizes a scientific name to a join key: lowercased, trimmed, and + /// reduced to genus + species so authorship and subspecies don't block a match. + /// "Taraxacum officinale F.H.Wigg." -> "taraxacum officinale" + static func scientificNameKey(_ value: String) -> String { + let cleaned = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "×", with: "") + let words = cleaned.split(separator: " ").map(String.init) + return words.prefix(2).joined(separator: " ") + } + + /// Finds the catalog entry matching an external taxon, preferring the stable + /// iNaturalist ID and falling back to the normalized scientific-name key. + static func match( + inaturalistTaxonID taxonID: Int?, + scientificName: String, + in catalog: [CatalogPlant] + ) -> CatalogPlant? { + if let taxonID, + let byID = catalog.first(where: { $0.inaturalistTaxonID == taxonID }) { + return byID + } + + let key = scientificNameKey(scientificName) + guard !key.isEmpty else { return nil } + return catalog.first { $0.scientificNameKey == key } + } +} diff --git a/Fieldnote/Services/INaturalistService.swift b/Fieldnote/Services/INaturalistService.swift new file mode 100644 index 0000000..ea7a7c4 --- /dev/null +++ b/Fieldnote/Services/INaturalistService.swift @@ -0,0 +1,180 @@ +// +// INaturalistService.swift +// Fieldnote +// +// iNaturalist API client for nearby + seasonal species counts. +// +// Free and keyless. We send only a coarse cell (rounded coordinates) — never +// precise encounter coordinates. See LocaleAwareCatalogImplementationPlan.md. +// + +import Foundation +import CoreLocation + +// MARK: - Domain Models + +/// One taxon's "reported nearby" evidence from iNaturalist `species_counts`. +struct INatSpeciesCount: Codable, Hashable, Identifiable { + /// iNaturalist taxon ID — the stable join key into the catalog. + let taxonID: Int + /// Accepted scientific name as iNaturalist reports it. + let scientificName: String + /// Preferred localized common name, when available. + let commonName: String? + /// Number of research-grade observations matching the query window. + let count: Int + /// Default photo URL, if iNaturalist provides one. + let defaultPhotoURL: URL? + /// License code for the default photo (e.g. "cc-by-nc"). Image reuse is a + /// separate legal decision from occurrence evidence — store it, don't assume. + let defaultPhotoLicense: String? + + var id: Int { taxonID } +} + +// MARK: - Errors + +enum INaturalistError: LocalizedError { + case invalidRequest + case networkError + case rateLimited + case decodingFailed + + var errorDescription: String? { + switch self { + case .invalidRequest: return "Could not build the iNaturalist request" + case .networkError: return "Network error contacting iNaturalist" + case .rateLimited: return "iNaturalist rate limit reached — try again later" + case .decodingFailed: return "Unexpected response from iNaturalist" + } + } +} + +// MARK: - Service + +actor INaturalistService { + static let shared = INaturalistService() + + private let baseURL = "https://api.inaturalist.org/v1/observations/species_counts" + /// iNaturalist taxon ID for kingdom Plantae — scopes results to plants. + private static let plantaeTaxonID = 47126 + + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + /// Fetches research-grade plant species counts reported near a coordinate + /// in a given month. + /// + /// - Parameters: + /// - coordinate: A **coarse** cell center (already rounded by the caller). + /// - radiusKm: Search radius in kilometers (iNaturalist caps this). + /// - month: Calendar month 1...12 to constrain seasonality. `nil` = all year. + /// - locale: Locale used for `preferred_common_name`. + /// - limit: Max taxa to return (iNaturalist `per_page`, max 500). + func speciesCounts( + near coordinate: CLLocationCoordinate2D, + radiusKm: Double = 25, + month: Int? = nil, + locale: Locale = .current, + limit: Int = 200 + ) async throws -> [INatSpeciesCount] { + guard var components = URLComponents(string: baseURL) else { + throw INaturalistError.invalidRequest + } + + var query: [URLQueryItem] = [ + URLQueryItem(name: "taxon_id", value: String(Self.plantaeTaxonID)), + URLQueryItem(name: "quality_grade", value: "research"), + URLQueryItem(name: "lat", value: String(coordinate.latitude)), + URLQueryItem(name: "lng", value: String(coordinate.longitude)), + URLQueryItem(name: "radius", value: String(radiusKm)), + URLQueryItem(name: "per_page", value: String(min(max(limit, 1), 500))), + URLQueryItem(name: "locale", value: locale.identifier) + ] + if let month, (1...12).contains(month) { + query.append(URLQueryItem(name: "month", value: String(month))) + } + components.queryItems = query + + guard let url = components.url else { + throw INaturalistError.invalidRequest + } + + var request = URLRequest(url: url) + request.timeoutInterval = 20 + // iNaturalist recommends identifying the client in the User-Agent. + request.setValue("Fieldnote/1.0 (iOS)", forHTTPHeaderField: "User-Agent") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw INaturalistError.networkError + } + + guard let http = response as? HTTPURLResponse else { + throw INaturalistError.networkError + } + switch http.statusCode { + case 200: break + case 429: throw INaturalistError.rateLimited + default: throw INaturalistError.networkError + } + + let decoded: SpeciesCountsResponse + do { + decoded = try JSONDecoder().decode(SpeciesCountsResponse.self, from: data) + } catch { + throw INaturalistError.decodingFailed + } + + return decoded.results.compactMap { result in + guard let taxon = result.taxon, let name = taxon.name else { return nil } + return INatSpeciesCount( + taxonID: taxon.id, + scientificName: name, + commonName: taxon.preferredCommonName, + count: result.count, + defaultPhotoURL: taxon.defaultPhoto?.mediumURL.flatMap(URL.init(string:)), + defaultPhotoLicense: taxon.defaultPhoto?.licenseCode + ) + } + } +} + +// MARK: - Wire Models + +private struct SpeciesCountsResponse: Decodable { + let results: [Result] + + struct Result: Decodable { + let count: Int + let taxon: Taxon? + } + + struct Taxon: Decodable { + let id: Int + let name: String? + let preferredCommonName: String? + let defaultPhoto: DefaultPhoto? + + enum CodingKeys: String, CodingKey { + case id, name + case preferredCommonName = "preferred_common_name" + case defaultPhoto = "default_photo" + } + } + + struct DefaultPhoto: Decodable { + let mediumURL: String? + let licenseCode: String? + + enum CodingKeys: String, CodingKey { + case mediumURL = "medium_url" + case licenseCode = "license_code" + } + } +} diff --git a/Fieldnote/Services/LocalCatalogCache.swift b/Fieldnote/Services/LocalCatalogCache.swift new file mode 100644 index 0000000..39a1d89 --- /dev/null +++ b/Fieldnote/Services/LocalCatalogCache.swift @@ -0,0 +1,85 @@ +// +// LocalCatalogCache.swift +// Fieldnote +// +// On-device cache of iNaturalist species counts, keyed by coarse cell + month. +// Lets us respect iNaturalist's rate limits by fetching only when the cell or +// month changes. See LocaleAwareCatalogImplementationPlan.md. +// + +import Foundation + +/// A cached species-counts response with the freshness metadata the UI shows. +struct CachedSpeciesCounts: Codable { + let cacheKey: String + let counts: [INatSpeciesCount] + let fetchedAt: Date +} + +actor LocalCatalogCache { + static let shared = LocalCatalogCache() + + private let fileManager = FileManager.default + + /// How long a cached response stays fresh before we refetch. + private let maxAge: TimeInterval = 60 * 60 * 24 * 7 // 7 days + + private var directory: URL { + let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let dir = base.appendingPathComponent("LocalCatalog", isDirectory: true) + if !fileManager.fileExists(atPath: dir.path) { + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + } + return dir + } + + private func fileURL(for cacheKey: String) -> URL { + // Sanitize the key (it contains "," and "@") into a safe filename. + let safe = cacheKey + .replacingOccurrences(of: ",", with: "_") + .replacingOccurrences(of: "@", with: "-m") + return directory.appendingPathComponent("\(safe).json") + } + + // MARK: - Read + + /// Returns the cached entry for a key, regardless of freshness. + func entry(for cacheKey: String) -> CachedSpeciesCounts? { + let url = fileURL(for: cacheKey) + guard let data = try? Data(contentsOf: url), + let entry = try? JSONDecoder.iso.decode(CachedSpeciesCounts.self, from: data) else { + return nil + } + return entry + } + + /// Returns the cached entry only if it is still within `maxAge`. + func freshEntry(for cacheKey: String, now: Date = .now) -> CachedSpeciesCounts? { + guard let entry = entry(for: cacheKey) else { return nil } + return now.timeIntervalSince(entry.fetchedAt) <= maxAge ? entry : nil + } + + // MARK: - Write + + func store(_ counts: [INatSpeciesCount], for cacheKey: String, fetchedAt: Date = .now) { + let entry = CachedSpeciesCounts(cacheKey: cacheKey, counts: counts, fetchedAt: fetchedAt) + guard let data = try? JSONEncoder.iso.encode(entry) else { return } + try? data.write(to: fileURL(for: cacheKey)) + } +} + +private extension JSONDecoder { + static let iso: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() +} + +private extension JSONEncoder { + static let iso: JSONEncoder = { + let e = JSONEncoder() + e.dateEncodingStrategy = .iso8601 + return e + }() +} diff --git a/Fieldnote/Services/LocalRankingService.swift b/Fieldnote/Services/LocalRankingService.swift new file mode 100644 index 0000000..3e3bd3a --- /dev/null +++ b/Fieldnote/Services/LocalRankingService.swift @@ -0,0 +1,261 @@ +// +// LocalRankingService.swift +// Fieldnote +// +// Stage-1 ecological ranking: turns iNaturalist species counts + the bundled +// catalog into ranked, explainable local items. Pure and synchronous so it is +// trivially unit-testable. The personal/editorial Stage-2 model is deferred to +// Milestone 2. See LocaleAwareCatalogImplementationPlan.md. +// + +import Foundation + +// MARK: - Explanation Codes + +/// Drives the user-facing "Why this plant?" copy. Wording avoids abundance +/// claims — we say "reported nearby," not "abundant." +enum ExplanationCode: Codable, Hashable { + case nearbyNow(radiusKm: Int) + case reportedThisMonth(monthName: String) + case seasonalPeak(monthName: String) + case easyFirstFind + + var label: String { + switch self { + case .nearbyNow(let radiusKm): + return "Frequently reported within \(radiusKm) km" + case .reportedThisMonth(let monthName): + return "Reported nearby in \(monthName)" + case .seasonalPeak(let monthName): + return "Often active around \(monthName)" + case .easyFirstFind: + return "Commonly seen — a good first find" + } + } +} + +// MARK: - Ranked Item + +/// A catalog entry positioned for a specific locality + month, with the +/// evidence the UI needs to explain its placement. +struct LocalCatalogItem: Identifiable, Hashable { + let catalogPlant: CatalogPlant + let nearbyObservationCount: Int + /// Stage-1 ecological score in 0...1. + let rankScore: Double + let explanationCodes: [ExplanationCode] + + var id: UUID { catalogPlant.id } +} + +/// An identification candidate after local + seasonal reranking. +struct RankedCandidate: Identifiable, Hashable { + let candidate: PlantIdentificationCandidate + /// Visual confidence after the bounded local/seasonal adjustment. + let combinedScore: Double + /// Whether this taxon is also reported in the current locality. + let hasLocalSupport: Bool + let nearbyObservationCount: Int + + var id: String { candidate.scientificName } +} + +// MARK: - Service + +struct LocalRankingService { + + /// Weights for the signals available client-side in Milestone 1. The + /// research model also reserves weight for recency, habitat, and climate + /// fit — those are deferred, so we renormalize across the two we have. + private let occurrenceWeight = 0.6 + private let seasonalWeight = 0.4 + + /// Fraction of the (log-scaled) occurrence range above which an item is + /// considered a confident "near you now" / "easy first find". + private let strongOccurrenceThreshold = 0.66 + + init() {} + + /// Ranks the catalog against nearby species counts for a month. + /// + /// - Parameters: + /// - catalog: The bundled catalog entries. + /// - counts: iNaturalist species counts for the locality + month. + /// - month: Calendar month 1...12 used for seasonal copy. + /// - radiusKm: Radius used in the query, for explanation copy. + /// - Returns: Items with a local match, sorted by `rankScore` descending. + func rank( + catalog: [CatalogPlant], + counts: [INatSpeciesCount], + month: Int, + radiusKm: Int + ) -> [LocalCatalogItem] { + guard !counts.isEmpty else { return [] } + + // Lookup tables for joining counts to catalog entries. + let countsByTaxonID = Dictionary( + counts.map { ($0.taxonID, $0) }, + uniquingKeysWith: { a, b in a.count >= b.count ? a : b } + ) + let countsByNameKey = Dictionary( + counts.map { (CatalogPlant.scientificNameKey($0.scientificName), $0) }, + uniquingKeysWith: { a, b in a.count >= b.count ? a : b } + ) + + // Log-scale occurrence so highly reported urban weeds don't dominate. + let maxLogCount = counts.map { log1p(Double($0.count)) }.max() ?? 1 + let monthName = Self.monthName(month) + + var items: [LocalCatalogItem] = [] + for plant in catalog { + guard let match = matchedCount( + for: plant, + byID: countsByTaxonID, + byName: countsByNameKey + ) else { continue } + + let occurrenceScore = maxLogCount > 0 + ? log1p(Double(match.count)) / maxLogCount + : 0 + + let seasonalScore = Self.seasonalScore(for: plant, month: month) + + let rankScore = occurrenceWeight * occurrenceScore + + seasonalWeight * seasonalScore + + let codes = explanationCodes( + plant: plant, + occurrenceScore: occurrenceScore, + month: month, + monthName: monthName, + radiusKm: radiusKm + ) + + items.append(LocalCatalogItem( + catalogPlant: plant, + nearbyObservationCount: match.count, + rankScore: rankScore, + explanationCodes: codes + )) + } + + return items.sorted { lhs, rhs in + if lhs.rankScore != rhs.rankScore { + return lhs.rankScore > rhs.rankScore + } + return lhs.nearbyObservationCount > rhs.nearbyObservationCount + } + } + + // MARK: - Identification Reranking + + /// How much local support and seasonality may nudge a candidate, as a + /// fraction. Kept small so the visual signal stays dominant: a locally + /// common plant should not rescue a morphologically implausible match. + private let maxLocalBoost = 0.3 + private let maxSeasonalBoost = 0.2 + + /// Reranks visual identification candidates using the local + seasonal prior. + /// `combinedScore = visualConfidence × localPrior × seasonalPrior`, with the + /// priors bounded near 1 so visual likelihood dominates (research §291). + func rerankCandidates( + _ candidates: [PlantIdentificationCandidate], + localItems: [LocalCatalogItem], + month: Int + ) -> [RankedCandidate] { + let itemsByKey = Dictionary( + localItems.map { (CatalogPlant.scientificNameKey($0.catalogPlant.scientificName), $0) }, + uniquingKeysWith: { a, _ in a } + ) + + let ranked = candidates.map { candidate -> RankedCandidate in + let key = CatalogPlant.scientificNameKey(candidate.scientificName) + let local = itemsByKey[key] + + let localPrior = 1.0 + (local?.rankScore ?? 0) * maxLocalBoost + + let seasonal = local.map { + Self.seasonalScore(for: $0.catalogPlant, month: month) + } ?? 0.5 + // Centered at the neutral 0.5 so missing data neither helps nor hurts. + let seasonalPrior = 1.0 + (seasonal - 0.5) * maxSeasonalBoost + + let combined = candidate.visualConfidence * localPrior * seasonalPrior + + return RankedCandidate( + candidate: candidate, + combinedScore: combined, + hasLocalSupport: local != nil, + nearbyObservationCount: local?.nearbyObservationCount ?? 0 + ) + } + + return ranked.sorted { $0.combinedScore > $1.combinedScore } + } + + // MARK: - Matching + + private func matchedCount( + for plant: CatalogPlant, + byID: [Int: INatSpeciesCount], + byName: [String: INatSpeciesCount] + ) -> INatSpeciesCount? { + if let id = plant.inaturalistTaxonID, let hit = byID[id] { + return hit + } + return byName[plant.scientificNameKey] + } + + // MARK: - Seasonality + + /// Returns the plant's affinity for `month` (0...1). Neutral 0.5 when no + /// seasonal data exists yet, so absence of data never penalizes a plant. + static func seasonalScore(for plant: CatalogPlant, month: Int) -> Double { + guard let affinity = plant.monthlyAffinity, + affinity.count == 12, + (1...12).contains(month) else { + return 0.5 + } + return affinity[month - 1] + } + + // MARK: - Explanations + + private func explanationCodes( + plant: CatalogPlant, + occurrenceScore: Double, + month: Int, + monthName: String, + radiusKm: Int + ) -> [ExplanationCode] { + var codes: [ExplanationCode] = [] + + if occurrenceScore >= strongOccurrenceThreshold { + codes.append(.nearbyNow(radiusKm: radiusKm)) + } else { + codes.append(.reportedThisMonth(monthName: monthName)) + } + + if let affinity = plant.monthlyAffinity, + affinity.count == 12, + let peak = affinity.max(), + peak > 0, + affinity[month - 1] >= peak * 0.85 { + codes.append(.seasonalPeak(monthName: monthName)) + } + + if occurrenceScore >= strongOccurrenceThreshold { + codes.append(.easyFirstFind) + } + + return codes + } + + // MARK: - Helpers + + static func monthName(_ month: Int) -> String { + guard (1...12).contains(month) else { return "" } + let formatter = DateFormatter() + return formatter.standaloneMonthSymbols[month - 1] + } +} diff --git a/Fieldnote/Services/PlantIDAPIService.swift b/Fieldnote/Services/PlantIDAPIService.swift index eef47ab..ea52807 100644 --- a/Fieldnote/Services/PlantIDAPIService.swift +++ b/Fieldnote/Services/PlantIDAPIService.swift @@ -20,6 +20,12 @@ struct PlantNetResponse: Codable { struct PlantNetResult: Codable { let score: Double let species: PlantNetSpecies + let gbif: PlantNetGBIF? +} + +/// GBIF cross-reference, when Pl@ntNet provides one. +struct PlantNetGBIF: Codable { + let id: String? } /// Species information @@ -37,6 +43,29 @@ struct PlantNetTaxon: Codable { let scientificNameAuthorship: String? } +// MARK: - Candidate + +/// A single visual identification candidate, carrying the raw provider score so +/// it can be reranked against a local + seasonal prior before being shown. +struct PlantIdentificationCandidate: Hashable { + let commonName: String + let scientificName: String + let family: String + /// Visual match likelihood from the provider, 0...1. + let visualConfidence: Double + /// GBIF taxon key when the provider supplied one (used for matching). + let gbifTaxonKey: Int? + + var asResult: PlantIdentificationResult { + PlantIdentificationResult( + commonName: commonName, + scientificName: scientificName, + family: family, + confidence: visualConfidence + ) + } +} + // MARK: - API Service actor PlantIDAPIService { @@ -68,8 +97,24 @@ actor PlantIDAPIService { } } - /// Identifies a plant from an image using the Pl@ntNet API + /// Identifies a plant from an image, returning the single best visual match. + /// Retained for callers that don't yet handle alternatives. func identify(image: UIImage, location: CLLocationCoordinate2D? = nil) async throws -> PlantIdentificationResult { + let candidates = try await identifyCandidates(image: image, location: location) + guard let top = candidates.first else { + throw PlantIdentificationError.noResult + } + return top.asResult + } + + /// Identifies a plant and returns the top visual candidates (highest score + /// first), so the caller can rerank them with a local + seasonal prior and + /// surface alternatives. See LocalRankingService.rerankCandidates. + func identifyCandidates( + image: UIImage, + location: CLLocationCoordinate2D? = nil, + maxResults: Int = 5 + ) async throws -> [PlantIdentificationCandidate] { // Validate API key is configured guard !apiKey.isEmpty else { throw PlantIdentificationError.invalidAPIKey @@ -149,17 +194,25 @@ actor PlantIDAPIService { // Decode response let decoded = try JSONDecoder().decode(PlantNetResponse.self, from: data) - // Get top result - guard let top = decoded.results.first, top.score >= 0.1 else { + // Keep the top candidates above the minimum score threshold. + let candidates = decoded.results + .filter { $0.score >= 0.1 } + .prefix(maxResults) + .map { result in + PlantIdentificationCandidate( + commonName: result.species.commonNames?.first + ?? result.species.scientificNameWithoutAuthor, + scientificName: result.species.scientificNameWithoutAuthor, + family: result.species.family?.scientificNameWithoutAuthor ?? "", + visualConfidence: result.score, + gbifTaxonKey: result.gbif?.id.flatMap { Int($0) } + ) + } + + guard !candidates.isEmpty else { throw PlantIdentificationError.noResult } - // Map to local result type - return PlantIdentificationResult( - commonName: top.species.commonNames?.first ?? top.species.scientificNameWithoutAuthor, - scientificName: top.species.scientificNameWithoutAuthor, - family: top.species.family?.scientificNameWithoutAuthor ?? "", - confidence: top.score - ) + return Array(candidates) } } From 4475ceef031030dd6a4012af47f63f1ddfe6a476 Mon Sep 17 00:00:00 2001 From: Tobias Fu Date: Wed, 24 Jun 2026 16:18:01 -0700 Subject: [PATCH 3/6] Locale-aware catalog PR2: Explore + identification UI Wires the PR1 data layer into the UI (Workstream B, items B1-B4). B1 - AppStore wiring: - Observable state: localityProfile, localCatalogItems, catalogFreshnessDate, isRefreshingLocalCatalog, and a selectedRegionOverride (ExploreRegion) that is a view preference and never touches an observation's stored region. - refreshLocalCatalog(): resolves a coarse coordinate (current location or a chosen city), builds a LocalityProfile, reuses a fresh LocalCatalogCache entry or fetches via INaturalistService + stores it, then ranks the bundled catalog with LocalRankingService. Resilient: falls back to stale cache and otherwise leaves existing state untouched on any failure. Called from refresh(). - Derived helpers: nearYouNowItems, reportedThisMonthItems, catalogFreshnessLabel. B2 - Location-value prompt: - LocalDiscoveryPrompt explainer ("See plants reported near you and what is active this season") with Use Approximate Location / Choose a City. - NSLocationWhenInUseUsageDescription (both build configs) now mentions local discovery, not just observation tagging. B3 - Explore sections: - Ecology-led order when a locality exists: Near You Now -> Reported This Month -> existing Recently Encountered / custom / full catalog fallback. - LocalCatalogSection/LocalCatalogCard render localCatalogItems with a per-card "Why this plant?" line from explanationCodes.first.label ("reported nearby" wording, no abundance claims). - RegionPickerSheet (current location vs. one of a few preset cities) plus a freshness pill ("Updated N days ago"). Falls back to today's behavior when no locality is set, so nothing regresses. B4 - Capture review alternatives: - CaptureMode.mlIdentification gains an alternatives: [RankedCandidate] payload. - CaptureViewModel uses identifyCandidates + LocalRankingService.rerankCandidates (locality items + month passed in from AppStore via CaptureView), surfacing the reranked top result plus close runners-up. Manual-entry / error fallbacks intact. - CaptureReviewSheet shows an "Other possibilities" card; tapping an alternative fills the form and re-matches the catalog. Build: ** BUILD SUCCEEDED ** (iPhone 17, iOS 26 simulator). Co-Authored-By: Claude Opus 4.8 --- Fieldnote.xcodeproj/project.pbxproj | 4 +- Fieldnote/Models/CaptureMode.swift | 22 ++- .../Screens/Capture/CaptureReviewSheet.swift | 88 ++++++++++ Fieldnote/Screens/Capture/CaptureView.swift | 13 +- .../Screens/Capture/CaptureViewModel.swift | 95 +++++++++-- .../Components/LocalCatalogSection.swift | 120 +++++++++++++ .../Components/LocalDiscoveryPrompt.swift | 85 ++++++++++ .../Components/RegionPickerSheet.swift | 88 ++++++++++ Fieldnote/Screens/Explore/ExploreView.swift | 109 +++++++++++- Fieldnote/Store/AppStore.swift | 158 ++++++++++++++++++ 10 files changed, 762 insertions(+), 20 deletions(-) create mode 100644 Fieldnote/Screens/Explore/Components/LocalCatalogSection.swift create mode 100644 Fieldnote/Screens/Explore/Components/LocalDiscoveryPrompt.swift create mode 100644 Fieldnote/Screens/Explore/Components/RegionPickerSheet.swift diff --git a/Fieldnote.xcodeproj/project.pbxproj b/Fieldnote.xcodeproj/project.pbxproj index a7a7676..ada75b3 100644 --- a/Fieldnote.xcodeproj/project.pbxproj +++ b/Fieldnote.xcodeproj/project.pbxproj @@ -294,7 +294,7 @@ INFOPLIST_KEY_CFBundleDisplayName = Fieldnote; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.reference"; INFOPLIST_KEY_NSCameraUsageDescription = "Fieldnote needs camera access to capture plant photos."; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Fieldnote uses your location to tag plant observations."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Fieldnote uses your approximate location to show plants reported near you and what is active this season, and to tag plant observations."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -344,7 +344,7 @@ INFOPLIST_KEY_CFBundleDisplayName = Fieldnote; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.reference"; INFOPLIST_KEY_NSCameraUsageDescription = "Fieldnote needs camera access to capture plant photos."; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Fieldnote uses your location to tag plant observations."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Fieldnote uses your approximate location to show plants reported near you and what is active this season, and to tag plant observations."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; diff --git a/Fieldnote/Models/CaptureMode.swift b/Fieldnote/Models/CaptureMode.swift index fb5dc35..9a29dda 100644 --- a/Fieldnote/Models/CaptureMode.swift +++ b/Fieldnote/Models/CaptureMode.swift @@ -8,7 +8,15 @@ import UIKit enum CaptureMode { - case mlIdentification(result: PlantIdentificationResult, image: UIImage) + /// Identification result plus, when available, the reranked alternative + /// candidates so the review sheet can offer "did you mean…?" choices instead + /// of presenting the top match as a certainty. + /// See LocaleAwareCatalogImplementationPlan.md (B4). + case mlIdentification( + result: PlantIdentificationResult, + image: UIImage, + alternatives: [RankedCandidate] = [] + ) case manualEntry var hasPrefilledData: Bool { @@ -20,15 +28,23 @@ enum CaptureMode { var image: UIImage? { switch self { - case .mlIdentification(_, let image): return image + case .mlIdentification(_, let image, _): return image case .manualEntry: return nil } } var identificationResult: PlantIdentificationResult? { switch self { - case .mlIdentification(let result, _): return result + case .mlIdentification(let result, _, _): return result case .manualEntry: return nil } } + + /// Reranked alternatives (excluding the chosen top result), if any. + var alternatives: [RankedCandidate] { + switch self { + case .mlIdentification(_, _, let alternatives): return alternatives + case .manualEntry: return [] + } + } } diff --git a/Fieldnote/Screens/Capture/CaptureReviewSheet.swift b/Fieldnote/Screens/Capture/CaptureReviewSheet.swift index 254d1ae..59e5c2e 100644 --- a/Fieldnote/Screens/Capture/CaptureReviewSheet.swift +++ b/Fieldnote/Screens/Capture/CaptureReviewSheet.swift @@ -45,6 +45,11 @@ struct CaptureReviewSheet: View { addedImage ?? captureMode.image } + /// Reranked runner-up candidates surfaced as alternatives. + private var alternatives: [RankedCandidate] { + captureMode.alternatives + } + init(viewModel: CaptureViewModel, store: AppStore, captureMode: CaptureMode) { self.viewModel = viewModel self.store = store @@ -140,6 +145,13 @@ struct CaptureReviewSheet: View { } } + // Alternative candidates (shown when the reranker returned + // close runners-up). Visual signal stays dominant — these + // are offered as "did you mean…?", not as equal claims. + if !alternatives.isEmpty { + alternativesCard + } + // Confidence VintageCard { VStack(alignment: .leading, spacing: FieldSpace.sm) { @@ -337,6 +349,82 @@ struct CaptureReviewSheet: View { isEnriching = false } + // MARK: - Alternatives + + private var alternativesCard: some View { + VintageCard { + VStack(alignment: .leading, spacing: FieldSpace.sm) { + HStack(spacing: FieldSpace.xs) { + Image(systemName: "questionmark.circle") + .font(.caption) + .foregroundColor(FieldColor.mutedInk) + Text("Other possibilities") + .font(FieldType.bodyEmphasized) + .foregroundColor(FieldColor.vintageInk) + } + + Text("If this isn't quite right, tap a closer match.") + .font(FieldType.caption) + .foregroundColor(FieldColor.fadedInk) + + ForEach(alternatives) { ranked in + Button { + applyCandidate(ranked.candidate) + } label: { + HStack(spacing: FieldSpace.sm) { + VStack(alignment: .leading, spacing: 2) { + Text(ranked.candidate.commonName) + .font(FieldType.callout) + .foregroundColor(FieldColor.ink) + .lineLimit(1) + Text(ranked.candidate.scientificName) + .font(FieldType.caption) + .foregroundColor(FieldColor.fadedInk) + .italic() + .lineLimit(1) + if ranked.hasLocalSupport { + Text("Also reported nearby") + .font(FieldType.caption2) + .foregroundColor(FieldColor.accent) + } + } + + Spacer() + + Text("\(Int((ranked.candidate.visualConfidence * 100).rounded()))%") + .font(FieldType.caption) + .foregroundColor(FieldColor.mutedInk) + + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundColor(FieldColor.fadedInk) + } + .padding(FieldSpace.sm) + .background(FieldColor.surface) + .cornerRadius(FieldRadius.sm) + .overlay( + RoundedRectangle(cornerRadius: FieldRadius.sm) + .stroke(FieldColor.bookBorder.opacity(0.5), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + } + } + } + } + + private func applyCandidate(_ candidate: PlantIdentificationCandidate) { + commonName = candidate.commonName + scientificName = candidate.scientificName + family = candidate.family + confidence = candidate.visualConfidence + // Re-match against the catalog with the newly chosen names. + selectedCatalogPlant = CatalogPlant.match( + for: candidate.asResult, + in: store.catalogPlants + ) + } + // MARK: - AI Badge private var aiIdentifiedBadge: some View { diff --git a/Fieldnote/Screens/Capture/CaptureView.swift b/Fieldnote/Screens/Capture/CaptureView.swift index 50dafe5..0cbae7f 100644 --- a/Fieldnote/Screens/Capture/CaptureView.swift +++ b/Fieldnote/Screens/Capture/CaptureView.swift @@ -42,13 +42,22 @@ struct CaptureView: View { .navigationTitle("Capture") .onChange(of: viewModel.selectedItem) { _, _ in Task { - await viewModel.loadPhoto(subscriptionStore: subscriptionStore) + await viewModel.loadPhoto( + subscriptionStore: subscriptionStore, + localItems: store?.localCatalogItems ?? [], + localMonth: store?.localityProfile?.currentMonth + ) } } .onChange(of: capturedImage) { _, newImage in if let image = newImage { Task { - await viewModel.handleCapturedImage(image, subscriptionStore: subscriptionStore) + await viewModel.handleCapturedImage( + image, + subscriptionStore: subscriptionStore, + localItems: store?.localCatalogItems ?? [], + localMonth: store?.localityProfile?.currentMonth + ) } } } diff --git a/Fieldnote/Screens/Capture/CaptureViewModel.swift b/Fieldnote/Screens/Capture/CaptureViewModel.swift index 568a2b6..bb4a3c4 100644 --- a/Fieldnote/Screens/Capture/CaptureViewModel.swift +++ b/Fieldnote/Screens/Capture/CaptureViewModel.swift @@ -18,6 +18,18 @@ protocol PlantIdentificationProviding { func identify(image: UIImage, location: CLLocationCoordinate2D?) async throws -> PlantIdentificationResult } +/// Provider of multi-candidate identification, so the review sheet can surface +/// reranked alternatives. See LocaleAwareCatalogImplementationPlan.md (B4). +protocol PlantCandidateProviding { + func identifyCandidates( + image: UIImage, + location: CLLocationCoordinate2D?, + maxResults: Int + ) async throws -> [PlantIdentificationCandidate] +} + +extension PlantIDAPIService: PlantCandidateProviding {} + protocol CaptureSubscriptionProviding: AnyObject { var canUseAIIdentification: Bool { get } func recordIdentification() @@ -54,19 +66,30 @@ class CaptureViewModel { var identificationError: Error? private var pendingImage: UIImage? + /// Locality context captured from the AppStore at request time, used to + /// rerank visual candidates against what's reported nearby this month. + private var pendingLocalItems: [LocalCatalogItem] = [] + private var pendingLocalMonth: Int? private let locationService: CaptureLocationProviding private let identificationService: PlantIdentificationProviding + private let candidateService: PlantCandidateProviding init( locationService: CaptureLocationProviding = LocationService.shared, - identificationService: PlantIdentificationProviding = HybridPlantIdentificationService.shared + identificationService: PlantIdentificationProviding = HybridPlantIdentificationService.shared, + candidateService: PlantCandidateProviding = PlantIDAPIService.shared ) { self.locationService = locationService self.identificationService = identificationService + self.candidateService = candidateService } - func loadPhoto(subscriptionStore: CaptureSubscriptionProviding) async { + func loadPhoto( + subscriptionStore: CaptureSubscriptionProviding, + localItems: [LocalCatalogItem] = [], + localMonth: Int? = nil + ) async { guard let item = selectedItem else { return } do { @@ -74,7 +97,12 @@ class CaptureViewModel { selectedPhotoData = data if let image = UIImage(data: data) { - await identifyPlant(image: image, subscriptionStore: subscriptionStore) + await identifyPlant( + image: image, + subscriptionStore: subscriptionStore, + localItems: localItems, + localMonth: localMonth + ) } } } catch { @@ -82,14 +110,31 @@ class CaptureViewModel { } } - func handleCapturedImage(_ image: UIImage, subscriptionStore: CaptureSubscriptionProviding) async { - await identifyPlant(image: image, subscriptionStore: subscriptionStore) + func handleCapturedImage( + _ image: UIImage, + subscriptionStore: CaptureSubscriptionProviding, + localItems: [LocalCatalogItem] = [], + localMonth: Int? = nil + ) async { + await identifyPlant( + image: image, + subscriptionStore: subscriptionStore, + localItems: localItems, + localMonth: localMonth + ) } - private func identifyPlant(image: UIImage, subscriptionStore: CaptureSubscriptionProviding) async { + private func identifyPlant( + image: UIImage, + subscriptionStore: CaptureSubscriptionProviding, + localItems: [LocalCatalogItem], + localMonth: Int? + ) async { // Check if user can use AI identification guard subscriptionStore.canUseAIIdentification else { pendingImage = image + pendingLocalItems = localItems + pendingLocalMonth = localMonth destination = .paywall return } @@ -105,16 +150,35 @@ class CaptureViewModel { // Fetch location for better API accuracy (non-blocking) let location = await locationService.requestCurrentLocation() - // Use hybrid service (API-first, CoreML fallback) - let result = try await identificationService.identify( + // Pull the top visual candidates so we can rerank + offer alternatives. + let candidates = try await candidateService.identifyCandidates( image: image, - location: location + location: location, + maxResults: 5 + ) + + // Rerank with the local + seasonal prior (visual signal stays dominant). + let month = localMonth ?? Calendar.current.component(.month, from: Date()) + let ranked = LocalRankingService().rerankCandidates( + candidates, + localItems: localItems, + month: month ) + guard let top = ranked.first else { + throw PlantIdentificationError.noResult + } + // Record AI identification usage subscriptionStore.recordIdentification() - destination = .review(.mlIdentification(result: result, image: image)) + // Alternatives = the reranked tail, surfaced when scores are close. + let alternatives = Array(ranked.dropFirst()) + destination = .review(.mlIdentification( + result: top.candidate.asResult, + image: image, + alternatives: alternatives + )) } catch { identificationError = error // Still show review sheet but with empty fields for manual entry @@ -134,7 +198,16 @@ class CaptureViewModel { func retryPendingIdentification(subscriptionStore: CaptureSubscriptionProviding) async { guard let image = pendingImage else { return } pendingImage = nil - await identifyPlant(image: image, subscriptionStore: subscriptionStore) + let localItems = pendingLocalItems + let localMonth = pendingLocalMonth + pendingLocalItems = [] + pendingLocalMonth = nil + await identifyPlant( + image: image, + subscriptionStore: subscriptionStore, + localItems: localItems, + localMonth: localMonth + ) } func startManualEntry() { diff --git a/Fieldnote/Screens/Explore/Components/LocalCatalogSection.swift b/Fieldnote/Screens/Explore/Components/LocalCatalogSection.swift new file mode 100644 index 0000000..82703e1 --- /dev/null +++ b/Fieldnote/Screens/Explore/Components/LocalCatalogSection.swift @@ -0,0 +1,120 @@ +// +// LocalCatalogSection.swift +// Fieldnote +// +// Ecology-led Explore sections driven by the locale-aware ranking. Each card +// carries a "Why this plant?" line built from the item's explanation codes. +// Wording stays at "reported nearby" — never abundance. +// See LocaleAwareCatalogImplementationPlan.md (B3). +// + +import SwiftUI + +struct LocalCatalogSection: View { + let title: String + let items: [LocalCatalogItem] + let isDiscovered: (CatalogPlant) -> Bool + + var body: some View { + VStack(alignment: .leading, spacing: FieldSpace.sm) { + HStack { + SectionHeader(title: title) + Spacer() + if !items.isEmpty { + Text("\(items.count)") + .font(FieldType.caption) + .foregroundColor(FieldColor.fadedInk) + } + } + .padding(.horizontal, FieldSpace.md) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: FieldSpace.sm) { + ForEach(items) { item in + NavigationLink(value: item.catalogPlant) { + LocalCatalogCard( + item: item, + isDiscovered: isDiscovered(item.catalogPlant) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, FieldSpace.md) + } + } + } +} + +// MARK: - Card + +/// A catalog card annotated with the locale-aware "Why this plant?" reason. +struct LocalCatalogCard: View { + let item: LocalCatalogItem + let isDiscovered: Bool + + private var whyThisPlant: String? { + item.explanationCodes.first?.label + } + + var body: some View { + VStack(alignment: .leading, spacing: FieldSpace.xs) { + ZStack(alignment: .topTrailing) { + if isDiscovered { + BotanicalIllustrationView( + item.catalogPlant.commonName, + family: item.catalogPlant.family, + size: .card + ) + } else { + UndiscoveredIllustrationView( + item.catalogPlant.commonName, + family: item.catalogPlant.family, + size: .card + ) + } + } + .frame(width: 140, height: 100) + .clipped() + .overlay(alignment: .topTrailing) { + if isDiscovered { + Image(systemName: "checkmark.circle.fill") + .font(.caption) + .foregroundColor(FieldColor.accent) + .background( + Circle() + .fill(FieldColor.surface) + .frame(width: 18, height: 18) + ) + .padding(FieldSpace.xs) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(item.catalogPlant.commonName) + .font(FieldType.callout) + .foregroundColor(isDiscovered ? FieldColor.vintageInk : FieldColor.fadedInk) + .lineLimit(2) + .frame(height: 40, alignment: .top) + + if let whyThisPlant { + HStack(alignment: .top, spacing: 3) { + Image(systemName: "mappin.and.ellipse") + .font(.system(size: 9)) + .foregroundColor(FieldColor.accent) + .padding(.top, 1) + Text(whyThisPlant) + .font(FieldType.caption2) + .foregroundColor(FieldColor.mutedInk) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + .frame(height: 30, alignment: .top) + .accessibilityElement(children: .combine) + .accessibilityLabel("Why this plant? \(whyThisPlant)") + } + } + } + .frame(width: 140) + } +} diff --git a/Fieldnote/Screens/Explore/Components/LocalDiscoveryPrompt.swift b/Fieldnote/Screens/Explore/Components/LocalDiscoveryPrompt.swift new file mode 100644 index 0000000..c339019 --- /dev/null +++ b/Fieldnote/Screens/Explore/Components/LocalDiscoveryPrompt.swift @@ -0,0 +1,85 @@ +// +// LocalDiscoveryPrompt.swift +// Fieldnote +// +// Pre-permission explainer that sells the value of local discovery before we +// ask for location. Offers "Use Approximate Location" (triggers the system +// prompt via a refresh) and "Choose a City/Region" (no permission needed). +// See LocaleAwareCatalogImplementationPlan.md (B2). +// + +import SwiftUI +import CoreLocation + +struct LocalDiscoveryPrompt: View { + /// Called when the user opts into using their (approximate) current location. + let onUseLocation: () -> Void + /// Called when the user picks a city, with that city's coarse coordinate. + let onChooseRegion: (ExploreRegion) -> Void + + @State private var showCityPicker = false + + var body: some View { + VStack(alignment: .leading, spacing: FieldSpace.md) { + SectionHeader(title: "Discover Plants Nearby") + .padding(.horizontal, FieldSpace.md) + + VintageCard { + VStack(alignment: .leading, spacing: FieldSpace.md) { + HStack(alignment: .top, spacing: FieldSpace.sm) { + Image(systemName: "leaf.circle.fill") + .font(.system(size: 32)) + .foregroundColor(FieldColor.accent) + + VStack(alignment: .leading, spacing: FieldSpace.xs) { + Text("See what's growing around you") + .font(FieldType.bodyEmphasized) + .foregroundColor(FieldColor.vintageInk) + + Text("See plants reported near you and what is active this season. We only ever send a rough area — never your exact spot.") + .font(FieldType.caption) + .foregroundColor(FieldColor.mutedInk) + .fixedSize(horizontal: false, vertical: true) + } + } + + VStack(spacing: FieldSpace.sm) { + Button { + onUseLocation() + } label: { + Label("Use Approximate Location", systemImage: "location.fill") + .font(FieldType.buttonLabel) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, FieldSpace.sm) + .background(FieldColor.accent) + .cornerRadius(FieldRadius.button) + } + .buttonStyle(.plain) + + Button { + showCityPicker = true + } label: { + Label("Choose a City / Region", systemImage: "map") + .font(FieldType.buttonLabel) + .foregroundColor(FieldColor.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, FieldSpace.sm) + .background( + RoundedRectangle(cornerRadius: FieldRadius.button) + .stroke(FieldColor.accent, lineWidth: 1.5) + ) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, FieldSpace.md) + } + .sheet(isPresented: $showCityPicker) { + RegionPickerSheet(includeCurrentLocation: false) { region in + onChooseRegion(region) + } + } + } +} diff --git a/Fieldnote/Screens/Explore/Components/RegionPickerSheet.swift b/Fieldnote/Screens/Explore/Components/RegionPickerSheet.swift new file mode 100644 index 0000000..c717b41 --- /dev/null +++ b/Fieldnote/Screens/Explore/Components/RegionPickerSheet.swift @@ -0,0 +1,88 @@ +// +// RegionPickerSheet.swift +// Fieldnote +// +// Minimal region picker for the locale-aware Explore catalog: "Current +// Location" plus a short curated list of cities (enough to prove the travel +// case in Milestone 1). Selecting a region only changes how Explore is ranked; +// it never touches the region stored on an observation. +// See LocaleAwareCatalogImplementationPlan.md (B3). +// + +import SwiftUI + +/// A few well-known cities spread across hemispheres so different selections +/// visibly change the Explore screen. Coordinates are city centers; the locality +/// layer rounds them to a coarse cell before any network call. +struct PresetRegion: Identifiable, Hashable { + let id = UUID() + let name: String + let latitude: Double + let longitude: Double + + var asExploreRegion: ExploreRegion { + .chosen(latitude: latitude, longitude: longitude, name: name) + } + + static let presets: [PresetRegion] = [ + PresetRegion(name: "San Francisco, CA", latitude: 37.77, longitude: -122.42), + PresetRegion(name: "New York, NY", latitude: 40.71, longitude: -74.01), + PresetRegion(name: "Seattle, WA", latitude: 47.61, longitude: -122.33), + PresetRegion(name: "Austin, TX", latitude: 30.27, longitude: -97.74), + PresetRegion(name: "London, UK", latitude: 51.51, longitude: -0.13), + PresetRegion(name: "Berlin, DE", latitude: 52.52, longitude: 13.40), + PresetRegion(name: "Sydney, AU", latitude: -33.87, longitude: 151.21), + PresetRegion(name: "Cape Town, ZA", latitude: -33.92, longitude: 18.42) + ] +} + +struct RegionPickerSheet: View { + /// Whether to offer a "Current Location" row at the top. + var includeCurrentLocation: Bool = true + let onSelect: (ExploreRegion) -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + if includeCurrentLocation { + Section { + Button { + onSelect(.currentLocation) + dismiss() + } label: { + Label("Current Location", systemImage: "location.fill") + .foregroundColor(FieldColor.ink) + } + } + } + + Section("Cities") { + ForEach(PresetRegion.presets) { region in + Button { + onSelect(region.asExploreRegion) + dismiss() + } label: { + HStack { + Text(region.name) + .foregroundColor(FieldColor.ink) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(FieldColor.fadedInk) + } + } + } + } + } + .navigationTitle("Choose Region") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } +} diff --git a/Fieldnote/Screens/Explore/ExploreView.swift b/Fieldnote/Screens/Explore/ExploreView.swift index a5dfa46..6012a8d 100644 --- a/Fieldnote/Screens/Explore/ExploreView.swift +++ b/Fieldnote/Screens/Explore/ExploreView.swift @@ -10,6 +10,10 @@ import SwiftUI struct ExploreView: View { @Environment(\.appStore) private var store @State private var searchQuery = "" + @State private var showRegionPicker = false + /// True once we've kicked off a local-catalog load this session, so the + /// pre-permission prompt doesn't flash while the first fetch is in flight. + @State private var didRequestLocalCatalog = false var body: some View { Group { @@ -53,13 +57,73 @@ struct ExploreView: View { .refreshable { await appStore.refresh() } + .task { + // Attempt a locale-aware load once Explore appears. Resilient: if no + // location is available, state stays empty and we show the prompt. + guard !didRequestLocalCatalog else { return } + didRequestLocalCatalog = true + await appStore.refreshLocalCatalog() + } + .sheet(isPresented: $showRegionPicker) { + RegionPickerSheet { region in + Task { await appStore.selectRegion(region) } + } + } } @ViewBuilder private func browseSections(appStore: AppStore) -> some View { - // TODO: GPS-based nearby plants section - // NearMeSection() + if appStore.hasLocalCatalog { + localCatalogSections(appStore: appStore) + } else if !didRequestLocalCatalog || appStore.isRefreshingLocalCatalog { + // First load in flight — fall through to the standard sections so + // nothing flashes; the local sections appear once ranked. + standardSections(appStore: appStore) + } else { + // No locality resolved (permission not granted, no region chosen): + // offer the value prompt, then the standard sections below it. + LocalDiscoveryPrompt( + onUseLocation: { + Task { await appStore.selectRegion(.currentLocation) } + }, + onChooseRegion: { region in + Task { await appStore.selectRegion(region) } + } + ) + standardSections(appStore: appStore) + } + } + + /// Ecology-led ordering when a locality exists: Near You Now → Reported This + /// Month → the existing Recently Encountered / custom / full catalog. + @ViewBuilder + private func localCatalogSections(appStore: AppStore) -> some View { + regionHeader(appStore: appStore) + + let nearby = appStore.nearYouNowItems + if !nearby.isEmpty { + LocalCatalogSection( + title: "Near You Now", + items: nearby, + isDiscovered: appStore.isDiscovered + ) + } + + let thisMonth = appStore.reportedThisMonthItems + if !thisMonth.isEmpty { + LocalCatalogSection( + title: "Reported This Month", + items: thisMonth, + isDiscovered: appStore.isDiscovered + ) + } + standardSections(appStore: appStore) + } + + /// The pre-existing browse experience, used as a fallback so nothing regresses. + @ViewBuilder + private func standardSections(appStore: AppStore) -> some View { ExploreSection( title: "Recently Encountered", plants: appStore.recentlyEncountered @@ -79,6 +143,47 @@ struct ExploreView: View { ) } + /// Region picker + freshness pill shown above the locale-aware sections. + @ViewBuilder + private func regionHeader(appStore: AppStore) -> some View { + VStack(alignment: .leading, spacing: FieldSpace.xs) { + Button { + showRegionPicker = true + } label: { + HStack(spacing: FieldSpace.xs) { + Image(systemName: "location.fill") + .font(.caption) + Text(regionName(appStore: appStore)) + .font(FieldType.callout) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.caption2) + } + .foregroundColor(FieldColor.accent) + .padding(.vertical, FieldSpace.xs) + .padding(.horizontal, FieldSpace.sm) + .background( + Capsule().stroke(FieldColor.accent.opacity(0.4), lineWidth: 1) + ) + } + .buttonStyle(.plain) + + if let freshness = appStore.catalogFreshnessLabel { + Text(freshness) + .font(FieldType.caption2) + .foregroundColor(FieldColor.fadedInk) + } + } + .padding(.horizontal, FieldSpace.md) + } + + private func regionName(appStore: AppStore) -> String { + if case .chosen(_, _, let name) = appStore.selectedRegionOverride { + return name + } + return appStore.localityProfile?.displayRegion ?? "Current Location" + } + @ViewBuilder private func searchResults(appStore: AppStore) -> some View { let matchedPlants = appStore.searchPlants(trimmedQuery) diff --git a/Fieldnote/Store/AppStore.swift b/Fieldnote/Store/AppStore.swift index 394af6f..119d328 100644 --- a/Fieldnote/Store/AppStore.swift +++ b/Fieldnote/Store/AppStore.swift @@ -18,6 +18,16 @@ enum Tab: Int { case profile } +/// Which place the Explore catalog is ranked for. This is a *view* preference — +/// it never touches the region stored on any observation. Defaults to following +/// the device's current (coarse) location. See LocaleAwareCatalogImplementationPlan.md. +enum ExploreRegion: Hashable { + /// Use the device's current location (coarse cell). + case currentLocation + /// A user-chosen city/region, carried as a coarse coordinate + display name. + case chosen(latitude: Double, longitude: Double, name: String) +} + @MainActor @Observable class AppStore { @@ -36,6 +46,25 @@ class AppStore { // Static catalog (not persisted, bundled with app) let catalogPlants: [CatalogPlant] = CatalogPlant.catalog + // MARK: - Locale-aware catalog state (PR2 / Workstream B) + + /// Coarse "where + when" the local catalog was last ranked for. `nil` until + /// the user opts into local discovery (location or a chosen region). + private(set) var localityProfile: LocalityProfile? + /// Catalog entries reported nearby, ranked by Stage-1 ecological score. + private(set) var localCatalogItems: [LocalCatalogItem] = [] + /// When the underlying species counts were fetched, for "Updated N days ago". + private(set) var catalogFreshnessDate: Date? + /// True while a `refreshLocalCatalog()` pass is in flight (for spinners). + private(set) var isRefreshingLocalCatalog = false + + /// Which place Explore ranks for. Changing this is a view preference and is + /// deliberately *not* persisted onto any observation. + var selectedRegionOverride: ExploreRegion = .currentLocation + + /// Radius (km) used for the nearby query — kept in one place for copy + query. + let localCatalogRadiusKm = 25 + init(modelContext: ModelContext) { self.modelContext = modelContext } @@ -239,6 +268,135 @@ class AppStore { // Small delay for visual feedback try? await Task.sleep(nanoseconds: 300_000_000) refreshTrigger += 1 + await refreshLocalCatalog() + } + + // MARK: - Locale-aware Catalog Refresh (PR2 / Workstream B) + + /// Resolves the current Explore region into a `LocalityProfile`, fetches (or + /// reuses a cached) iNaturalist species count, and ranks the bundled catalog + /// against it. Resilient by design: on any failure the existing state is left + /// untouched so the screen never regresses to empty. + func refreshLocalCatalog() async { + guard !isRefreshingLocalCatalog else { return } + isRefreshingLocalCatalog = true + defer { isRefreshingLocalCatalog = false } + + // 1. Resolve a coarse coordinate + display name for the chosen region. + guard let resolved = await resolveLocalityCoordinate() else { + // No location available yet (permission not granted, no chosen region). + // Leave any existing state in place. + return + } + + let profile = LocalityProfile.make( + from: resolved.coordinate, + displayRegion: resolved.displayName + ) + + // 2. Reuse a fresh cache entry, else fetch from iNaturalist and store. + let counts: [INatSpeciesCount] + let fetchedAt: Date + if let cached = await LocalCatalogCache.shared.freshEntry(for: profile.cacheKey) { + counts = cached.counts + fetchedAt = cached.fetchedAt + } else { + do { + let fetched = try await INaturalistService.shared.speciesCounts( + near: profile.coordinate, + radiusKm: Double(localCatalogRadiusKm), + month: profile.currentMonth + ) + await LocalCatalogCache.shared.store(fetched, for: profile.cacheKey) + counts = fetched + fetchedAt = .now + } catch { + // Network/rate-limit failure: fall back to a stale cache if we + // have one, otherwise keep existing state. + if let stale = await LocalCatalogCache.shared.entry(for: profile.cacheKey) { + counts = stale.counts + fetchedAt = stale.fetchedAt + } else { + print("refreshLocalCatalog: fetch failed and no cache: \(error)") + return + } + } + } + + // 3. Rank the bundled catalog against the counts (pure, synchronous). + let items = LocalRankingService().rank( + catalog: catalogPlants, + counts: counts, + month: profile.currentMonth, + radiusKm: localCatalogRadiusKm + ) + + // 4. Publish. Direct assignment is fine — these are stored properties. + localityProfile = profile + localCatalogItems = items + catalogFreshnessDate = fetchedAt + } + + /// Resolves the chosen Explore region into a coarse coordinate + display name. + /// For `.currentLocation` we ask `LocationService` (one-shot, may return nil + /// when permission isn't granted yet); for `.chosen` we use the stored values. + private func resolveLocalityCoordinate() async + -> (coordinate: CLLocationCoordinate2D, displayName: String?)? { + switch selectedRegionOverride { + case .currentLocation: + guard let coordinate = await LocationService.shared.requestCurrentLocation() else { + return nil + } + // Reverse-geocode the coarse cell center (not the precise fix) for a + // friendly label; geocoding failure is non-fatal. + let cellProfile = LocalityProfile.make(from: coordinate) + let name = await LocationGeocoderService.shared.reverseGeocode(cellProfile.coordinate) + return (coordinate, name) + case .chosen(let latitude, let longitude, let name): + return (CLLocationCoordinate2D(latitude: latitude, longitude: longitude), name) + } + } + + /// Convenience for the region picker: switch to a chosen city and refresh. + func selectRegion(_ region: ExploreRegion) async { + selectedRegionOverride = region + // Clear current items so the UI shows a loading state for the new region. + localCatalogItems = [] + await refreshLocalCatalog() + } + + // MARK: - Locale-aware Catalog Derived Views + + /// Whether we have a locality + ranked items to drive the ecology-led sections. + var hasLocalCatalog: Bool { + localityProfile != nil && !localCatalogItems.isEmpty + } + + /// Top items strongly reported nearby — the "Near You Now" section. + var nearYouNowItems: [LocalCatalogItem] { + localCatalogItems + .filter { $0.explanationCodes.contains { code in + if case .nearbyNow = code { return true } else { return false } + } } + .prefix(12) + .map { $0 } + } + + /// Items reported this month that aren't already in "Near You Now". + var reportedThisMonthItems: [LocalCatalogItem] { + let nearIDs = Set(nearYouNowItems.map { $0.id }) + return localCatalogItems + .filter { !nearIDs.contains($0.id) } + .prefix(12) + .map { $0 } + } + + /// Human-readable "Updated 2 days ago" string for the freshness pill. + var catalogFreshnessLabel: String? { + guard let date = catalogFreshnessDate else { return nil } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return "Updated \(formatter.localizedString(for: date, relativeTo: .now))" } // MARK: - Computed Collections From 33aeb38da83e7a8f2f7f3f53f34e5dc9fec5decb Mon Sep 17 00:00:00 2001 From: Tobias Fu Date: Wed, 24 Jun 2026 16:20:04 -0700 Subject: [PATCH 4/6] Restore CoreML offline fallback in capture identification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR2's candidate flow called Pl@ntNet directly via identifyCandidates, which dropped the on-device CoreML fallback the hybrid service provided — offline captures fell straight through to manual entry. Now a candidate-path failure falls back to HybridPlantIdentificationService.identify (API-first, CoreML offline) before manual entry, and the injected identificationService is no longer dead. Co-Authored-By: Claude Opus 4.8 --- .../Screens/Capture/CaptureViewModel.swift | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/Fieldnote/Screens/Capture/CaptureViewModel.swift b/Fieldnote/Screens/Capture/CaptureViewModel.swift index bb4a3c4..c4b9718 100644 --- a/Fieldnote/Screens/Capture/CaptureViewModel.swift +++ b/Fieldnote/Screens/Capture/CaptureViewModel.swift @@ -146,10 +146,11 @@ class CaptureViewModel { isIdentifying = false } - do { - // Fetch location for better API accuracy (non-blocking) - let location = await locationService.requestCurrentLocation() + // Fetch location for better API accuracy (non-blocking) + let location = await locationService.requestCurrentLocation() + let month = localMonth ?? Calendar.current.component(.month, from: Date()) + do { // Pull the top visual candidates so we can rerank + offer alternatives. let candidates = try await candidateService.identifyCandidates( image: image, @@ -158,7 +159,6 @@ class CaptureViewModel { ) // Rerank with the local + seasonal prior (visual signal stays dominant). - let month = localMonth ?? Calendar.current.component(.month, from: Date()) let ranked = LocalRankingService().rerankCandidates( candidates, localItems: localItems, @@ -180,17 +180,29 @@ class CaptureViewModel { alternatives: alternatives )) } catch { - identificationError = error - // Still show review sheet but with empty fields for manual entry - destination = .review(.mlIdentification( - result: PlantIdentificationResult( - commonName: "", - scientificName: "", - family: "", - confidence: 0.75 - ), - image: image - )) + // The candidate path is Pl@ntNet-only. When it fails (offline or API + // error), fall back to the hybrid service, which uses the on-device + // CoreML model offline. No alternatives are available on this path. + do { + let result = try await identificationService.identify( + image: image, + location: location + ) + subscriptionStore.recordIdentification() + destination = .review(.mlIdentification(result: result, image: image)) + } catch let fallbackError { + identificationError = fallbackError + // Still show review sheet but with empty fields for manual entry + destination = .review(.mlIdentification( + result: PlantIdentificationResult( + commonName: "", + scientificName: "", + family: "", + confidence: 0.75 + ), + image: image + )) + } } } From f8fbe4f98d9a0923170e6d302c12056d69d4facb Mon Sep 17 00:00:00 2001 From: Tobias Fu Date: Wed, 24 Jun 2026 16:28:10 -0700 Subject: [PATCH 5/6] A6: unit tests for locale-aware ranking + catalog join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds FieldnoteUnitTests (Swift Testing) covering the PR1 data layer: - LocalRankingService: count→catalog join (by iNat ID and name key), log-scaled occurrence ordering, bounded 0...1 scores, explanation-code selection, and visual-dominant identification reranking. - CatalogPlant: scientificNameKey normalization and match() precedence. - LocalityProfile: coarse-grid coarsening, hemisphere, and cache key. Also brings the FieldnoteUnitTests / FieldnoteUITests target definitions (synchronized groups) and scheme test entries onto this branch so the suite runs via `xcodebuild test -only-testing:FieldnoteUnitTests`. Co-Authored-By: Claude Opus 4.8 --- Fieldnote.xcodeproj/project.pbxproj | 260 +++++++++++++++++- .../xcshareddata/xcschemes/Fieldnote.xcscheme | 24 ++ FieldnoteUITests/FieldnoteUITests.swift | 41 +++ .../FieldnoteUITestsLaunchTests.swift | 33 +++ FieldnoteUnitTests/FieldnoteUnitTests.swift | 16 ++ .../LocalRankingServiceTests.swift | 259 +++++++++++++++++ .../LocaleCatalogModelTests.swift | 125 +++++++++ 7 files changed, 755 insertions(+), 3 deletions(-) create mode 100644 FieldnoteUITests/FieldnoteUITests.swift create mode 100644 FieldnoteUITests/FieldnoteUITestsLaunchTests.swift create mode 100644 FieldnoteUnitTests/FieldnoteUnitTests.swift create mode 100644 FieldnoteUnitTests/LocalRankingServiceTests.swift create mode 100644 FieldnoteUnitTests/LocaleCatalogModelTests.swift diff --git a/Fieldnote.xcodeproj/project.pbxproj b/Fieldnote.xcodeproj/project.pbxproj index ada75b3..9ef054d 100644 --- a/Fieldnote.xcodeproj/project.pbxproj +++ b/Fieldnote.xcodeproj/project.pbxproj @@ -16,6 +16,23 @@ 3EF888C72F0CBEDC0082C357 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3EF888C62F0CBEDB0082C357 /* StoreKit.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 3EF82F892FEC9E5F00DBE744 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3E6546982EF93C4C003C38AC /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3E65469F2EF93C4C003C38AC; + remoteInfo = Fieldnote; + }; + 3EF82F962FEC9E7200DBE744 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3E6546982EF93C4C003C38AC /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3E65469F2EF93C4C003C38AC; + remoteInfo = Fieldnote; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 3E6546A02EF93C4C003C38AC /* Fieldnote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Fieldnote.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3EB31FBE2F0284900016DB46 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -24,6 +41,8 @@ 3EB31FC22F0284A40016DB46 /* screenshot-3.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "screenshot-3.png"; sourceTree = ""; }; 3EB31FC32F0284A40016DB46 /* screenshot-4.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "screenshot-4.png"; sourceTree = ""; }; 3EB3212E2F0674FA0016DB46 /* Config.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Config.plist; sourceTree = ""; }; + 3EF82F832FEC9E5F00DBE744 /* FieldnoteUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FieldnoteUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3EF82F922FEC9E7200DBE744 /* FieldnoteUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FieldnoteUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3EF888C62F0CBEDB0082C357 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -33,6 +52,16 @@ path = Fieldnote; sourceTree = ""; }; + 3EF82F842FEC9E5F00DBE744 /* FieldnoteUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FieldnoteUITests; + sourceTree = ""; + }; + 3EF82F932FEC9E7200DBE744 /* FieldnoteUnitTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FieldnoteUnitTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -44,6 +73,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3EF82F802FEC9E5F00DBE744 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3EF82F8F2FEC9E7200DBE744 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -57,6 +100,8 @@ 3EB31FC32F0284A40016DB46 /* screenshot-4.png */, 3EB31FBE2F0284900016DB46 /* README.md */, 3E6546A22EF93C4C003C38AC /* Fieldnote */, + 3EF82F842FEC9E5F00DBE744 /* FieldnoteUITests */, + 3EF82F932FEC9E7200DBE744 /* FieldnoteUnitTests */, 3EF888C52F0CBEDB0082C357 /* Frameworks */, 3E6546A12EF93C4C003C38AC /* Products */, ); @@ -66,6 +111,8 @@ isa = PBXGroup; children = ( 3E6546A02EF93C4C003C38AC /* Fieldnote.app */, + 3EF82F832FEC9E5F00DBE744 /* FieldnoteUITests.xctest */, + 3EF82F922FEC9E7200DBE744 /* FieldnoteUnitTests.xctest */, ); name = Products; sourceTree = ""; @@ -103,6 +150,52 @@ productReference = 3E6546A02EF93C4C003C38AC /* Fieldnote.app */; productType = "com.apple.product-type.application"; }; + 3EF82F822FEC9E5F00DBE744 /* FieldnoteUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3EF82F8D2FEC9E5F00DBE744 /* Build configuration list for PBXNativeTarget "FieldnoteUITests" */; + buildPhases = ( + 3EF82F7F2FEC9E5F00DBE744 /* Sources */, + 3EF82F802FEC9E5F00DBE744 /* Frameworks */, + 3EF82F812FEC9E5F00DBE744 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3EF82F8A2FEC9E5F00DBE744 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3EF82F842FEC9E5F00DBE744 /* FieldnoteUITests */, + ); + name = FieldnoteUITests; + packageProductDependencies = ( + ); + productName = FieldnoteUITests; + productReference = 3EF82F832FEC9E5F00DBE744 /* FieldnoteUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 3EF82F912FEC9E7200DBE744 /* FieldnoteUnitTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3EF82F982FEC9E7200DBE744 /* Build configuration list for PBXNativeTarget "FieldnoteUnitTests" */; + buildPhases = ( + 3EF82F8E2FEC9E7200DBE744 /* Sources */, + 3EF82F8F2FEC9E7200DBE744 /* Frameworks */, + 3EF82F902FEC9E7200DBE744 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3EF82F972FEC9E7200DBE744 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3EF82F932FEC9E7200DBE744 /* FieldnoteUnitTests */, + ); + name = FieldnoteUnitTests; + packageProductDependencies = ( + ); + productName = FieldnoteUnitTests; + productReference = 3EF82F922FEC9E7200DBE744 /* FieldnoteUnitTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -110,12 +203,20 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2620; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 2620; TargetAttributes = { 3E65469F2EF93C4C003C38AC = { CreatedOnToolsVersion = 26.2; }; + 3EF82F822FEC9E5F00DBE744 = { + CreatedOnToolsVersion = 26.0.1; + TestTargetID = 3E65469F2EF93C4C003C38AC; + }; + 3EF82F912FEC9E7200DBE744 = { + CreatedOnToolsVersion = 26.0.1; + TestTargetID = 3E65469F2EF93C4C003C38AC; + }; }; }; buildConfigurationList = 3E65469B2EF93C4C003C38AC /* Build configuration list for PBXProject "Fieldnote" */; @@ -133,6 +234,8 @@ projectRoot = ""; targets = ( 3E65469F2EF93C4C003C38AC /* Fieldnote */, + 3EF82F822FEC9E5F00DBE744 /* FieldnoteUITests */, + 3EF82F912FEC9E7200DBE744 /* FieldnoteUnitTests */, ); }; /* End PBXProject section */ @@ -151,6 +254,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3EF82F812FEC9E5F00DBE744 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3EF82F902FEC9E7200DBE744 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -161,8 +278,35 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3EF82F7F2FEC9E5F00DBE744 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3EF82F8E2FEC9E7200DBE744 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 3EF82F8A2FEC9E5F00DBE744 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3E65469F2EF93C4C003C38AC /* Fieldnote */; + targetProxy = 3EF82F892FEC9E5F00DBE744 /* PBXContainerItemProxy */; + }; + 3EF82F972FEC9E7200DBE744 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3E65469F2EF93C4C003C38AC /* Fieldnote */; + targetProxy = 3EF82F962FEC9E7200DBE744 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 3E6546A92EF93C4E003C38AC /* Debug */ = { isa = XCBuildConfiguration; @@ -306,7 +450,7 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; @@ -356,7 +500,7 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; @@ -378,6 +522,98 @@ }; name = Release; }; + 3EF82F8B2FEC9E5F00DBE744 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 679K683SQ5; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tobiasfu.FieldnoteUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Fieldnote; + }; + name = Debug; + }; + 3EF82F8C2FEC9E5F00DBE744 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 679K683SQ5; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tobiasfu.FieldnoteUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Fieldnote; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 3EF82F992FEC9E7200DBE744 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 679K683SQ5; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tobiasfu.FieldnoteUnitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Fieldnote.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Fieldnote"; + }; + name = Debug; + }; + 3EF82F9A2FEC9E7200DBE744 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 679K683SQ5; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tobiasfu.FieldnoteUnitTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Fieldnote.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Fieldnote"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -399,6 +635,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 3EF82F8D2FEC9E5F00DBE744 /* Build configuration list for PBXNativeTarget "FieldnoteUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3EF82F8B2FEC9E5F00DBE744 /* Debug */, + 3EF82F8C2FEC9E5F00DBE744 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3EF82F982FEC9E7200DBE744 /* Build configuration list for PBXNativeTarget "FieldnoteUnitTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3EF82F992FEC9E7200DBE744 /* Debug */, + 3EF82F9A2FEC9E7200DBE744 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 3E6546982EF93C4C003C38AC /* Project object */; diff --git a/Fieldnote.xcodeproj/xcshareddata/xcschemes/Fieldnote.xcscheme b/Fieldnote.xcodeproj/xcshareddata/xcschemes/Fieldnote.xcscheme index ff3c471..48cc17e 100644 --- a/Fieldnote.xcodeproj/xcshareddata/xcschemes/Fieldnote.xcscheme +++ b/Fieldnote.xcodeproj/xcshareddata/xcschemes/Fieldnote.xcscheme @@ -29,6 +29,30 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + + + + + CatalogPlant { + CatalogPlant( + commonName: common, + scientificName: scientific, + family: "TestFamily", + habitat: "test", + traits: [], + inaturalistTaxonID: inatID, + monthlyAffinity: monthly + ) + } + + private func count( + taxonID: Int, + scientific: String, + count: Int + ) -> INatSpeciesCount { + INatSpeciesCount( + taxonID: taxonID, + scientificName: scientific, + commonName: nil, + count: count, + defaultPhotoURL: nil, + defaultPhotoLicense: nil + ) + } + + // MARK: - Matching + + @Test("Joins counts to catalog by iNaturalist taxon ID") + func matchesByTaxonID() { + let catalog = [plant("Dandelion", scientific: "Taraxacum officinale", inatID: 47602)] + // Different name spelling, but the taxon ID matches. + let counts = [count(taxonID: 47602, scientific: "Taraxacum erythrospermum", count: 10)] + + let ranked = LocalRankingService().rank(catalog: catalog, counts: counts, month: 6, radiusKm: 25) + + #expect(ranked.count == 1) + #expect(ranked.first?.catalogPlant.commonName == "Dandelion") + #expect(ranked.first?.nearbyObservationCount == 10) + } + + @Test("Falls back to scientific-name key when no ID is present") + func matchesByNameKey() { + let catalog = [plant("Yarrow", scientific: "Achillea millefolium")] + // Authorship suffix should not block the join. + let counts = [count(taxonID: 1, scientific: "Achillea millefolium L.", count: 5)] + + let ranked = LocalRankingService().rank(catalog: catalog, counts: counts, month: 6, radiusKm: 25) + + #expect(ranked.count == 1) + #expect(ranked.first?.catalogPlant.commonName == "Yarrow") + } + + @Test("Drops catalog entries with no local match") + func dropsUnmatched() { + let catalog = [ + plant("Yarrow", scientific: "Achillea millefolium"), + plant("Ghost Plant", scientific: "Monotropa uniflora") + ] + let counts = [count(taxonID: 1, scientific: "Achillea millefolium", count: 5)] + + let ranked = LocalRankingService().rank(catalog: catalog, counts: counts, month: 6, radiusKm: 25) + + #expect(ranked.map(\.catalogPlant.commonName) == ["Yarrow"]) + } + + @Test("Empty counts yields no items") + func emptyCounts() { + let catalog = [plant("Yarrow", scientific: "Achillea millefolium")] + let ranked = LocalRankingService().rank(catalog: catalog, counts: [], month: 6, radiusKm: 25) + #expect(ranked.isEmpty) + } + + // MARK: - Scoring + + @Test("Higher occurrence ranks ahead of lower occurrence") + func higherOccurrenceRanksFirst() { + let catalog = [ + plant("Common", scientific: "Aaa aaa"), + plant("Rare", scientific: "Bbb bbb") + ] + let counts = [ + count(taxonID: 1, scientific: "Aaa aaa", count: 1000), + count(taxonID: 2, scientific: "Bbb bbb", count: 2) + ] + + let ranked = LocalRankingService().rank(catalog: catalog, counts: counts, month: 6, radiusKm: 25) + + #expect(ranked.first?.catalogPlant.commonName == "Common") + #expect(ranked.last?.catalogPlant.commonName == "Rare") + } + + @Test("Occurrence is log-scaled so a huge weed doesn't swamp a strong-seasonal plant") + func logScalingTempersWeeds() { + // June-peaking plant with modest counts vs. a year-round weed with massive counts. + var juneAffinity = Array(repeating: 0.1, count: 12) + juneAffinity[5] = 1.0 // June + + let catalog = [ + plant("Weed", scientific: "Aaa aaa"), + plant("JuneBloom", scientific: "Bbb bbb", monthly: juneAffinity) + ] + let counts = [ + count(taxonID: 1, scientific: "Aaa aaa", count: 100_000), + count(taxonID: 2, scientific: "Bbb bbb", count: 300) + ] + + let ranked = LocalRankingService().rank(catalog: catalog, counts: counts, month: 6, radiusKm: 25) + let weed = try? #require(ranked.first { $0.catalogPlant.commonName == "Weed" }) + let bloom = try? #require(ranked.first { $0.catalogPlant.commonName == "JuneBloom" }) + + // The weed still leads, but the seasonal plant stays competitive rather than + // being buried — its score should be a meaningful fraction of the weed's. + if let weed, let bloom { + #expect(bloom.rankScore > weed.rankScore * 0.5) + } + } + + @Test("Scores are bounded in 0...1") + func scoresBounded() { + let catalog = [plant("X", scientific: "Aaa aaa")] + let counts = [count(taxonID: 1, scientific: "Aaa aaa", count: 50)] + let ranked = LocalRankingService().rank(catalog: catalog, counts: counts, month: 6, radiusKm: 25) + let score = ranked.first?.rankScore ?? -1 + #expect(score >= 0 && score <= 1) + } + + // MARK: - Explanations + + @Test("Strong occurrence yields nearbyNow + easyFirstFind copy") + func strongOccurrenceExplanations() { + let catalog = [ + plant("Top", scientific: "Aaa aaa"), + plant("Low", scientific: "Bbb bbb") + ] + let counts = [ + count(taxonID: 1, scientific: "Aaa aaa", count: 1000), + count(taxonID: 2, scientific: "Bbb bbb", count: 1) + ] + + let ranked = LocalRankingService().rank(catalog: catalog, counts: counts, month: 6, radiusKm: 25) + let top = try? #require(ranked.first { $0.catalogPlant.commonName == "Top" }) + + if let top { + #expect(top.explanationCodes.contains(.nearbyNow(radiusKm: 25))) + #expect(top.explanationCodes.contains(.easyFirstFind)) + } + } + + @Test("Weak occurrence uses reportedThisMonth copy, not nearbyNow") + func weakOccurrenceExplanations() { + let catalog = [ + plant("Top", scientific: "Aaa aaa"), + plant("Low", scientific: "Bbb bbb") + ] + let counts = [ + count(taxonID: 1, scientific: "Aaa aaa", count: 1000), + count(taxonID: 2, scientific: "Bbb bbb", count: 1) + ] + + let ranked = LocalRankingService().rank(catalog: catalog, counts: counts, month: 6, radiusKm: 25) + let low = try? #require(ranked.first { $0.catalogPlant.commonName == "Low" }) + + if let low { + #expect(low.explanationCodes.contains(.reportedThisMonth(monthName: "June"))) + #expect(!low.explanationCodes.contains(.nearbyNow(radiusKm: 25))) + } + } + + @Test("Seasonal peak code appears in the plant's peak month only") + func seasonalPeakExplanation() { + var juneAffinity = Array(repeating: 0.1, count: 12) + juneAffinity[5] = 1.0 + + let catalog = [plant("JuneBloom", scientific: "Aaa aaa", monthly: juneAffinity)] + let counts = [count(taxonID: 1, scientific: "Aaa aaa", count: 50)] + + let june = LocalRankingService().rank(catalog: catalog, counts: counts, month: 6, radiusKm: 25) + let january = LocalRankingService().rank(catalog: catalog, counts: counts, month: 1, radiusKm: 25) + + #expect(june.first?.explanationCodes.contains(.seasonalPeak(monthName: "June")) == true) + #expect(january.first?.explanationCodes.contains(.seasonalPeak(monthName: "January")) == false) + } + + // MARK: - Reranking + + @Test("Local support boosts a candidate but visual signal stays dominant") + func rerankKeepsVisualDominant() { + let local = plant("LocalCommon", scientific: "Bbb bbb") + let counts = [count(taxonID: 1, scientific: "Bbb bbb", count: 1000)] + let localItems = LocalRankingService().rank(catalog: [local], counts: counts, month: 6, radiusKm: 25) + + // Strong visual match with no local support vs. weak visual match that IS local. + let strongVisual = PlantIdentificationCandidate( + commonName: "StrongVisual", scientificName: "Aaa aaa", + family: "F", visualConfidence: 0.9, gbifTaxonKey: nil + ) + let weakLocal = PlantIdentificationCandidate( + commonName: "LocalCommon", scientificName: "Bbb bbb", + family: "F", visualConfidence: 0.5, gbifTaxonKey: nil + ) + + let ranked = LocalRankingService().rerankCandidates( + [strongVisual, weakLocal], localItems: localItems, month: 6 + ) + + // The 0.9 visual match must not be overtaken by a 0.5 local match — the + // bounded prior can't rescue a much weaker visual candidate. + #expect(ranked.first?.candidate.scientificName == "Aaa aaa") + // The local candidate is flagged as locally supported. + let localRanked = ranked.first { $0.candidate.scientificName == "Bbb bbb" } + #expect(localRanked?.hasLocalSupport == true) + #expect(localRanked?.nearbyObservationCount == 1000) + } + + @Test("Local support breaks ties between equal visual matches") + func rerankBreaksTiesByLocal() { + let local = plant("LocalCommon", scientific: "Bbb bbb") + let counts = [count(taxonID: 1, scientific: "Bbb bbb", count: 1000)] + let localItems = LocalRankingService().rank(catalog: [local], counts: counts, month: 6, radiusKm: 25) + + let noSupport = PlantIdentificationCandidate( + commonName: "Elsewhere", scientificName: "Aaa aaa", + family: "F", visualConfidence: 0.8, gbifTaxonKey: nil + ) + let supported = PlantIdentificationCandidate( + commonName: "LocalCommon", scientificName: "Bbb bbb", + family: "F", visualConfidence: 0.8, gbifTaxonKey: nil + ) + + let ranked = LocalRankingService().rerankCandidates( + [noSupport, supported], localItems: localItems, month: 6 + ) + + #expect(ranked.first?.candidate.scientificName == "Bbb bbb") + } +} diff --git a/FieldnoteUnitTests/LocaleCatalogModelTests.swift b/FieldnoteUnitTests/LocaleCatalogModelTests.swift new file mode 100644 index 0000000..13c8e39 --- /dev/null +++ b/FieldnoteUnitTests/LocaleCatalogModelTests.swift @@ -0,0 +1,125 @@ +// +// LocaleCatalogModelTests.swift +// FieldnoteUnitTests +// +// CatalogPlant join keys (A1) and LocalityProfile coarsening (A3). +// + +import Testing +import Foundation +import CoreLocation +@testable import Fieldnote + +@Suite("CatalogPlant locale join") +struct CatalogPlantJoinTests { + + private func plant(_ scientific: String, inatID: Int? = nil) -> CatalogPlant { + CatalogPlant( + commonName: "C", + scientificName: scientific, + family: "F", + habitat: "h", + traits: [], + inaturalistTaxonID: inatID + ) + } + + @Test("Scientific-name key strips authorship and subspecies to genus + species") + func nameKeyReducesToBinomial() { + #expect(CatalogPlant.scientificNameKey("Taraxacum officinale F.H.Wigg.") == "taraxacum officinale") + #expect(CatalogPlant.scientificNameKey("Achillea millefolium L.") == "achillea millefolium") + #expect(CatalogPlant.scientificNameKey("Quercus") == "quercus") + #expect(CatalogPlant.scientificNameKey(" Rosa canina ") == "rosa canina") + } + + @Test("Match prefers iNaturalist ID over name") + func matchPrefersID() { + let catalog = [ + plant("Aaa aaa", inatID: 100), + plant("Bbb bbb", inatID: 200) + ] + // Name says "Aaa aaa" but ID 200 should win. + let match = CatalogPlant.match( + inaturalistTaxonID: 200, + scientificName: "Aaa aaa", + in: catalog + ) + #expect(match?.scientificName == "Bbb bbb") + } + + @Test("Match falls back to name key when ID is absent or unknown") + func matchFallsBackToName() { + let catalog = [plant("Achillea millefolium", inatID: nil)] + let match = CatalogPlant.match( + inaturalistTaxonID: nil, + scientificName: "Achillea millefolium subsp. lanulosa", + in: catalog + ) + #expect(match?.scientificName == "Achillea millefolium") + } + + @Test("No match returns nil") + func noMatch() { + let catalog = [plant("Achillea millefolium")] + let match = CatalogPlant.match( + inaturalistTaxonID: 999, + scientificName: "Monotropa uniflora", + in: catalog + ) + #expect(match == nil) + } + + @Test("New locale fields default to nil and don't disturb existing data") + func optionalFieldsDefaultNil() { + let p = plant("Rosa canina") + #expect(p.gbifTaxonKey == nil) + #expect(p.inaturalistTaxonID == nil) + #expect(p.monthlyAffinity == nil) + } +} + +@Suite("LocalityProfile coarsening") +struct LocalityProfileTests { + + @Test("Snaps coordinates to the coarse grid") + func snapsToGrid() { + let precise = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) + let profile = LocalityProfile.make(from: precise) + + // 0.1-degree grid: 37.7749 -> 37.8, -122.4194 -> -122.4 + #expect(abs(profile.latitude - 37.8) < 0.0001) + #expect(abs(profile.longitude - (-122.4)) < 0.0001) + // The precise coordinate must not survive in the stored profile. + #expect(profile.latitude != precise.latitude) + } + + @Test("Derives hemisphere from latitude") + func hemisphere() { + let north = LocalityProfile.make(from: .init(latitude: 40, longitude: 0)) + let south = LocalityProfile.make(from: .init(latitude: -33, longitude: 151)) + #expect(north.hemisphere == .northern) + #expect(south.hemisphere == .southern) + } + + @Test("Cache key combines cell and month") + func cacheKeyCombinesCellAndMonth() { + var june = DateComponents() + june.year = 2026; june.month = 6; june.day = 15 + let date = Calendar(identifier: .gregorian).date(from: june)! + + let profile = LocalityProfile.make( + from: .init(latitude: 37.8, longitude: -122.4), + now: date, + calendar: Calendar(identifier: .gregorian) + ) + #expect(profile.currentMonth == 6) + #expect(profile.cacheKey == "\(profile.coarseCellID)@6") + } + + @Test("Nearby coordinates collapse to the same cell") + func nearbyCoordsShareCell() { + let a = LocalityProfile.make(from: .init(latitude: 37.78, longitude: -122.41)) + let b = LocalityProfile.make(from: .init(latitude: 37.82, longitude: -122.43)) + #expect(a.coarseCellID == b.coarseCellID) + } +} From bf5bd941ca8d1615677ef5827329b3214c03ad66 Mon Sep 17 00:00:00 2001 From: Tobias Fu Date: Wed, 24 Jun 2026 16:34:29 -0700 Subject: [PATCH 6/6] Add SwiftUI previews for locale-aware UI; extract alternatives card So the new Explore + capture surfaces are reviewable in Xcode canvas: - LocalCatalogSection / LocalCatalogCard: previews via DEBUG-only LocaleCatalogPreviewData (Near You Now + Reported This Month, discovered and undiscovered states). - LocalDiscoveryPrompt: pre-permission explainer preview. - RegionPickerSheet: region list preview. - Extracted the capture "Other possibilities" UI from CaptureReviewSheet into a standalone AlternativeCandidatesCard view with its own preview (the sheet now composes it, passing applyCandidate). Build + FieldnoteUnitTests green. Co-Authored-By: Claude Opus 4.8 --- .../Capture/AlternativeCandidatesCard.swift | 120 ++++++++++++++++++ .../Screens/Capture/CaptureReviewSheet.swift | 67 +--------- .../Components/LocalCatalogSection.swift | 37 ++++++ .../Components/LocalDiscoveryPrompt.swift | 13 ++ .../Components/LocaleCatalogPreviewData.swift | 44 +++++++ .../Components/RegionPickerSheet.swift | 6 + 6 files changed, 224 insertions(+), 63 deletions(-) create mode 100644 Fieldnote/Screens/Capture/AlternativeCandidatesCard.swift create mode 100644 Fieldnote/Screens/Explore/Components/LocaleCatalogPreviewData.swift diff --git a/Fieldnote/Screens/Capture/AlternativeCandidatesCard.swift b/Fieldnote/Screens/Capture/AlternativeCandidatesCard.swift new file mode 100644 index 0000000..78acd18 --- /dev/null +++ b/Fieldnote/Screens/Capture/AlternativeCandidatesCard.swift @@ -0,0 +1,120 @@ +// +// AlternativeCandidatesCard.swift +// Fieldnote +// +// "Other possibilities" card shown in the capture review sheet when the +// identification reranker returns close runner-up candidates. These are offered +// as "did you mean…?", not as equal claims — the visual signal stays dominant. +// See LocaleAwareCatalogImplementationPlan.md (B4). +// + +import SwiftUI + +struct AlternativeCandidatesCard: View { + let alternatives: [RankedCandidate] + let onSelect: (PlantIdentificationCandidate) -> Void + + var body: some View { + VintageCard { + VStack(alignment: .leading, spacing: FieldSpace.sm) { + HStack(spacing: FieldSpace.xs) { + Image(systemName: "questionmark.circle") + .font(.caption) + .foregroundColor(FieldColor.mutedInk) + Text("Other possibilities") + .font(FieldType.bodyEmphasized) + .foregroundColor(FieldColor.vintageInk) + } + + Text("If this isn't quite right, tap a closer match.") + .font(FieldType.caption) + .foregroundColor(FieldColor.fadedInk) + + ForEach(alternatives) { ranked in + Button { + onSelect(ranked.candidate) + } label: { + row(for: ranked) + } + .buttonStyle(.plain) + } + } + } + } + + private func row(for ranked: RankedCandidate) -> some View { + HStack(spacing: FieldSpace.sm) { + VStack(alignment: .leading, spacing: 2) { + Text(ranked.candidate.commonName) + .font(FieldType.callout) + .foregroundColor(FieldColor.ink) + .lineLimit(1) + Text(ranked.candidate.scientificName) + .font(FieldType.caption) + .foregroundColor(FieldColor.fadedInk) + .italic() + .lineLimit(1) + if ranked.hasLocalSupport { + Text("Also reported nearby") + .font(FieldType.caption2) + .foregroundColor(FieldColor.accent) + } + } + + Spacer() + + Text("\(Int((ranked.candidate.visualConfidence * 100).rounded()))%") + .font(FieldType.caption) + .foregroundColor(FieldColor.mutedInk) + + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundColor(FieldColor.fadedInk) + } + .padding(FieldSpace.sm) + .background(FieldColor.surface) + .cornerRadius(FieldRadius.sm) + .overlay( + RoundedRectangle(cornerRadius: FieldRadius.sm) + .stroke(FieldColor.bookBorder.opacity(0.5), lineWidth: 0.5) + ) + } +} + +#if DEBUG +#Preview("Alternative Candidates") { + ScrollView { + AlternativeCandidatesCard( + alternatives: [ + RankedCandidate( + candidate: PlantIdentificationCandidate( + commonName: "California Poppy", + scientificName: "Eschscholzia californica", + family: "Papaveraceae", + visualConfidence: 0.74, + gbifTaxonKey: nil + ), + combinedScore: 0.81, + hasLocalSupport: true, + nearbyObservationCount: 940 + ), + RankedCandidate( + candidate: PlantIdentificationCandidate( + commonName: "Mexican Poppy", + scientificName: "Eschscholzia mexicana", + family: "Papaveraceae", + visualConfidence: 0.41, + gbifTaxonKey: nil + ), + combinedScore: 0.42, + hasLocalSupport: false, + nearbyObservationCount: 0 + ) + ], + onSelect: { _ in } + ) + .padding() + } + .background(FieldColor.paper) +} +#endif diff --git a/Fieldnote/Screens/Capture/CaptureReviewSheet.swift b/Fieldnote/Screens/Capture/CaptureReviewSheet.swift index 59e5c2e..e045c81 100644 --- a/Fieldnote/Screens/Capture/CaptureReviewSheet.swift +++ b/Fieldnote/Screens/Capture/CaptureReviewSheet.swift @@ -149,7 +149,10 @@ struct CaptureReviewSheet: View { // close runners-up). Visual signal stays dominant — these // are offered as "did you mean…?", not as equal claims. if !alternatives.isEmpty { - alternativesCard + AlternativeCandidatesCard( + alternatives: alternatives, + onSelect: applyCandidate + ) } // Confidence @@ -351,68 +354,6 @@ struct CaptureReviewSheet: View { // MARK: - Alternatives - private var alternativesCard: some View { - VintageCard { - VStack(alignment: .leading, spacing: FieldSpace.sm) { - HStack(spacing: FieldSpace.xs) { - Image(systemName: "questionmark.circle") - .font(.caption) - .foregroundColor(FieldColor.mutedInk) - Text("Other possibilities") - .font(FieldType.bodyEmphasized) - .foregroundColor(FieldColor.vintageInk) - } - - Text("If this isn't quite right, tap a closer match.") - .font(FieldType.caption) - .foregroundColor(FieldColor.fadedInk) - - ForEach(alternatives) { ranked in - Button { - applyCandidate(ranked.candidate) - } label: { - HStack(spacing: FieldSpace.sm) { - VStack(alignment: .leading, spacing: 2) { - Text(ranked.candidate.commonName) - .font(FieldType.callout) - .foregroundColor(FieldColor.ink) - .lineLimit(1) - Text(ranked.candidate.scientificName) - .font(FieldType.caption) - .foregroundColor(FieldColor.fadedInk) - .italic() - .lineLimit(1) - if ranked.hasLocalSupport { - Text("Also reported nearby") - .font(FieldType.caption2) - .foregroundColor(FieldColor.accent) - } - } - - Spacer() - - Text("\(Int((ranked.candidate.visualConfidence * 100).rounded()))%") - .font(FieldType.caption) - .foregroundColor(FieldColor.mutedInk) - - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundColor(FieldColor.fadedInk) - } - .padding(FieldSpace.sm) - .background(FieldColor.surface) - .cornerRadius(FieldRadius.sm) - .overlay( - RoundedRectangle(cornerRadius: FieldRadius.sm) - .stroke(FieldColor.bookBorder.opacity(0.5), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - } - } - } - } - private func applyCandidate(_ candidate: PlantIdentificationCandidate) { commonName = candidate.commonName scientificName = candidate.scientificName diff --git a/Fieldnote/Screens/Explore/Components/LocalCatalogSection.swift b/Fieldnote/Screens/Explore/Components/LocalCatalogSection.swift index 82703e1..73da70c 100644 --- a/Fieldnote/Screens/Explore/Components/LocalCatalogSection.swift +++ b/Fieldnote/Screens/Explore/Components/LocalCatalogSection.swift @@ -118,3 +118,40 @@ struct LocalCatalogCard: View { .frame(width: 140) } } + +#if DEBUG +#Preview("Local Catalog Section") { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: FieldSpace.xl) { + LocalCatalogSection( + title: "Near You Now", + items: LocaleCatalogPreviewData.items, + isDiscovered: LocaleCatalogPreviewData.isDiscovered + ) + LocalCatalogSection( + title: "Reported This Month", + items: Array(LocaleCatalogPreviewData.items.reversed()), + isDiscovered: LocaleCatalogPreviewData.isDiscovered + ) + } + .padding(.vertical, FieldSpace.md) + } + .background(FieldColor.paper) + .navigationDestination(for: CatalogPlant.self) { _ in EmptyView() } + } +} + +#Preview("Local Catalog Card") { + HStack(spacing: FieldSpace.md) { + if let discovered = LocaleCatalogPreviewData.items.first { + LocalCatalogCard(item: discovered, isDiscovered: true) + } + if let undiscovered = LocaleCatalogPreviewData.items.last { + LocalCatalogCard(item: undiscovered, isDiscovered: false) + } + } + .padding() + .background(FieldColor.paper) +} +#endif diff --git a/Fieldnote/Screens/Explore/Components/LocalDiscoveryPrompt.swift b/Fieldnote/Screens/Explore/Components/LocalDiscoveryPrompt.swift index c339019..6569d33 100644 --- a/Fieldnote/Screens/Explore/Components/LocalDiscoveryPrompt.swift +++ b/Fieldnote/Screens/Explore/Components/LocalDiscoveryPrompt.swift @@ -83,3 +83,16 @@ struct LocalDiscoveryPrompt: View { } } } + +#if DEBUG +#Preview("Local Discovery Prompt") { + ScrollView { + LocalDiscoveryPrompt( + onUseLocation: {}, + onChooseRegion: { _ in } + ) + .padding(.vertical, FieldSpace.md) + } + .background(FieldColor.paper) +} +#endif diff --git a/Fieldnote/Screens/Explore/Components/LocaleCatalogPreviewData.swift b/Fieldnote/Screens/Explore/Components/LocaleCatalogPreviewData.swift new file mode 100644 index 0000000..8ac0097 --- /dev/null +++ b/Fieldnote/Screens/Explore/Components/LocaleCatalogPreviewData.swift @@ -0,0 +1,44 @@ +// +// LocaleCatalogPreviewData.swift +// Fieldnote +// +// Sample locale-aware data for SwiftUI previews of the new Explore components. +// DEBUG-only — never compiled into release. +// + +#if DEBUG +import Foundation + +enum LocaleCatalogPreviewData { + /// A spread of ranked items with varied "Why this plant?" explanations. + static let items: [LocalCatalogItem] = { + let plants = Array(CatalogPlant.catalog.prefix(8)) + let codes: [[ExplanationCode]] = [ + [.nearbyNow(radiusKm: 25), .easyFirstFind], + [.reportedThisMonth(monthName: "June"), .seasonalPeak(monthName: "June")], + [.nearbyNow(radiusKm: 25)], + [.reportedThisMonth(monthName: "June")], + [.seasonalPeak(monthName: "June")], + [.easyFirstFind], + [.nearbyNow(radiusKm: 25), .seasonalPeak(monthName: "June")], + [.reportedThisMonth(monthName: "June")] + ] + let counts = [1240, 360, 880, 45, 210, 670, 1530, 120] + let maxCount = Double(counts.max() ?? 1) + return plants.enumerated().map { index, plant in + let count = counts[index % counts.count] + return LocalCatalogItem( + catalogPlant: plant, + nearbyObservationCount: count, + rankScore: Double(count) / maxCount, + explanationCodes: codes[index % codes.count] + ) + } + }() + + /// Treat the first two sample plants as already discovered. + static func isDiscovered(_ plant: CatalogPlant) -> Bool { + items.prefix(2).contains { $0.catalogPlant.id == plant.id } + } +} +#endif diff --git a/Fieldnote/Screens/Explore/Components/RegionPickerSheet.swift b/Fieldnote/Screens/Explore/Components/RegionPickerSheet.swift index c717b41..9f00448 100644 --- a/Fieldnote/Screens/Explore/Components/RegionPickerSheet.swift +++ b/Fieldnote/Screens/Explore/Components/RegionPickerSheet.swift @@ -86,3 +86,9 @@ struct RegionPickerSheet: View { } } } + +#if DEBUG +#Preview("Region Picker") { + RegionPickerSheet { _ in } +} +#endif