Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
264 changes: 259 additions & 5 deletions Fieldnote.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions Fieldnote.xcodeproj/xcshareddata/xcschemes/Fieldnote.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,30 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3EF82F822FEC9E5F00DBE744"
BuildableName = "FieldnoteUITests.xctest"
BlueprintName = "FieldnoteUITests"
ReferencedContainer = "container:Fieldnote.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3EF82F912FEC9E7200DBE744"
BuildableName = "FieldnoteUnitTests.xctest"
BlueprintName = "FieldnoteUnitTests"
ReferencedContainer = "container:Fieldnote.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
Expand Down
140 changes: 140 additions & 0 deletions Fieldnote/Docs/LocaleAwareCatalogImplementationPlan.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 8 additions & 2 deletions Fieldnote/FieldnoteApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions Fieldnote/Models/Achievement.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
59 changes: 59 additions & 0 deletions Fieldnote/Models/BadgeCatalog.swift
Original file line number Diff line number Diff line change
@@ -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))
]
}
22 changes: 19 additions & 3 deletions Fieldnote/Models/CaptureMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 []
}
}
}
51 changes: 51 additions & 0 deletions Fieldnote/Models/FieldProfile.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading