From 5b217a048e3e3f8c53eca7208f498f4de2e07836 Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Thu, 9 Jul 2026 14:10:20 -0500
Subject: [PATCH 001/108] disable autocorrect for UniversalSearch text inputs
---
src/components/Explore/ExploreV2/screens/UniversalSearch.tsx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx
index 4ba27c032..1f7aaa36a 100644
--- a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx
+++ b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx
@@ -223,6 +223,7 @@ const UniversalSearch = ( ) => {
{
onFocus={handleSubjectFocus}
placeholder={t( "Search-for-species-user-or-project" )}
placeholderTextColor={colors.mediumGray}
+ spellCheck={false}
testID="UniversalSearch.subjectInput"
value={subjectText}
/>
@@ -238,6 +240,7 @@ const UniversalSearch = ( ) => {
{
placeholder={t( "Search-for-a-location" )}
placeholderTextColor={colors.mediumGray}
ref={locationInputRef}
+ spellCheck={false}
testID="UniversalSearch.locationInput"
value={locationText}
/>
From 79d5f0476a21c122ec128ce5d936dfea2c518363 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Sat, 11 Jul 2026 22:55:24 +0200
Subject: [PATCH 002/108] Basic fct to validate a POF
---
.../validateProjectFieldsForObservation.ts | 48 +++++++++++++++++++
1 file changed, 48 insertions(+)
create mode 100644 src/sharedHelpers/validateProjectFieldsForObservation.ts
diff --git a/src/sharedHelpers/validateProjectFieldsForObservation.ts b/src/sharedHelpers/validateProjectFieldsForObservation.ts
new file mode 100644
index 000000000..f77cd0e47
--- /dev/null
+++ b/src/sharedHelpers/validateProjectFieldsForObservation.ts
@@ -0,0 +1,48 @@
+import type {
+ RealmProjectObservationField,
+} from "realmModels/types";
+
+// Machine-readable reason codes. UI layers map these to localized
+// strings; membership-rule validation uses a separate module with its
+// own error strings.
+export const MISSING_REQUIRED = "MISSING_REQUIRED" as const;
+export const INVALID_NUMERIC = "INVALID_NUMERIC" as const;
+
+export type ProjectFieldValidationReason =
+ | typeof MISSING_REQUIRED
+ | typeof INVALID_NUMERIC;
+
+export type ProjectObservationFieldLike = Pick<
+ RealmProjectObservationField,
+ "required" | "obsField"
+>;
+
+/**
+ * Validates a single value param against a POF.
+ *
+ * Rules (parity with Android Legacy ProjectFieldViewer.isValid()):
+ * - required: value must be non-empty after trim
+ * - numeric datatype: a non-empty value must parse as a float, whether or
+ * not the field is required
+ *
+ * Returns null when valid.
+ */
+export function validateProjectFieldValue(
+ pof: ProjectObservationFieldLike,
+ value?: string | null,
+): ProjectFieldValidationReason | null {
+ const trimmed = ( value ?? "" ).trim( );
+ if ( pof.required && trimmed === "" ) {
+ return MISSING_REQUIRED;
+ }
+ if (
+ pof.obsField?.datatype === "numeric"
+ && trimmed !== ""
+ // Number( ), unlike parseFloat( ), rejects things like
+ // "1.5abc", matching Android Legacy's Float.valueOf
+ && !Number.isFinite( Number( trimmed ) )
+ ) {
+ return INVALID_NUMERIC;
+ }
+ return null;
+}
From 5e10ceead60c8344984bdb2e21667b158458ceeb Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Sat, 11 Jul 2026 23:13:37 +0200
Subject: [PATCH 003/108] numeric fields should return INVALID_NUMERIC for a
non-numeric value on an optional field
---
...alidateProjectFieldsForObservation.test.js | 20 +++++++++++++++++++
1 file changed, 20 insertions(+)
create mode 100644 tests/unit/helpers/validateProjectFieldsForObservation.test.js
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
new file mode 100644
index 000000000..4c33cba00
--- /dev/null
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -0,0 +1,20 @@
+import {
+ INVALID_NUMERIC,
+ validateProjectFieldValue,
+} from "sharedHelpers/validateProjectFieldsForObservation";
+import factory from "tests/factory";
+
+describe( "validateProjectFieldValue", () => {
+ describe( "numeric fields", () => {
+ it( "should return INVALID_NUMERIC for a non-numeric value on an optional field", () => {
+ const mockPOF = factory( "LocalProjectObservationField", {
+ required: false,
+ obsField: factory( "LocalObservationField", {
+ allowedValues: [],
+ datatype: "numeric",
+ } ),
+ } );
+ expect( validateProjectFieldValue( mockPOF, "abc" ) ).toBe( INVALID_NUMERIC );
+ } );
+ } );
+} );
From 7020936d044a2ae02fe408210f0c80cc009174e9 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Sat, 11 Jul 2026 23:17:09 +0200
Subject: [PATCH 004/108] it should return null for empty values on an optional
numeric field
---
.../validateProjectFieldsForObservation.test.js | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 4c33cba00..14092317f 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -16,5 +16,19 @@ describe( "validateProjectFieldValue", () => {
} );
expect( validateProjectFieldValue( mockPOF, "abc" ) ).toBe( INVALID_NUMERIC );
} );
+
+ test.each( [
+ [undefined],
+ [""],
+ ] )( "should return null for empty value %p on an optional numeric field", value => {
+ const mockPOF = factory( "LocalProjectObservationField", {
+ required: false,
+ obsField: factory( "LocalObservationField", {
+ allowedValues: [],
+ datatype: "numeric",
+ } ),
+ } );
+ expect( validateProjectFieldValue( mockPOF, value ) ).toBeNull( );
+ } );
} );
} );
From e9b20cc5b543002df72b18fb863927824996d012 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Sat, 11 Jul 2026 23:24:22 +0200
Subject: [PATCH 005/108] optional text fields should return null
---
.../validateProjectFieldsForObservation.test.js | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 14092317f..41864e97e 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -31,4 +31,20 @@ describe( "validateProjectFieldValue", () => {
expect( validateProjectFieldValue( mockPOF, value ) ).toBeNull( );
} );
} );
+
+ describe( "optional text fields", () => {
+ test.each( [
+ [undefined],
+ [""],
+ ["anything"],
+ ] )( "should return null for %p", value => {
+ const mockPOF = factory( "LocalProjectObservationField", {
+ required: false,
+ obsField: factory( "LocalObservationField", {
+ allowedValues: [],
+ } ),
+ } );
+ expect( validateProjectFieldValue( mockPOF, value ) ).toBeNull( );
+ } );
+ } );
} );
From 8f83ec18a36ae118c50bede79b8a0cf64925e9d6 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Sat, 11 Jul 2026 23:28:47 +0200
Subject: [PATCH 006/108] required fields should return null for a value that
is non-empty after trim
---
.../validateProjectFieldsForObservation.test.js | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 41864e97e..0fa664dc8 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -5,6 +5,18 @@ import {
import factory from "tests/factory";
describe( "validateProjectFieldValue", () => {
+ describe( "required fields", () => {
+ it( "should return null for a value that is non-empty after trim", () => {
+ const mockPOF = factory( "LocalProjectObservationField", {
+ required: true,
+ obsField: factory( "LocalObservationField", {
+ allowedValues: [],
+ } ),
+ } );
+ expect( validateProjectFieldValue( mockPOF, " x " ) ).toBeNull( );
+ } );
+ } );
+
describe( "numeric fields", () => {
it( "should return INVALID_NUMERIC for a non-numeric value on an optional field", () => {
const mockPOF = factory( "LocalProjectObservationField", {
From 5d82826e8cd9f45dab3a3677aa694789e98faaf1 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Sat, 11 Jul 2026 23:30:57 +0200
Subject: [PATCH 007/108] required fields should return null for a non-empty
value
---
.../validateProjectFieldsForObservation.test.js | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 0fa664dc8..9f2da69ba 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -6,6 +6,16 @@ import factory from "tests/factory";
describe( "validateProjectFieldValue", () => {
describe( "required fields", () => {
+ it( "should return null for a non-empty value", () => {
+ const mockPOF = factory( "LocalProjectObservationField", {
+ required: true,
+ obsField: factory( "LocalObservationField", {
+ allowedValues: [],
+ } ),
+ } );
+ expect( validateProjectFieldValue( mockPOF, "x" ) ).toBeNull( );
+ } );
+
it( "should return null for a value that is non-empty after trim", () => {
const mockPOF = factory( "LocalProjectObservationField", {
required: true,
From 39f82b6d205b941f603e6aad411406e80cb9994d Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Sat, 11 Jul 2026 23:32:05 +0200
Subject: [PATCH 008/108] required fields should return MISSING_REQUIRED for
empty value %p
---
.../validateProjectFieldsForObservation.test.js | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 9f2da69ba..339f60a59 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -1,11 +1,27 @@
import {
INVALID_NUMERIC,
+ MISSING_REQUIRED,
validateProjectFieldValue,
} from "sharedHelpers/validateProjectFieldsForObservation";
import factory from "tests/factory";
describe( "validateProjectFieldValue", () => {
describe( "required fields", () => {
+ test.each( [
+ [undefined],
+ [null],
+ [""],
+ [" "],
+ ] )( "should return MISSING_REQUIRED for empty value %p", value => {
+ const mockPOF = factory( "LocalProjectObservationField", {
+ required: true,
+ obsField: factory( "LocalObservationField", {
+ allowedValues: [],
+ } ),
+ } );
+ expect( validateProjectFieldValue( mockPOF, value ) ).toBe( MISSING_REQUIRED );
+ } );
+
it( "should return null for a non-empty value", () => {
const mockPOF = factory( "LocalProjectObservationField", {
required: true,
From 619ba03d327b9d8a9f5164a38533017e3243c110 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Sun, 12 Jul 2026 22:46:05 +0200
Subject: [PATCH 009/108] Widen acceptance to observation with OFVs undefined
(as could be in zustand)
---
src/realmModels/ObservationFieldValue.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/realmModels/ObservationFieldValue.ts b/src/realmModels/ObservationFieldValue.ts
index 155a9afc2..880915359 100644
--- a/src/realmModels/ObservationFieldValue.ts
+++ b/src/realmModels/ObservationFieldValue.ts
@@ -35,7 +35,7 @@ class ObservationFieldValue extends Realm.Object {
observation: RealmObservation,
obsFieldId: number,
): RealmObservationFieldValue | undefined {
- return observation.observationFieldValues.find(
+ return observation.observationFieldValues?.find(
ofv => ofv.obsFieldId === obsFieldId,
);
}
From 27d752832c091c4d8af636f176e8e783279a5a44 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Mon, 13 Jul 2026 11:42:54 +0200
Subject: [PATCH 010/108] Simpler typing, only concerned with local access
---
.../validateProjectFieldsForObservation.ts | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/sharedHelpers/validateProjectFieldsForObservation.ts b/src/sharedHelpers/validateProjectFieldsForObservation.ts
index f77cd0e47..842cad18b 100644
--- a/src/sharedHelpers/validateProjectFieldsForObservation.ts
+++ b/src/sharedHelpers/validateProjectFieldsForObservation.ts
@@ -17,6 +17,15 @@ export type ProjectObservationFieldLike = Pick<
"required" | "obsField"
>;
+interface ObservationFieldToValidate {
+ datatype: string;
+}
+
+interface ProjectObservationFieldToValidate {
+ required: boolean;
+ obsField: ObservationFieldToValidate;
+}
+
/**
* Validates a single value param against a POF.
*
@@ -28,7 +37,7 @@ export type ProjectObservationFieldLike = Pick<
* Returns null when valid.
*/
export function validateProjectFieldValue(
- pof: ProjectObservationFieldLike,
+ pof: ProjectObservationFieldToValidate,
value?: string | null,
): ProjectFieldValidationReason | null {
const trimmed = ( value ?? "" ).trim( );
From 3efa77e281aa2893ee354fe7f70a69b04ef26709 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Mon, 13 Jul 2026 11:50:13 +0200
Subject: [PATCH 011/108] Fct to iterate and validate all OFV of an observation
---
.../validateProjectFieldsForObservation.ts | 72 ++++++++++++++++---
1 file changed, 64 insertions(+), 8 deletions(-)
diff --git a/src/sharedHelpers/validateProjectFieldsForObservation.ts b/src/sharedHelpers/validateProjectFieldsForObservation.ts
index 842cad18b..98129811a 100644
--- a/src/sharedHelpers/validateProjectFieldsForObservation.ts
+++ b/src/sharedHelpers/validateProjectFieldsForObservation.ts
@@ -1,6 +1,4 @@
-import type {
- RealmProjectObservationField,
-} from "realmModels/types";
+import ObservationFieldValue from "realmModels/ObservationFieldValue";
// Machine-readable reason codes. UI layers map these to localized
// strings; membership-rule validation uses a separate module with its
@@ -12,18 +10,28 @@ export type ProjectFieldValidationReason =
| typeof MISSING_REQUIRED
| typeof INVALID_NUMERIC;
-export type ProjectObservationFieldLike = Pick<
- RealmProjectObservationField,
- "required" | "obsField"
->;
+export interface ProjectFieldValidationError {
+ projectId: number;
+ projectTitle: string;
+ obsFieldId: number;
+ fieldName: string;
+ reason: ProjectFieldValidationReason;
+}
+
+export interface ProjectFieldValidationResult {
+ valid: boolean;
+ errors: ProjectFieldValidationError[];
+}
interface ObservationFieldToValidate {
datatype: string;
+ id: number;
+ name: string;
}
interface ProjectObservationFieldToValidate {
- required: boolean;
obsField: ObservationFieldToValidate;
+ required: boolean;
}
/**
@@ -55,3 +63,51 @@ export function validateProjectFieldValue(
}
return null;
}
+
+interface ObservationFieldValueToValidate {
+ obsFieldId: number;
+ value: string;
+}
+
+interface ObservationToValidate {
+ observationFieldValues?: ObservationFieldValueToValidate[];
+}
+
+interface ProjectToValidate {
+ id: number;
+ projectObservationFields: ProjectObservationFieldToValidate[];
+ title: string;
+}
+
+/**
+ * Validates an observation's OFVs against the observation fields (POFs) of
+ * the given projects.
+ *
+ * OFVs are global per observation and keyed by obsFieldId, so two projects
+ * sharing a field read the same OFV; when a shared required field is empty,
+ * each project reports its own error.
+ */
+export default function validateProjectFieldsForObservation(
+ observation: ObservationToValidate,
+ projects: ProjectToValidate[],
+): ProjectFieldValidationResult {
+ const errors: ProjectFieldValidationError[] = [];
+ projects.forEach( project => {
+ project.projectObservationFields.forEach( pof => {
+ const { obsField } = pof;
+ if ( !obsField ) { return; }
+ const ofv = ObservationFieldValue.findForObsField( observation, obsField.id );
+ const reason = validateProjectFieldValue( pof, ofv?.value );
+ if ( reason ) {
+ errors.push( {
+ projectId: project.id,
+ projectTitle: project.title ?? "",
+ obsFieldId: obsField.id,
+ fieldName: obsField.name ?? "",
+ reason,
+ } );
+ }
+ } );
+ } );
+ return { valid: errors.length === 0, errors };
+}
From d6002f860ec2a257ee02c6275c12b48bb732c482 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Mon, 13 Jul 2026 12:25:13 +0200
Subject: [PATCH 012/108] validateProjectFieldsForObservation required fields
should be valid when OFV is non-empty after trim
---
...alidateProjectFieldsForObservation.test.js | 27 ++++++++++++++++++-
1 file changed, 26 insertions(+), 1 deletion(-)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 339f60a59..8cf2fe12c 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -1,4 +1,4 @@
-import {
+import validateProjectFieldsForObservation, {
INVALID_NUMERIC,
MISSING_REQUIRED,
validateProjectFieldValue,
@@ -86,3 +86,28 @@ describe( "validateProjectFieldValue", () => {
} );
} );
} );
+
+describe( "validateProjectFieldsForObservation", () => {
+ describe( "required fields", () => {
+ it( "should be valid when a required field's OFV is non-empty after trim", () => {
+ const mockProject = {
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ },
+ }],
+ };
+ const mockObservation = {
+ observationFieldValues: [{
+ obsFieldId: 10,
+ value: "something",
+ }],
+ };
+ expect(
+ validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
+ ).toBe( true );
+ } );
+ } );
+} );
From 09c844b2fee9c1e59648825b5747dacd64e51364 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Mon, 13 Jul 2026 12:28:39 +0200
Subject: [PATCH 013/108] should be valid when an optional field has no OFV
---
.../validateProjectFieldsForObservation.test.js | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 8cf2fe12c..6f7eaab15 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -109,5 +109,21 @@ describe( "validateProjectFieldsForObservation", () => {
validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
).toBe( true );
} );
+
+ it( "should be valid when an optional field has no OFV", () => {
+ const mockProject = {
+ projectObservationFields: [{
+ required: false,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ },
+ }],
+ };
+ const mockObservation = { observationFieldValues: [] };
+ expect(
+ validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
+ ).toBe( true );
+ } );
} );
} );
From 74d3d47a3dbd2abd372ac56e3fc1d9bd6893b28b Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Mon, 13 Jul 2026 12:31:14 +0200
Subject: [PATCH 014/108] should return MISSING_REQUIRED when a required
field's OFV value is empty
---
...alidateProjectFieldsForObservation.test.js | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 6f7eaab15..8fa448506 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -89,6 +89,27 @@ describe( "validateProjectFieldValue", () => {
describe( "validateProjectFieldsForObservation", () => {
describe( "required fields", () => {
+ test.each( [
+ [""],
+ [" "],
+ ] )( "should return MISSING_REQUIRED when a required field's OFV value is %p", value => {
+ const mockProject = {
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ },
+ }],
+ };
+ const mockObservation = {
+ observationFieldValues: [{ id: 10, value }],
+ };
+ const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
+ expect( result.valid ).toBe( false );
+ expect( result.errors[0].reason ).toBe( MISSING_REQUIRED );
+ } );
+
it( "should be valid when a required field's OFV is non-empty after trim", () => {
const mockProject = {
projectObservationFields: [{
From 9e7434e30f002e1fb04cf5b2192840f7735e4dcf Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Mon, 13 Jul 2026 12:34:57 +0200
Subject: [PATCH 015/108] should return MISSING_REQUIRED when a required field
has no OFV
---
...alidateProjectFieldsForObservation.test.js | 24 +++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 8fa448506..398fbf1c8 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -89,6 +89,30 @@ describe( "validateProjectFieldValue", () => {
describe( "validateProjectFieldsForObservation", () => {
describe( "required fields", () => {
+ it( "should return MISSING_REQUIRED when a required field has no OFV", () => {
+ const mockProject = {
+ title: "Mushrooms of Bavaria",
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ name: "Habitat",
+ },
+ }],
+ };
+ const mockObservation = { observationFieldValues: [] };
+ const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
+ expect( result.valid ).toBe( false );
+ expect( result.errors ).toEqual( [{
+ projectId: mockProject.id,
+ projectTitle: "Mushrooms of Bavaria",
+ obsFieldId: 10,
+ fieldName: "Habitat",
+ reason: MISSING_REQUIRED,
+ }] );
+ } );
+
test.each( [
[""],
[" "],
From f093f5773aac69fef5b7221297a524454a5818af Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Mon, 13 Jul 2026 12:40:58 +0200
Subject: [PATCH 016/108] should be valid when a required field has a non-empty
OFV
---
...validateProjectFieldsForObservation.test.js | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 398fbf1c8..f87c4fb8d 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -89,6 +89,24 @@ describe( "validateProjectFieldValue", () => {
describe( "validateProjectFieldsForObservation", () => {
describe( "required fields", () => {
+ it( "should be valid when a required field has a non-empty OFV", () => {
+ const mockProject = {
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ },
+ }],
+ };
+ const mockObservation = {
+ observationFieldValues: [{ obsFieldId: 10, value: "shrubland" }],
+ };
+ const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
+ expect( result.valid ).toBe( true );
+ expect( result.errors ).toEqual( [] );
+ } );
+
it( "should return MISSING_REQUIRED when a required field has no OFV", () => {
const mockProject = {
title: "Mushrooms of Bavaria",
From 27382c213f4da43d710cf664e0265b4d5509b3a1 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Mon, 13 Jul 2026 13:03:06 +0200
Subject: [PATCH 017/108] should report only the unfilled field when one of two
required fields is filled
---
...alidateProjectFieldsForObservation.test.js | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index f87c4fb8d..c8f59189c 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -188,5 +188,36 @@ describe( "validateProjectFieldsForObservation", () => {
validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
).toBe( true );
} );
+
+ it( "should report only the unfilled field when one of two required fields is filled", () => {
+ const mockProject = {
+ projectObservationFields: [
+ {
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ name: "Habitat",
+ },
+ },
+ {
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 20,
+ name: "Substrate",
+ },
+ },
+ ],
+ };
+ const mockObservation = {
+ observationFieldValues: [{ obsFieldId: 10, value: "shrubland" }],
+ };
+ const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
+ expect( result.valid ).toBe( false );
+ expect( result.errors ).toHaveLength( 1 );
+ expect( result.errors[0].fieldName ).toBe( "Substrate" );
+ expect( result.errors[0].reason ).toBe( MISSING_REQUIRED );
+ } );
} );
} );
From 2c7ab5d150bdfd27b86286d638054b99f7f68e2e Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Mon, 13 Jul 2026 13:04:11 +0200
Subject: [PATCH 018/108] should report both fields in POF order when two
required fields are unfilled
---
...alidateProjectFieldsForObservation.test.js | 27 +++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index c8f59189c..358f78865 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -219,5 +219,32 @@ describe( "validateProjectFieldsForObservation", () => {
expect( result.errors[0].fieldName ).toBe( "Substrate" );
expect( result.errors[0].reason ).toBe( MISSING_REQUIRED );
} );
+
+ it( "should report both fields in POF order when two required fields are unfilled", () => {
+ const mockProject = {
+ projectObservationFields: [
+ {
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ name: "Habitat",
+ },
+ },
+ {
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 20,
+ name: "Substrate",
+ },
+ },
+ ],
+ };
+ const mockObservation = { observationFieldValues: [] };
+ const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
+ expect( result.valid ).toBe( false );
+ expect( result.errors.map( e => e.fieldName ) ).toEqual( ["Habitat", "Substrate"] );
+ } );
} );
} );
From be5b2426a4a630695a011382490a40b99cc97524 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 14:50:29 +0200
Subject: [PATCH 019/108] numeric fields should be valid
---
...alidateProjectFieldsForObservation.test.js | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 358f78865..3c66ae17e 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -247,4 +247,30 @@ describe( "validateProjectFieldsForObservation", () => {
expect( result.errors.map( e => e.fieldName ) ).toEqual( ["Habitat", "Substrate"] );
} );
} );
+
+ describe( "numeric fields", () => {
+ test.each( [
+ ["12.5"],
+ ["42"],
+ ["-3.2"],
+ [" 7 "],
+ ] )( "should be valid when a required numeric field's OFV value is %p", value => {
+ const mockProject = {
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ datatype: "numeric",
+ id: 10,
+ },
+ }],
+ };
+ const mockObservation = {
+ observationFieldValues: [{ obsFieldId: 10, value }],
+ };
+ expect(
+ validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
+ ).toBe( true );
+ } );
+ } );
} );
From 3bf60d7888f4e4852fbe5567c3597769af68dcd1 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 14:51:18 +0200
Subject: [PATCH 020/108] should return INVALID_NUMERIC
---
docs/traditional-projects-build-plan.md | 1148 +++++++++
docs/traditional-projects-glossary.md | 225 ++
...ional-projects-porting-analysis_Android.md | 839 +++++++
...ditional-projects-porting-reference_iOS.md | 2163 +++++++++++++++++
...alidateProjectFieldsForObservation.test.js | 27 +
5 files changed, 4402 insertions(+)
create mode 100644 docs/traditional-projects-build-plan.md
create mode 100644 docs/traditional-projects-glossary.md
create mode 100644 docs/traditional-projects-porting-analysis_Android.md
create mode 100644 docs/traditional-projects-porting-reference_iOS.md
diff --git a/docs/traditional-projects-build-plan.md b/docs/traditional-projects-build-plan.md
new file mode 100644
index 000000000..4a97be17f
--- /dev/null
+++ b/docs/traditional-projects-build-plan.md
@@ -0,0 +1,1148 @@
+# Traditional Projects — Engineering Build Plan
+
+Phase 2 deliverable for the **Traditional Project Support POD**: exhaustive ticket breakdown for Linear, with implementation notes, Figma references, dependencies, and point estimates.
+
+**Linear project:** [Traditional Projects in App](https://linear.app/inaturalist/project/traditional-projects-in-app-85969e27f9f8) — all implementation tickets for this feature belong in this project (team: **Mobile**).
+
+**Abbreviations:** PO = project observation, OFV = observation field value, POF = project observation field. See [traditional-projects-glossary.md](traditional-projects-glossary.md).
+
+**Audits:** [iOS porting reference](traditional-projects-porting-reference_iOS.md) · [Android porting analysis](traditional-projects-porting-analysis_Android.md)
+
+**Designs:** [Figma — Add to Projects section](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-78787&m=dev)
+
+---
+
+## Summary
+
+| Phase | Points | ~Ideal days (4 pts/day) | Scope |
+|-------|--------|-------------------------|-------|
+| **Delivered / in-flight** | 74 | — | F0, A1–A4, E1, E8, B1, B2; B3, B4–B7 in progress |
+| **Phase A — Parity (remaining)** | 75 | 19 | A3c, B8, B3b, BUG, C1–C4, C3a, D1–D4, D3, E5a, E6a |
+| **Phase B — Beyond parity** | 77 | 19 | A3b, B9, C3b, E2, E3, E7, E5b, E6b, P2-2, P2-4 |
+| **Total (active)** | **226** | **57** | Excludes cancelled tickets |
+
+**Estimation:** 1 ideal engineer-day = **4 story points**. Use points for Linear sizing.
+
+**Shippable prototype:** Phase A completes classic iOS/Android parity for add-to-project, field form, save/upload, and server 422 surfacing. Basic join/leave and project detail already ship on `main` without the feature flag. Phase B starts after the parity prototype is tested.
+
+**Cancelled (traceability only, 0 pts):** P2-1 (merged into E2), P2-3 (merged into D2), P2-5 (out of scope per 2026-06-10 meeting), E4 (folded into E7 — detail subtitle only; list type already in `ProjectListItem`).
+
+---
+
+## Product meeting outcomes (2026-06-10)
+
+**Attendees:** Tony Iwane, Abhas Misraraj, Johannes Klein
+
+| Topic | Decision |
+|-------|----------|
+| **Join flow** | Bottom sheet with **3 radio options** (pattern: geo-privacy sheet). About, curators, and rules live on the **public traditional project detail page** — not a separate join screen. |
+| **Leave flow** | **3-option full sheet only** — drop simple variant (`29821-81792`). |
+| **Incremental release** | Ship **add-to-project first** (feature flag) for already-joined projects; join/leave + project detail enhancements can follow. Foundation/data before UI polish. |
+| **Incomplete chooser data** | Back/save with incomplete projects → **Missing info sheet**; LEAVE keeps **only completed** projects; **clear incomplete project state** (no partial data on ObsEdit). |
+| **Project rules validation** | Show **project rules at top of chooser** with required/checkmark UI (same pattern as observation fields). Validate **client-checkable rules** (photo, sound, location, captive/cultivated, etc.) before save — **primary strategy** to avoid post-upload 422 complexity. |
+| **ObsEdit re-edit** | When editing an obs **already in a traditional project**, ObsEdit must show project requirements/fields (not only via chooser from scratch). |
+| **Hidden coords at join** | **In scope** — part of join bottom sheet (absorbs former P2-1). |
+| **422 / D3** | **Phase A (parity):** surface server 422 at upload time (classic-app behavior). **Phase B (B9):** client-side rules reduce 422 rate. |
+| **P2-5 ObsDetails OFV** | **Out of scope** — not POD scope or classic parity (Tony/Abhas confirmed). |
+| **Default select seeding** | **Do not seed** — explicit user input required (reinforces existing plan). |
+| **Upload model** | Observation uploads first; **project_observation link is created last** and can 422 — cannot block obs upload server-side for project rules. |
+
+**Action items (not blocking implementation):**
+
+- ~~**Johannes:** Spike which project rules are client-checkable vs server-only~~ — **Done** (2026-06-10); findings in [B9 spike appendix](#b9-spike-appendix--project-rules-validation) below.
+- **Tony:** Curator/admin count data for layout.
+- ~~**Abhas:** Finalize join/leave bottom sheets, project detail sections, cursor states; annotate Figma~~ — **Done** (2026-06).
+
+---
+
+## Phasing rationale
+
+Work is split into **parity** (Phase A) and **beyond parity** (Phase B). Phase A ends in a **shippable prototype** that matches what the classic iOS and Android apps do. Phase B adds POD-mandated enhancements the classic apps lack and starts only after the prototype is tested.
+
+### Phase A — Classic parity (shippable prototype)
+
+Everything the classic apps support today:
+
+- Add observation to joined traditional projects (chooser, field form, all 7 field types)
+- Required-field validation before save (Android validates at picker confirm; iOS validator is dead code — RN wires C2/C3a)
+- Save PO/OFV to Realm; upload OFVs then POs; sync deletions (D2, incl. OFV clear → DELETE — iOS parity)
+- Server 422 surfacing on failed PO/OFV upload (D3 — classic apps store `validationErrorMsg` / SharedPreferences)
+- ObsDetails local project display for unsynced adds (C4)
+- Joined-projects sync triggers (A3c); post-join fetch (E8, **Done**)
+- Feature flag for beta rollout (E1, **Done**)
+
+**Already on `main` (no ticket):** basic join/leave via `ProjectDetailsContainer` (`joinProject` / `leaveProject`); project detail with type label, description, and requirements link.
+
+**Not in Phase A:** client-side project rules in chooser (B9), join/leave bottom sheets with location permissions (E2/E3), project detail re-layout (E7), upload-time pre-validation blocking (C3b), upload-time schema reconciliation (P2-2), offline join/leave queue (P2-4).
+
+### Phase B — Beyond parity (post-prototype)
+
+POD-mandated items classic apps lack:
+
+- Client-side project rules validation in chooser (B9) + rules metadata sync (A3b)
+- Pre-upload gate for membership rules (C3b)
+- Join flow with hidden-coordinate grant via bottom sheet (E2, formerly P2-1)
+- Leave flow with keep/remove observations and hidden-coordinate revoke (3-option sheet, E3)
+- Project detail About / Project Admins / inline Project Rules sections (E7)
+- Upload-time schema reconciliation (P2-2), offline join/leave queue (P2-4)
+
+### Incremental release strategy
+
+```mermaid
+flowchart LR
+ R1["Release 1: Foundation + chooser UI — Done/in-flight"] --> R2["Release 2: Parity prototype — Phase A"]
+ R2 --> R3["Release 3+: Beyond parity — Phase B"]
+```
+
+- **Release 1 (done/in-flight):** F0, A1–A4, E1, E8, B1, B2, B3–B7 — add-to-project UI for already-joined projects; feature flag off by default.
+- **Release 2 (Phase A):** B8, C1–C4, C3a, D1–D4, D3, E5a, E6a — save, required-field validation, upload pipeline, 422 surfacing; **shippable parity prototype**.
+- **Release 3+ (Phase B):** A3b, B9, C3b, E7, E2, E3, P2-2, P2-4, E5b, E6b — rules validation, enhanced join/leave/detail, resilience upgrades.
+
+---
+
+## Key engineering decisions
+
+| Area | Decision |
+|------|----------|
+| **Joined projects cache** | Standalone Realm `Project` model with embedded `ProjectObservationField` → `ObservationField` (mirrors iOS `ExploreProjectRealm`). |
+| **Per-observation data** | Embedded `ProjectObservation` + `ObservationFieldValue` on `Observation` (same pattern as `ObservationPhoto`). |
+| **In-flight edits** | Zustand observation POJO in `createObservationFlowSlice.ts`; persist on save via `saveLocalObservationForUpload`. |
+| **Upload** | Extend `observationUploader.ts`: after obs + media → OFVs → POs (PO link last; can 422 independently). Deletes: PO before OFV. |
+| **Membership rules vs preferences** | Only `project_observation_rules` cause rule 422s on traditional `POST /v1/project_observations`. `rule_preferences` / `search_parameters` are ES/display for traditional — show in UI, **do not SAVE-gate on prefs alone**. |
+| **Project rules** | **Phase B (B9):** validate `project_observation_rules` in chooser before save/upload. **Phase A (D3):** surface server 422 when rules fail at upload time (classic-app parity). |
+| **Join/leave UI** | **Phase A:** basic join/leave on project detail (`main`). **Phase B (E2/E3):** bottom sheet with 3 radio options (geo-privacy pattern). |
+| **Incremental release** | Phase A = shippable parity prototype; Phase B = enhancements after prototype testing. |
+| **Field semantics** | Traditional = `project_type` not collection/umbrella; select = **text** + >1 `allowed_values` (`dna` = free text in RN); all values strings; **do not** seed required fields with first allowed value (iOS bug). |
+| **OFV semantics** | Global on observation; keyed by `obsFieldId` in Realm (no `projectId` on OFV); iOS `valueForObsField:` parity; upload body has no `project_id`. Schema **v70** introduced by A4. |
+| **UI** | Chooser = full stack screen; reuse `DropdownItem`, `RadioButtonSheet`, `DateTimePicker`, `TaxonSearch`. |
+
+---
+
+## Figma design reference map
+
+**Status: Final (2026-06).** Section: [Add to Projects](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-78787&m=dev) (`29821:78787`).
+
+| Frame | Node | Tickets |
+|-------|------|---------|
+| [Obs Edit — No Projects Added](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80570&m=dev) | `29821:80570` | B1 |
+| [Obs Edit — Projects Added](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80653&m=dev) | `29821:80653` | B1 |
+| [Logged out state](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29967-46496&m=dev) | `29967:46496` | B1 |
+| [No Projects Selected](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80612&m=dev) | `29821:80612` | B2 |
+| [Add to Projects — No Projects](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80722&m=dev) | `29821:80722` | B2 |
+| [Project Selected — No Requirements Met](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29876-27135&m=dev) | `29876:27135` | B3, B9, C3 |
+| [Project Selected — Some Requirements Met](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29876-27108&m=dev) | `29876:27108` | B3, B9, C3 |
+| [Project Selected — All Requirements Met](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29876-27162&m=dev) | `29876:27162` | B3 |
+| [Cursor State](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-20243&m=dev) | `30019:20243` | B3, B4 |
+| [Cursor in Progress](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-20915&m=dev) | `30019:20915` | B3, B4 |
+| [Project Rules & Obs Fields — None Met](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30002-15858&m=dev) | `30002:15858` | B4–B7, B9 (catalog) |
+| [Project Rules and Obs Fields — All Met](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30021-58210&m=dev) | `30021:58210` | B4–B7, B9 (catalog) |
+| [Text String Input](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30026-58576&m=dev) | `30026:58576` | B4 |
+| [Number Input](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30026-59552&m=dev) | `30026:59552` | B4 |
+| [Date Input](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30028-13392&m=dev) | `30028:13392` | B6 |
+| [Time Input](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30028-13467&m=dev) | `30028:13467` | B6 |
+| [Date & Time Input](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30028-13542&m=dev) | `30028:13542` | B6 |
+| [Value Input (select)](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30060-87134&m=dev) | `30060:87134` | B5 |
+| [Species Search](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80733&m=dev) | `29821:80733` | B7 |
+| [Missing Info Bottom Sheet](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80736&m=dev) | `29821:80736` | C3 |
+| [Join + Location Permissions](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-21511&m=dev) | `30019:21511` | E2 |
+| [Edit Location Permissions](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-58169&m=dev) | `30019:58169` | E2, E7 |
+| [Leave Project](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-81668&m=dev) | `29821:81668` | E3 |
+| [Traditional Project — Not Joined](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-21238&m=dev) | `30019:21238` | E7 (incl. former E4 subtitle) |
+| [Traditional Project — Joined](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-21546&m=dev) | `30019:21546` | E7 |
+
+**Partial coverage:** E4 Projects tab browse list — no dedicated list-row frame; infer type label from project detail subtitle (`Traditional Project` on `30019:21238`) and existing `ProjectListItem` + `displayProjectType.ts`.
+
+---
+
+## Open product questions
+
+1. **Remove all observations on leave** — What API removes a user's existing project observations? Web spike required (part of E3).
+
+---
+
+# Delivered and in-flight
+
+Tickets below are **Done** or **In Progress** — not re-estimated in Phase A/B totals.
+
+| ID | Title | Pts | Status | Linear |
+|----|-------|-----|--------|--------|
+| F0 | Engineering glossary | 2 | Done | MOB-1490 |
+| A1 | API wrappers + types | 4 | Done | MOB-1491 |
+| A2 | Realm models + migration | 12 | Done | MOB-1492 |
+| A3 | Joined-projects sync (PoC) | 4 | Done | MOB-1496 |
+| A4 | Download mapping | 8 | Done | MOB-1497 |
+| E1 | Feature flag | 2 | Done | MOB-1493 |
+| E8 | Post-join offline sync | 2 | Done | MOB-1524 |
+| B1 | ObsEdit Projects row | 4 | Done | MOB-1501 |
+| B2 | Project chooser screen (UI shell) | 12 | Done | MOB-1502 |
+| B3 | Per-project field form | 8 | In Progress | MOB-1503 |
+| B4–B7 | Field input components (7 types) | 16 | In Progress | MOB-1504 |
+
+**Parity baseline already on `main` (no ticket):** `ProjectDetailsContainer` join/leave; project detail type label, description, requirements link.
+
+---
+
+# Phase A — Classic parity (shippable prototype)
+
+Remaining tickets to reach classic iOS/Android parity. When complete, ship behind `TraditionalProjectsEnabled` for testing.
+
+## Workstream F — Documentation (2 pts) — Done
+
+### F0 — Engineering glossary
+
+| | |
+|---|---|
+| **Points** | 2 |
+| **Status** | **Done** — MOB-1490 |
+| **Linear labels** | `docs`, `traditional-projects`, `parity` |
+
+**Description:** Create and maintain [traditional-projects-glossary.md](traditional-projects-glossary.md) for shared vocabulary across implementers.
+
+**Acceptance criteria:**
+
+- All required terms documented with API key, RN/Realm name, classic-app equivalent, common confusion
+- "How the pieces fit together" flow diagram included
+- Cross-links to audit docs and build-plan tickets
+
+---
+
+## Workstream A — Data foundation (parity remaining: 3 pts)
+
+### A1 — API wrappers and TypeScript types — Done
+
+| | |
+|---|---|
+| **Points** | 4 |
+| **Status** | **Done** — MOB-1491 |
+
+*(Acceptance criteria unchanged — see git history or MOB-1491.)*
+
+---
+
+### A2 — Realm models and schema migration — Done
+
+| | |
+|---|---|
+| **Points** | 12 |
+| **Status** | **Done** — MOB-1492 |
+
+*(Acceptance criteria unchanged — see git history or MOB-1492.)*
+
+---
+
+### A3 — Joined-projects sync to Realm (PoC) — Done
+
+| | |
+|---|---|
+| **Points** | 4 |
+| **Status** | **Done** — MOB-1496, PR #3767 (2026-06-25) |
+
+*(Acceptance criteria unchanged — see MOB-1496.)*
+
+---
+
+### A3c — Joined-projects sync triggers and pagination
+
+| | |
+|---|---|
+| **Points** | 3 |
+| **Phase** | A (parity) |
+| **Dependencies** | A3 |
+| **Linear** | MOB-1535 (triggers, **Done**); MOB-1568 (pagination + chooser online guard) |
+| **Linear labels** | `sync`, `offline`, `traditional-projects`, `parity` |
+
+**Description:** Dedicated sync triggers and full pagination so joined projects are cached without requiring the user to visit Projects UI first. Classic apps re-fetch joined projects when opening the chooser.
+
+**MOB-1535 (Done):** `syncJoinedProjects` helper, deferred startup trigger, chooser mount trigger, empty-list prune, deferred startup user guard.
+
+**MOB-1568:** Full pagination, chooser online guard, error swallowing, pagination-aware prune rules.
+
+**Acceptance criteria:**
+
+- Paginate `fetchUserProjects({ per_page: 100, page, fields })` until all pages fetched
+- Additional triggers: deferred startup task (`useDeferredStartup`), chooser screen mount (if online), callable from E8 post-join
+- Optional `useJoinedProjects` Realm query hook: **not needed** — B2 (`AddToProjects`) reads joined traditional projects directly via `RealmContext.useQuery` on `Project`
+
+**Related:** E8 (Done), B2
+
+---
+
+### A4 — Download mapping for remote observations — Done
+
+| | |
+|---|---|
+| **Points** | 8 |
+| **Status** | **Done** — MOB-1497 |
+
+*(Acceptance criteria unchanged — see MOB-1497.)*
+
+---
+
+## Workstream B — ObsEdit add-to-project UI (parity remaining: 12 pts + BUG)
+
+### B1 — Projects row in ObsEdit — Done
+
+| | |
+|---|---|
+| **Points** | 4 |
+| **Status** | **Done** — MOB-1501 |
+
+*(Acceptance criteria unchanged — see MOB-1501.)*
+
+---
+
+### B2 — Project chooser screen — Done (UI shell)
+
+| | |
+|---|---|
+| **Points** | 12 |
+| **Status** | **Done** — MOB-1502 (B2a UI shell) |
+| **Note** | B2b chooser persistence ships in **B8** (Phase A). |
+
+*(B2a acceptance criteria unchanged — see MOB-1502.)*
+
+---
+
+### B3 — Per-project observation field form — In Progress
+
+| | |
+|---|---|
+| **Points** | 8 |
+| **Status** | **In Progress** — MOB-1503 |
+| **Linear labels** | `ui`, `traditional-projects`, `parity` |
+
+*(Acceptance criteria unchanged — see MOB-1503.)*
+
+---
+
+### B3b — Per-project field form polish
+
+| | |
+|---|---|
+| **Points** | 4 |
+| **Phase** | A (parity) |
+| **Dependencies** | B3 |
+| **Linear** | MOB-1550 |
+| **Linear labels** | `ui`, `traditional-projects`, `parity` |
+
+**Description:** Figma polish for the expandable field form shell delivered in MOB-1503.
+
+**Acceptance criteria:**
+
+- Expand/collapse animation
+- Fields sorted by `position`
+- Row selection icon driven by validation state (filled checkmark when project has no required fields; global pass/fail per project for submit)
+- Text/number fields: inline cursor at placeholder position (`30019:20243`); cursor moves while typing (`30019:20915`); dismiss by tapping another field or outside keyboard
+- Supports arbitrary field count (virtualized list / nested FlashList)
+- Background color per Figma (`#f1f7e5` / grey — match design tokens)
+- Project rules rows not tappable (evaluative only — B9 in Phase B)
+
+---
+
+### B4 — Field inputs: text and numeric — In Progress (MOB-1504)
+
+### B5 — Field inputs: select — In Progress (MOB-1504)
+
+### B6 — Field inputs: date, time, datetime — In Progress (MOB-1504)
+
+### B7 — Field inputs: taxon — In Progress (MOB-1504)
+
+*(Full acceptance criteria in MOB-1504 — see original B4–B7 sections in git history.)*
+
+---
+
+### B8 — Zustand observation flow state for projects
+
+| | |
+|---|---|
+| **Points** | 6 |
+| **Phase** | A (parity) |
+| **Dependencies** | A4 |
+| **Linear** | MOB-1498 |
+| **Linear labels** | `state`, `traditional-projects`, `parity` |
+
+**Description:** Extend observation POJO in `createObservationFlowSlice` with project selections and OFV map. Includes **B2b chooser persistence**.
+
+**Acceptance criteria:**
+
+- `updateObservationKeys` accepts `projectObservations` and `observationFieldValues` (flat array on observation POJO, not a per-project map)
+- Chooser SAVE merges into current observation in `observations[]`
+- Toggle OFF stages **PO** removal only: track PO uuids to delete at save (synced) vs drop (never synced); do **not** delete/clear OFV when a project is toggled off
+- Survives rotation via existing observation flow patterns
+- Load initial state from Realm when editing existing obs
+- When ObsEdit opens for synced/local obs with existing PO/OFV (A4), hydrate project selections and field values into Zustand so chooser and ObsEdit row show current state
+- **B2b:** Sticky SAVE commits to Zustand and pops navigator; draft selection hydrated from existing POs on open; SAVE disabled when selection unchanged
+
+---
+
+### BUG — DateTimePicker datetime date not stored
+
+| | |
+|---|---|
+| **Points** | 2 |
+| **Phase** | A (parity) |
+| **Linear** | MOB-1551 |
+| **Linear labels** | `bug`, `traditional-projects`, `parity` |
+
+**Description:** Fix pre-existing bug in `DateTimePicker.tsx` datetime two-step mode (blocks B6 datetime fields).
+
+**Acceptance criteria:**
+
+- Fix one-line state update in DateTimePicker
+- Add regression unit test
+
+---
+
+## Workstream C — Save and validation (parity: 21 pts)
+
+### C1 — Save path for POs and OFVs
+
+| | |
+|---|---|
+| **Points** | 8 |
+| **Phase** | A (parity) |
+| **Dependencies** | A4, B8 |
+| **Linear** | MOB-1508 |
+| **Linear labels** | `realm`, `obs-edit`, `traditional-projects`, `parity` |
+
+**Description:** Persist project observations and field values when saving observation locally.
+
+**Acceptance criteria:**
+
+- `Observation.saveLocalObservationForUpload` writes embedded PO/OFV with `_updated_at`, `_synced_at` null for new/changed
+- New PO/OFV get client UUIDs; new OFVs have **no `projectId`** (global per `obsFieldId`)
+- Removed synced PO/OFV create tombstone / `_pending_deletion` flag for upload pipeline
+- `needs_sync` on parent observation set true when project data changes
+- Re-edit path: when user saves ObsEdit for obs with existing PO/OFV, only changed projects/fields marked dirty
+
+---
+
+### C2 — Validation module (required fields)
+
+| | |
+|---|---|
+| **Points** | 4 |
+| **Phase** | A (parity) |
+| **Dependencies** | A4 |
+| **Linear** | MOB-1499 |
+| **Linear labels** | `validation`, `traditional-projects`, `parity` |
+
+**Description:** Pure functions to validate **POF/OFV required fields** before save/upload. Membership rules are **B9 (Phase B)** — separate module, separate error strings.
+
+**Acceptance criteria:**
+
+- `validateProjectFieldsForObservation(obs, projects)`: returns `{ valid, errors: [{ projectTitle, fieldName, reason }] }`
+- Required: non-empty string after trim
+- Numeric: must parse as float when non-empty
+- 2+ required POFs: all must have OFVs (matches `"Missing required observation field: {name}"` server message)
+- Multi-project: two projects sharing a field read the **same** OFV; required checks use `findForObsField` per POF
+- Unit tests in `tests/unit/` covering required, numeric, multi-project cases
+
+---
+
+### C3a — Wire required-field validation gates (parity)
+
+| | |
+|---|---|
+| **Points** | 5 |
+| **Phase** | A (parity) |
+| **Dependencies** | C2, B3 |
+| **Linear** | MOB-1509 |
+| **Figma** | [`29821-80736`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80736&m=dev) |
+| **Linear labels** | `validation`, `ui`, `traditional-projects`, `parity` |
+
+**Description:** Block chooser SAVE when required project fields fail; show Missing info sheet on back. Classic Android validates at picker confirm; iOS has dead-code validator — RN wires C2 here. **Does not include B9 membership rules gate** (see C3b, Phase B).
+
+**Acceptance criteria:**
+
+- Chooser SAVE: run field validation (C2); block pop if invalid; inline pass/fail per field row (B3)
+- Chooser back without SAVE: if invalid/incomplete selections, show "Missing info" sheet — LEAVE / KEEP EDITING
+- LEAVE on Missing info sheet: **only completed projects** persist to Zustand/ObsEdit; **clear incomplete project selections and partial field values**
+- ObsEdit Upload button: run **C2 required-field validation** before `addToUploadQueue` (like `missingBasics`)
+- Offline: validation runs locally without network
+
+**Implementation notes:**
+
+- `BottomButtonsContainer.tsx` insertion point alongside `passesEvidenceTest` / `hasIdentification`.
+- Membership rules: classic apps upload and let server 422 — handled by D3 in Phase A.
+
+---
+
+### C4 — ObsDetails local project display
+
+| | |
+|---|---|
+| **Points** | 4 |
+| **Phase** | A (parity) |
+| **Dependencies** | A2 |
+| **Linear** | MOB-1500 |
+| **Linear labels** | `obs-details`, `traditional-projects`, `parity` |
+
+**Description:** Show pending/unsynced project memberships from Realm on observation detail for local observations.
+
+**Acceptance criteria:**
+
+- `ProjectSection` / `ProjectButton` read from Realm observation when local/unsynced, not only remote API
+- Indicate count including not-yet-uploaded traditional project adds
+- Navigate to project list with local data
+
+---
+
+## Workstream D — Upload pipeline (parity: 32 pts)
+
+### D1 — Upload OFVs and POs after observation
+
+| | |
+|---|---|
+| **Points** | 12 |
+| **Phase** | A (parity) |
+| **Dependencies** | A1, C1 |
+| **Linear** | MOB-1510 |
+| **Linear labels** | `upload`, `traditional-projects`, `parity` |
+
+**Description:** Extend observation uploader to sync project child records.
+
+**Acceptance criteria:**
+
+- After `attachMediaToObservation` in `observationUploader.ts`: upload dirty OFVs, then dirty POs
+- OFV: POST or PUT per `wasSynced()`; nested body shape (`observation_field_id` only — no `project_id`)
+- PO: POST flat body; requires server `observation.id`
+- `markRecordUploaded` in `realmSync.ts` handles `ProjectObservation` and `ObservationFieldValue`
+- `countTotalIncrements` in upload slice includes new child ops for progress UI
+- Skip children when parent obs has no server id yet (retry next upload)
+
+---
+
+### D2 — Deletion sync for POs and OFVs
+
+| | |
+|---|---|
+| **Points** | 8 |
+| **Phase** | A (parity) |
+| **Dependencies** | D1 |
+| **Linear** | MOB-1511 |
+| **Linear labels** | `upload`, `traditional-projects`, `parity` |
+
+**Description:** Sync removals of project links and field values to server before uploads. Absorbs former **P2-3** (OFV clear → DELETE is iOS parity).
+
+**Acceptance criteria:**
+
+- Process tombstoned/deleted PO before OFV (iOS `deletedRecordsNeedingSync` order)
+- `DELETE /v1/project_observations/{id}` and `DELETE /v1/observation_field_values/{id}`
+- OFV DELETE when user **clears** a synced field value (or explicit remove), not when removing a PO — **P2-3 scope**
+- 404/403 treated as success (iOS audit §5.3)
+- Local Realm records removed after successful delete sync
+
+---
+
+### D3 — Server 422 surfacing (parity)
+
+| | |
+|---|---|
+| **Points** | 4 |
+| **Phase** | A (parity) |
+| **Dependencies** | D1 |
+| **Linear** | MOB-1512 |
+| **Linear labels** | `upload`, `errors`, `traditional-projects`, `parity` |
+
+**Description:** Surface project validation failures from server during upload. Classic iOS stores `validationErrorMsg`; Android uses SharedPreferences per obs+project. **Phase A primary defense** until B9 ships in Phase B.
+
+**Acceptance criteria:**
+
+- On 422 from PO or OFV upload: parse `errors[]`, set per-obs message on observation (e.g. `validationErrorMsg` field or upload slice)
+- Message includes project title when PO fails: "Couldn't be added to project {title}. {error}"
+- No dedicated MyObs error UI unless product requires — minimal surfacing acceptable
+- Failed PO link does not block observation upload retry; manual retry after user fixes fields
+- Clear validation message on re-save / re-upload attempt
+
+**Implementation notes:**
+
+- Covers all membership rule failures at upload time (classic behavior). B9 (Phase B) adds client-side pre-check to reduce 422 rate.
+
+---
+
+### D4 — Multi-obs and edge-case QA
+
+| | |
+|---|---|
+| **Points** | 8 |
+| **Phase** | A (parity) |
+| **Dependencies** | D1, D2 |
+| **Linear** | MOB-1513 |
+| **Linear labels** | `qa`, `traditional-projects`, `parity` |
+
+**Description:** Harden upload/edit flows for synced observations, multi-obs carousel, and id remapping.
+
+**Acceptance criteria:**
+
+- Edit synced observation: add/remove projects, upload deltas only
+- Multi-observation flow: each obs carries independent project state
+- Local observation id → server id remaps PO/OFV `observation_id` references (Android `ObservationProvider` pattern)
+- Manual test checklist documented in ticket/PR
+
+---
+
+## Workstream E — Release (parity: 7 pts)
+
+### E1 — Feature flag — Done
+
+| | |
+|---|---|
+| **Points** | 2 |
+| **Status** | **Done** — MOB-1493 |
+
+---
+
+### E5a — i18n strings (parity)
+
+| | |
+|---|---|
+| **Points** | 1 |
+| **Phase** | A (parity) |
+| **Linear** | MOB-1495 |
+| **Linear labels** | `i18n`, `traditional-projects`, `parity` |
+
+**Description:** Add parity-scope user-facing strings to `src/i18n/strings.ftl`.
+
+**Acceptance criteria:**
+
+- Chooser labels, field placeholders, validation errors
+- Missing info sheet body (LEAVE / KEEP EDITING)
+- Logged-out alert on ObsEdit (`29967:46496`)
+- Run i18n CLI to regenerate locale JSON
+
+---
+
+### E6a — Integration tests (parity prototype)
+
+| | |
+|---|---|
+| **Points** | 6 |
+| **Phase** | A (parity) |
+| **Dependencies** | D1, C3a |
+| **Linear** | MOB-1516 |
+| **Linear labels** | `tests`, `traditional-projects`, `parity` |
+
+**Description:** End-to-end tests for **parity** traditional project flows — sufficient to sign off the shippable prototype.
+
+**Acceptance criteria:**
+
+- Factoria factories: `ProjectWithFields`, `ObservationWithProjectFields`
+- Integration test: open ObsEdit → chooser → toggle project → fill required field → save → verify Realm
+- Integration test: C2 validation blocks upload when required field empty
+- Integration test: offline save persists PO/OFV locally
+- Integration test: D3 surfaces 422 message when PO upload fails rules
+- Tests in `tests/integration/` following `renderApp` patterns
+
+---
+
+# Phase B — Beyond parity (post-prototype)
+
+Starts after Phase A parity prototype is tested. All tickets below are enhancements the classic apps lack.
+
+## Workstream A — Rules metadata (5 pts)
+
+### A3b — Joined-projects rules metadata sync
+
+| | |
+|---|---|
+| **Points** | 5 |
+| **Phase** | B (beyond) |
+| **Dependencies** | A3 |
+| **Linear** | MOB-1534 |
+| **Linear labels** | `sync`, `offline`, `realm`, `traditional-projects`, `beyond-parity` |
+
+**Description:** Extend A3 PoC to persist membership rules metadata for offline B9 validation and E7 project detail sections.
+
+**Acceptance criteria:**
+
+- Extend Realm `Project` schema (likely **v71**, after A4 v70): `project_observation_rules[]`, `rule_preferences[]`, `search_parameters[]`, bioblitz `start_time`/`end_time`
+- Fetch with `rule_details: true` + expanded field set (match `ProjectRequirements.tsx` pattern)
+- Map and persist rule operands; cache `taxon.ancestor_ids` / list taxon IDs when API provides them; defer to D3 fallback when omitted
+- Extend `Project.mapApiToRealm` / upsert path — reuse `Project.upsertRemoteProjects`
+
+**Blocks:** B9, E7 (offline rules sections)
+
+---
+
+## Workstream B — Client-side project rules (10 pts)
+
+### B9 — Client-side project rules in chooser
+
+| | |
+|---|---|
+| **Points** | 10 |
+| **Phase** | B (beyond) |
+| **Dependencies** | B3, C2, A3b |
+| **Linear** | MOB-1522 |
+| **Figma** | [`29876-27135`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29876-27135&m=dev), [`30002-15858`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30002-15858&m=dev), [`30021-58210`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30021-58210&m=dev) |
+| **Linear labels** | `validation`, `ui`, `traditional-projects`, `beyond-parity` |
+
+**Description:** Display membership rules and preferences at top of each toggled project's field form; validate `project_observation_rules` before SAVE (not `rule_preferences` alone). Classic apps do not validate rules client-side.
+
+**Acceptance criteria:**
+
+- **Two UI sections** per expanded project (rules first, obs fields second):
+ 1. **Membership rules** — from `project_observation_rules` (pass/fail indicators; evaluative from obs state; **not tappable**; gates SAVE)
+ 2. **Project preferences** — from `rule_preferences` (informational; reuse `ProjectRequirements.tsx` wording; **no SAVE block** on prefs alone)
+ 3. **Obs fields** — below rules; pass/fail depends on user input; tappable rows (B4–B7)
+- Implement `validateProjectRules(obs, project)` pure function with OR-within-operator / AND-across-operator semantics (see [appendix](#b9-spike-appendix--project-rules-validation))
+- **P0 validators:** `identified?`, `georeferenced?`, `has_a_photo?`, `has_a_sound?`, `has_media?`, `in_taxon?`, `not_in_taxon?`, `verifiable?`
+- **P1 validators:** `wild?`, `captive?`, `observed_after?`, `observed_before?`, bioblitz window; non-rule: duplicate PO, collection/umbrella block, observer privacy
+- **P2 (optional):** `on_list?` if list taxa cached; `rule_preferences` date/month as UI warning only (not hard block)
+- **Defer to D3:** `observed_in_place?`, `coordinates_shareable_by_project_curators?`, establishment prefs, `members_only` (badge only)
+- Inline warnings when rules fail; block chooser SAVE when P0/P1 membership rules fail
+- Unit tests: 5 spike test cases + OR/AND combination cases in `tests/unit/`
+
+---
+
+## Workstream C — Rules validation wiring (3 pts)
+
+### C3b — Wire project-rules validation gates (beyond)
+
+| | |
+|---|---|
+| **Points** | 3 |
+| **Phase** | B (beyond) |
+| **Dependencies** | C3a, B9 |
+| **Linear** | MOB-1561 |
+| **Linear labels** | `validation`, `ui`, `traditional-projects`, `beyond-parity` |
+
+**Description:** Extend C3a gates with B9 membership rules validation. Block upload when client-checkable project rules fail — enhancement beyond classic apps (which upload and 422).
+
+**Acceptance criteria:**
+
+- Chooser SAVE: run C2 + B9; block pop if invalid; inline pass/fail per rule row (B9)
+- ObsEdit Upload button: run B9 validation in addition to C2
+- Gate order: C2 (POF/OFV fields) → B9 (membership rules) → pop/block upload
+
+---
+
+## Workstream E — Join/leave, detail, release (41 pts)
+
+### E2 — Join flow: bottom sheet
+
+| | |
+|---|---|
+| **Points** | 10 |
+| **Phase** | B (beyond) |
+| **Dependencies** | E7 |
+| **Linear** | MOB-1514 |
+| **Figma** | [`30019-21511`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-21511&m=dev), [`30019-58169`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-58169&m=dev) |
+| **Linear labels** | `projects`, `ui`, `traditional-projects`, `beyond-parity` |
+
+**Description:** Join traditional project via location-permissions bottom sheet (3 radio options, geo-privacy pattern). Absorbs former P2-1. **Parity baseline:** basic join on project detail already ships on `main`.
+
+**Acceptance criteria:**
+
+- Tapping Join on project detail opens `30019:21511` bottom sheet: 3 location-permission radio options (web parity)
+- **Confirm & Join** sets location permissions AND adds user to project in one action
+- User reads About, Project Admins, and Project Rules on project detail page (E7) before joining
+- Joined traditional projects: **Edit location permissions** button opens `30019:58169`
+- On join success: `POST join` with selected options; triggers **E8** post-join sync
+- Optimistic UI with rollback on join API failure
+
+---
+
+### E3 — Leave flow with retention options
+
+| | |
+|---|---|
+| **Points** | 12 |
+| **Phase** | B (beyond) |
+| **Dependencies** | A3 |
+| **Linear** | MOB-1515 |
+| **Figma** | [`29821-81668`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-81668&m=dev) |
+| **Linear labels** | `projects`, `ui`, `traditional-projects`, `beyond-parity` |
+
+**Description:** Leave project sheet with observation retention and hidden-coordinate options. **Parity baseline:** generic leave confirm on `main`.
+
+**Acceptance criteria:**
+
+- **3-option full sheet only:** (1) leave obs in project, curators keep coord access (2) leave obs, revoke hidden coord access (3) remove all user's obs from project
+- CANCEL / LEAVE (destructive) buttons
+- On success: `DELETE leave`, remove `Project` from Realm joined cache
+- **Spike (2 pts included):** document web API for option 3 and `prefers_curator_coordinate_access` update for option 2
+
+---
+
+### E7 — Project detail page — sections and membership UI
+
+| | |
+|---|---|
+| **Points** | 10 |
+| **Phase** | B (beyond) |
+| **Dependencies** | A3, A3b |
+| **Linear** | MOB-1523 |
+| **Figma** | [`30019-21238`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-21238&m=dev), [`30019-21546`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-21546&m=dev) |
+| **Linear labels** | `projects`, `ui`, `traditional-projects`, `beyond-parity` |
+
+**Description:** Re-layout `ProjectDetails.tsx`: About, Project Admins, Project Rules (traditional only), membership UI. **Parity baseline:** type label, description, requirements link already on `main`.
+
+**Acceptance criteria:**
+
+- **All project types:** project-type subtitle under title per Figma `30019:21238`
+- **All project types (joined):** **Manage Membership** heading; **Leave Project** button label
+- **Traditional only:** About section, Project Admins section, inline Project Rules section
+- **Traditional joined:** **Edit location permissions** entry → E2 edit sheet
+- Not joined: Join Project CTA → E2 bottom sheet
+
+---
+
+### E5b — i18n strings (beyond parity)
+
+| | |
+|---|---|
+| **Points** | 1 |
+| **Phase** | B (beyond) |
+| **Linear** | MOB-1562 |
+| **Linear labels** | `i18n`, `traditional-projects`, `beyond-parity` |
+
+**Description:** Add beyond-parity strings: join/leave location-permission options, project detail section headings, rules validation messages.
+
+**Acceptance criteria:**
+
+- Location-permission option labels (join + edit sheets)
+- "Confirm & Join", "Manage Membership", "Leave Project", "Edit location permissions"
+- Project Admins / Project Rules section headings
+- B9 rule failure messages
+
+---
+
+### E6b — Integration tests (beyond parity)
+
+| | |
+|---|---|
+| **Points** | 6 |
+| **Phase** | B (beyond) |
+| **Dependencies** | D1, C3b, E2, E3 |
+| **Linear** | MOB-1563 |
+| **Linear labels** | `tests`, `traditional-projects`, `beyond-parity` |
+
+**Description:** End-to-end tests for **beyond-parity** flows — run after Phase B features land.
+
+**Acceptance criteria:**
+
+- Integration test: B9 blocks chooser SAVE when membership rule fails
+- Integration test: join bottom sheet with location permission selection
+- Integration test: leave sheet with 3 retention options
+- Integration test: project detail shows About/Admins/Rules sections
+
+---
+
+## Workstream P2 — Resilience (20 pts)
+
+### P2-2 — Upload-time schema reconciliation
+
+| | |
+|---|---|
+| **Points** | 8 |
+| **Phase** | B (beyond) |
+| **Dependencies** | D1, C2 |
+| **Linear** | MOB-1518 |
+| **Linear labels** | `upload`, `beyond-parity` |
+
+**Description:** Re-fetch `project_observation_fields` before upload; re-validate OFVs against fresh schema. Neither classic app does this.
+
+**Acceptance criteria:**
+
+- Before uploading PO/OFV for an obs, refresh field definitions for selected projects if stale
+- Re-run validation; block upload with actionable message if new required field added server-side
+
+---
+
+### P2-4 — Offline join/leave queue
+
+| | |
+|---|---|
+| **Points** | 12 |
+| **Phase** | B (beyond) |
+| **Dependencies** | E2, E8, E3 |
+| **Linear** | MOB-1520 |
+| **Linear labels** | `offline`, `beyond-parity` |
+
+**Description:** Queue join/leave when offline; replay when online. Classic apps hard-block offline.
+
+**Acceptance criteria:**
+
+- Optimistic UI with rollback on failure
+- Persist pending join/leave ops in Realm or MMKV
+
+---
+
+# Cancelled tickets
+
+| ID | Reason | Linear |
+|----|--------|--------|
+| P2-1 | Merged into E2 (hidden coords at join) | MOB-1517 |
+| P2-3 | Merged into D2 (OFV clear → DELETE is iOS parity) | MOB-1519 |
+| P2-5 | Out of scope (ObsDetails OFV display) | MOB-1521 |
+| E4 | Folded into E7 (detail subtitle) | MOB-1494 |
+
+---
+
+## B9 spike appendix — project rules validation
+
+**Source:** Rails server spike (2026-06-10). Traditional PO create validates via `validates_rules_from :project` in `lib/ruler/ruler/has_rules_for.rb` — evaluates **`project_observation_rules` only**, not `rule_preferences`.
+
+### Executive summary
+
+| Category | Count |
+|----------|-------|
+| Rules that can 422 at PO create | ~25 (operators + non-rule PO validations) |
+| Client-checkable (yes) | 10 |
+| Client-checkable (partial) | 9 |
+| Server-only | 6 |
+| `rule_preferences` (display on traditional, not PO-validated) | 11 |
+
+### Rule combination semantics
+
+- **Same `operator`** → **OR** (any one rule in group passes)
+- **Different operators** → **AND** (all operator groups must pass)
+- **422 shape:** `{ errors: ["Didn't pass rule: …"] }` or `"Didn't pass rules: A OR B"`
+
+### Recommended validators (B9 priority)
+
+| Priority | Operators / checks | Client-checkable |
+|----------|-------------------|------------------|
+| **P0** | `identified?`, `georeferenced?`, `has_a_photo?`, `has_a_sound?`, `has_media?`, `in_taxon?`, `not_in_taxon?`, `verifiable?` | yes / partial |
+| **P1** | `wild?`, `captive?`, `observed_after?`, `observed_before?`, bioblitz window; duplicate PO; collection/umbrella block; observer privacy | yes / partial |
+| **P2** | `on_list?` (if list cached); `rule_preferences` date/month (warning only) | partial |
+| **Defer** | `observed_in_place?`, `coordinates_shareable_by_project_curators?`, establishment prefs, `members_only` | no |
+
+### Key operator checks (membership rules)
+
+| Operator | Check | Client? |
+|----------|-------|---------|
+| `identified?` | `taxon_id` present (any rank) | yes |
+| `georeferenced?` | lat/lng or private_lat/private_lng (non-zero) | yes |
+| `has_a_photo?` / `has_a_sound?` / `has_media?` | persisted media counts | partial (timing race) |
+| `verifiable?` | `quality_grade IN ('needs_id','research')` | partial (stale QG) |
+| `in_taxon?` / `not_in_taxon?` | taxon ancestry match | partial (needs `ancestor_ids`) |
+| `wild?` / `captive?` | `captive_cultivated` / quality metrics | partial |
+| `observed_after?` / `observed_before?` | `time_observed_at` / `observed_on` vs operand | yes |
+| `observed_in_place?` | PostGIS point-in-polygon | **no** |
+| `on_list?` | exact `listed_taxa.taxon_id` match | partial (needs cached list) |
+
+### `rule_preferences` (display only on traditional PO create)
+
+Shown in chooser/E7 for UI parity; **not SAVE-gated**: `quality_grade`, `photos`, `sounds`, `d1`, `d2`, `observed_on`, `month`, `native`, `introduced`, `members_only`, annotation terms. Traditional projects enforce equivalents via `project_observation_rules` operators (e.g. `verifiable?` not `quality_grade` pref).
+
+### POF/OFV validation (C2 — separate from B9)
+
+| Scenario | 422 message |
+|----------|-------------|
+| 1 required POF | Auto `has_observation_field?` rule |
+| 2+ required POFs | `"Missing required observation field: {name}"` |
+
+Upload order: observation → media → **OFVs** → **POs** last.
+
+### Server-only fallback (D3)
+
+Rules that may still 422 after B9 client checks:
+
+- `observed_in_place?` (PostGIS geometry)
+- `coordinates_shareable_by_project_curators?` (runtime `ProjectUser` prefs)
+- Stale `verifiable?` / quality grade vs server `get_quality_grade`
+- `has_a_photo?` / `has_a_sound?` media join timing race
+- Stale/missing taxon ancestry for `in_taxon?`
+- Observer privacy / invite-only / curators-only when submitter ≠ observer
+- Misconfigured collection-only operators (`observed_by_user?`, `in_project?`)
+
+### API gaps (server backlog)
+
+- `rule_preferences` displayed but not PO-enforced on traditional — client validating prefs may over-block
+- Place rules lack geometry in mobile cache — cannot offline-validate `observed_in_place?`
+- Taxon rules may lack `ancestor_ids` in default payload — include when `rule_details=true`
+- `on_list?` needs `project_list_taxon_ids[]` on project when `rule_details=true`
+
+### Test cases
+
+1. **Pass** — `in_taxon?` (Aves) + `georeferenced?`; obs has child taxon + coords → 200 PO
+2. **Fail** — `in_taxon?` + `georeferenced?`; obs has taxon, no coords → 422 `"must be georeferenced"`
+3. **Fail** — `verifiable?`; obs casual despite photo+coords+date → 422 `"must be verifiable"`
+4. **Edge** — `has_a_photo?` after upload race (PO before photo join) → 422; client may false-pass
+5. **Edge** — `observed_in_place?` with obscured private coords inside place → 200; client checking public coords only would false-negative
+
+### `validateProjectRules` pseudocode
+
+```javascript
+function validateProjectRules(obs, project) {
+ const errors = [];
+ const rulesByOperator = groupBy(project.project_observation_rules, "operator");
+ for (const [operator, rules] of rulesByOperator) {
+ const passed = rules.some(rule => evaluateRule(obs, project, rule));
+ if (!passed) {
+ errors.push(formatRuleTerms(rules)); // OR-join wording per server
+ }
+ }
+ return errors;
+}
+```
+
+### Rails source index
+
+| File | Role |
+|------|------|
+| `app/controllers/project_observations_controller.rb` | PO create API, 422 JSON |
+| `app/models/project_observation.rb` | Rule methods + non-rule validations |
+| `lib/ruler/ruler/has_rules_for.rb` | AND/OR combination, error messages |
+| `app/models/project_observation_rule.rb` | Operator definitions, `terms` strings |
+| `app/models/project.rb` | `RULE_PREFERENCES`, aggregation |
+| `app/models/observation.rb` | `verifiable?`, `georeferenced?`, `captive_cultivated?` |
+| `app/models/project_observation_field.rb` | Required POF → `has_observation_field?` rule |
+| `spec/models/project_observation_rule_spec.rb` | OR/AND semantics tests |
+
+---
+
+## Dependency graph
+
+```mermaid
+flowchart TD
+ subgraph phaseA [Phase A — Parity prototype]
+ A3c[A3c Sync triggers] --> B8[B8 Zustand + B2b]
+ A4[A4 Done] --> B8
+ B3[B3 Field form] --> B3b[B3b Polish]
+ B3 --> C3a[C3a Field validation gates]
+ B8 --> C1[C1 Save path]
+ C2[C2 Required fields] --> C3a
+ B3 --> C3a
+ C1 --> D1[D1 Upload OFV/PO]
+ D1 --> D2[D2 Deletion sync]
+ D1 --> D3[D3 422 surfacing]
+ D1 --> D4[D4 Edge QA]
+ C3a --> E6a[E6a Parity tests]
+ D1 --> E6a
+ end
+ subgraph phaseB [Phase B — Beyond parity]
+ A3b[A3b Rules metadata] --> B9[B9 Project rules]
+ B9 --> C3b[C3b Rules validation gates]
+ C3a --> C3b
+ A3b --> E7[E7 Project detail]
+ E7 --> E2[E2 Join sheet]
+ E2 --> E3[E3 Leave sheet]
+ E2 --> P24[P2-4 Offline join/leave]
+ E3 --> P24
+ D1 --> P22[P2-2 Schema reconciliation]
+ C3b --> E6b[E6b Beyond tests]
+ E2 --> E6b
+ end
+ A3[A3 PoC Done] --> A3c
+ A3 --> A3b
+ B2[B2 Chooser Done] --> B3
+```
+
+---
+
+## Parallelization and milestones
+
+### Phase A milestones (parity prototype)
+
+| Milestone | Tickets | Pts |
+|-----------|---------|-----|
+| **TPOD-A1 Parity: chooser persistence** | A3c, B8, B3b, BUG | 15 |
+| **TPOD-A2 Parity: save + upload** | C1, C2, C3a, C4, D1, D2, D3, D4, E5a, E6a | 60 |
+
+**Shippable prototype gate:** TPOD-A1 + TPOD-A2 complete + B3/B4–B7 (in progress) → test behind feature flag.
+
+### Phase B milestones (beyond parity — after prototype tested)
+
+| Milestone | Tickets | Pts |
+|-----------|---------|-----|
+| **TPOD-B1 Beyond: rules validation** | A3b, B9, C3b, E5b | 19 |
+| **TPOD-B2 Beyond: join/leave/detail** | E7, E2, E3, E6b, P2-2, P2-4 | 58 |
+
+### Completed milestones (unchanged)
+
+| Milestone | Tickets | Status |
+|-----------|---------|--------|
+| TPOD-M0 Foundation | F0, A1, A2, E1 | Done |
+| TPOD-M1 Sync & Realm (partial) | A3, A4, E8 | Done |
+| TPOD-M2 Chooser UI (partial) | B1, B2, B3, B4–B7 | Done / in progress |
+
+### Parallelization notes
+
+- **Phase A critical path:** B3/B4–B7 (in flight) → B8 → C1 → D1 → D3 → E6a
+- **Phase A parallel:** A3c, B3b, BUG, C2, C4, E5a alongside critical path
+- **Phase B critical path:** A3b → B9 → C3b; E7 → E2 → E3 → E6b
+- **Phase B parallel:** P2-2, P2-4, E5b after their dependencies land
+
+### Suggested Linear labels
+
+`traditional-projects`, `parity`, `beyond-parity`, plus area labels: `api`, `realm`, `upload`, `obs-edit`, `ui`, `validation`, `projects`, `tests`, `docs`, `feature-flag`, `offline`, `needs-product`, `needs-design`
+
+*(Legacy labels `phase-1` / `phase-2` map to `parity` / `beyond-parity`.)*
+
+---
+
+## Ticket index (quick reference)
+
+| ID | Title | Pts | Phase | Deps | Linear |
+|----|-------|-----|-------|------|--------|
+| F0 | Engineering glossary | 2 | Done | — | MOB-1490 |
+| A1 | API wrappers + types | 4 | Done | — | MOB-1491 |
+| A2 | Realm models + migration | 12 | Done | — | MOB-1492 |
+| A3 | Joined-projects sync (PoC) | 4 | Done | A1, A2 | MOB-1496 |
+| A3c | Sync triggers + pagination | 3 | A | A3 | MOB-1535 |
+| A4 | Download mapping | 8 | Done | A2 | MOB-1497 |
+| A3b | Rules metadata sync | 5 | B | A3 | MOB-1534 |
+| B1 | ObsEdit Projects row | 4 | Done | E1 | MOB-1501 |
+| B2 | Project chooser screen | 12 | Done | A3 | MOB-1502 |
+| B3 | Per-project field form | 8 | In Progress | B2, A4 | MOB-1503 |
+| B3b | Field form polish | 4 | A | B3 | MOB-1550 |
+| B4–B7 | Field inputs (7 types) | 16 | In Progress | B3 | MOB-1504 |
+| B8 | Zustand project state + B2b | 6 | A | A4 | MOB-1498 |
+| BUG | DateTimePicker datetime bug | 2 | A | — | MOB-1551 |
+| B9 | Client-side project rules | 10 | B | B3, C2, A3b | MOB-1522 |
+| C1 | Save POs/OFVs | 8 | A | A4, B8 | MOB-1508 |
+| C2 | Validation module (required fields) | 4 | A | A4 | MOB-1499 |
+| C3a | Required-field validation gates | 5 | A | C2, B3 | MOB-1509 |
+| C3b | Project-rules validation gates | 3 | B | C3a, B9 | MOB-1561 |
+| C4 | ObsDetails local projects | 4 | A | A2 | MOB-1500 |
+| D1 | Upload OFVs + POs | 12 | A | A1, C1 | MOB-1510 |
+| D2 | Deletion sync (+ P2-3) | 8 | A | D1 | MOB-1511 |
+| D3 | Server 422 surfacing | 4 | A | D1 | MOB-1512 |
+| D4 | Multi-obs edge QA | 8 | A | D1, D2 | MOB-1513 |
+| E1 | Feature flag | 2 | Done | — | MOB-1493 |
+| E8 | Post-join offline sync | 2 | Done | A3 | MOB-1524 |
+| E5a | i18n strings (parity) | 1 | A | — | MOB-1495 |
+| E5b | i18n strings (beyond) | 1 | B | — | MOB-1562 |
+| E6a | Integration tests (parity) | 6 | A | D1, C3a | MOB-1516 |
+| E6b | Integration tests (beyond) | 6 | B | D1, C3b, E2, E3 | MOB-1563 |
+| E2 | Join bottom sheet | 10 | B | E7 | MOB-1514 |
+| E3 | Leave retention sheet | 12 | B | A3 | MOB-1515 |
+| E7 | Project detail sections | 10 | B | A3, A3b | MOB-1523 |
+| P2-2 | Upload-time reconciliation | 8 | B | D1, C2 | MOB-1518 |
+| P2-4 | Offline join/leave queue | 12 | B | E2, E8, E3 | MOB-1520 |
+| P2-1 | Hidden coords at join | 0 | Cancelled | → E2 | MOB-1517 |
+| P2-3 | OFV delete propagation | 0 | Cancelled | → D2 | MOB-1519 |
+| P2-5 | OFV on ObsDetails | 0 | Cancelled | — | MOB-1521 |
+| E4 | Project type indicator | 0 | Cancelled | → E7 | MOB-1494 |
+
+**Linear mapping (MOB-1490 – MOB-1563):**
+
+| Ticket | Linear | Ticket | Linear |
+|--------|--------|--------|--------|
+| F0 | MOB-1490 | C1 | MOB-1508 |
+| A1 | MOB-1491 | C3a | MOB-1509 |
+| A2 | MOB-1492 | C3b | MOB-1561 |
+| E1 | MOB-1493 | D1 | MOB-1510 |
+| E4 | MOB-1494 (cancelled → E7) | D2 | MOB-1511 |
+| E5a | MOB-1495 | D3 | MOB-1512 |
+| E5b | MOB-1562 | D4 | MOB-1513 |
+| A3 | MOB-1496 (Done) | E2 | MOB-1514 |
+| A3b | MOB-1534 | E3 | MOB-1515 |
+| A3c | MOB-1535 | E6a | MOB-1516 |
+| A4 | MOB-1497 | E6b | MOB-1563 |
+| B8 | MOB-1498 | P2-1 | MOB-1517 (cancelled) |
+| C2 | MOB-1499 | P2-2 | MOB-1518 |
+| C4 | MOB-1500 | P2-3 | MOB-1519 (cancelled → D2) |
+| B1 | MOB-1501 | P2-4 | MOB-1520 |
+| B2 | MOB-1502 | P2-5 | MOB-1521 (cancelled) |
+| B3 | MOB-1503 | B9 | MOB-1522 |
+| B3b | MOB-1550 | E7 | MOB-1523 |
+| BUG | MOB-1551 | E8 | MOB-1524 (Done) |
+| B4–B7 | MOB-1504 | | |
+| B5 | MOB-1505 (canceled → MOB-1504) | | |
+| B6 | MOB-1506 (canceled → MOB-1504) | | |
+| B7 | MOB-1507 (canceled → MOB-1504) | | |
diff --git a/docs/traditional-projects-glossary.md b/docs/traditional-projects-glossary.md
new file mode 100644
index 000000000..9beef44bf
--- /dev/null
+++ b/docs/traditional-projects-glossary.md
@@ -0,0 +1,225 @@
+# Traditional Projects — Engineering Glossary
+
+Reference for mobile engineers implementing Traditional Project support in the React Native app. Abbreviations used across tickets: **PO** = project observation, **OFV** = observation field value, **POF** = project observation field.
+
+**Linear project:** [Traditional Projects in App](https://linear.app/inaturalist/project/traditional-projects-in-app-85969e27f9f8)
+
+Related docs:
+
+- [traditional-projects-build-plan.md](traditional-projects-build-plan.md) — ticket breakdown
+- [traditional-projects-porting-reference_iOS.md](traditional-projects-porting-reference_iOS.md) — iOS audit
+- [traditional-projects-porting-analysis_Android.md](traditional-projects-porting-analysis_Android.md) — Android audit
+
+---
+
+## How the pieces fit together
+
+```mermaid
+flowchart LR
+ Join[User joins project] --> Cache[Project + POFs cached in Realm]
+ Cache --> Chooser[User opens Add to Projects on obs edit]
+ Chooser --> Toggle[Toggle traditional project ON]
+ Toggle --> Rules[Check membership rules B9]
+ Rules --> OFVs[User fills OFVs per POF]
+ OFVs --> Save[Save observation locally]
+ Save --> Upload[Upload: obs → media → OFVs → POs]
+ Upload --> PO[POST project_observation links obs to project]
+```
+
+1. User **joins** a project (online) → project metadata and **project observation fields** (POFs) are cached locally.
+2. While editing an observation, user opens **Add to Projects** and toggles one or more **traditional** joined projects on.
+3. For each toggled project, the app shows **membership rules** (B9 gate) and **POFs**; user enters **OFVs** (answers).
+4. On save, **PO** and **OFV** records are written to Realm with dirty/sync flags.
+5. On upload, after the observation exists server-side: **OFVs** upload first, then **POs** (server validates `project_observation_rules` and required POFs on PO create).
+
+---
+
+## Terms (alphabetical)
+
+### `allowed_values`
+
+| | |
+|---|---|
+| **Definition** | Pipe-delimited list of permitted answers for a text or DNA observation field. |
+| **API** | `observation_field.allowed_values` — string like `"male\|female\|unknown"`. |
+| **RN / Realm** | Stored on embedded `ObservationField.allowedValues` (string array after split). |
+| **Classic apps** | iOS: split on `\|` in Mantle transformer; Android: `allowed_values.split("\\|")`. |
+| **Confusion** | Not present on numeric/date/taxon types. Multiple values on text/dna ⇒ UI treats field as **select**, not free text. |
+| **Tickets** | B4, B5; see iOS audit §2.2, Android §5. |
+
+### Collection project
+
+| | |
+|---|---|
+| **Definition** | Project type that **automatically** includes observations matching query rules. Users cannot manually add/remove obs. |
+| **API** | `project_type: "collection"`. |
+| **RN / Realm** | `ApiProject.project_type === "collection"`; read-only in chooser. |
+| **Classic apps** | iOS `ExploreProjectTypeCollection`; Android `PROJECT_TYPE_COLLECTION`. |
+| **Confusion** | Membership on an observation appears as `non_traditional_projects` in API responses — **not** via `project_observations`. |
+| **Tickets** | B2, B3; iOS audit §2.1, §3.2. |
+
+### Dirty flags / tombstone
+
+| | |
+|---|---|
+| **Definition** | Local state tracking whether a record needs upload or deletion on the server. |
+| **API** | N/A (client-only). |
+| **RN / Realm** | `_synced_at`, `_updated_at`, `needs_sync` on Observation; same pattern on embedded PO/OFV. Staged removals use tombstone/deleted-record pattern (see upload pipeline). |
+| **Classic apps** | iOS: `timeSynced` / `timeUpdatedLocally`, `ExploreDeletedRecord`; Android: `is_new` / `is_deleted`, `_synced_at` / `_updated_at`. |
+| **Confusion** | `needs_sync` on Observation does not yet include PO/OFV children — extend in A2, C1, D1. |
+| **Tickets** | A2, C1, D1, D2; iOS audit §5.1–5.3, Android §2. |
+
+### Joined project
+
+| | |
+|---|---|
+| **Definition** | A project the **signed-in user** is a member of. Distinct from an observation being *in* a project. |
+| **API** | `GET /v1/users/{userId}/projects` (paginated, includes `project_observation_fields`). |
+| **RN / Realm** | Cached in standalone `Project` Realm objects; membership list on User or query all `Project` rows synced from that endpoint. |
+| **Classic apps** | iOS: `ExploreUserRealm.joinedProjects`; Android: `projects` table wiped and re-filled on sync. |
+| **Confusion** | Joined ≠ observation is in project. Chooser only lists joined traditional projects for manual toggle. |
+| **Tickets** | A3, B2, E8 (post-join cache), E2 (join UI); iOS audit §2.5, §4, Android §6. |
+
+### `non_traditional_projects`
+
+| | |
+|---|---|
+| **Definition** | On an observation payload: collection/umbrella projects the obs is auto-included in (computed server-side). |
+| **API** | `observation.non_traditional_projects[]` with nested `project`. |
+| **RN / Realm** | Read-only in `ApiObservation`; shown in ObsDetails today via remote fetch. Not manually editable. |
+| **Classic apps** | Android passes as `UMBRELLA_PROJECT_IDs` to picker for read-only display. |
+| **Confusion** | Name says "non_traditional" but means **new-style** (collection/umbrella), not "not traditional". |
+| **Tickets** | B2; existing `ProjectSection.tsx`. |
+
+### Observation field
+
+| | |
+|---|---|
+| **Definition** | Global field **definition** (name, datatype, allowed values) reused across projects. |
+| **API** | Nested as `observation_field` inside `project_observation_fields[]`. |
+| **RN / Realm** | `ObservationField` (embedded on `Project.projectObservationFields` and referenced by OFV). |
+| **Classic apps** | iOS `ExploreObsFieldRealm`; Android `ProjectField` / `field_id`. |
+| **Confusion** | Not the user's answer — that is an **OFV**. |
+| **Tickets** | A1, A2; iOS audit §2.2–2.4, Android §2 `project_fields`. |
+
+### Observation field value (OFV)
+
+| | |
+|---|---|
+| **Definition** | The user's **answer** for one observation field on one observation. |
+| **API** | `observation_field_values` / `ofvs`; nested body on POST: `{ observation_field_value: { observation_id, observation_field_id, value, uuid } }`. One OFV per `(observation_id, observation_field_id)` — no `project_id`. |
+| **RN / Realm** | Embedded `ObservationFieldValue` on `Observation`: `uuid`, `id` (server OFV id), `obsFieldId`, `value` — **global per observation, not project-scoped**; lookup `ObservationFieldValue.findForObsField(obs, obsFieldId)`. Schema **v70** (no `projectId` on OFV). |
+| **Classic apps** | iOS `ExploreObsFieldValueRealm` (`valueForObsField:`); Android `project_field_values`. |
+| **Confusion** | Values are **always strings** (taxon id, dates, numbers as text). OFVs are on the observation, not on the PO record. Same global field on multiple projects shares **one** answer — do not duplicate OFV rows per project; `projectId` lives on **PO**, not OFV. |
+| **Tickets** | A1, A2, A4 (schema v70), B3–B7, C1, D1; iOS audit §2.4, §5.1, Android §2, §6. |
+
+### `prefers_curator_coordinate_access`
+
+| | |
+|---|---|
+| **Definition** | Whether project curators may view **hidden/obscured coordinates** for the member's observations in that project. |
+| **API** | `PUT /v1/project_users/{id}` with `prefers_curator_coordinate_access` (web; RN to confirm). |
+| **RN / Realm** | Not persisted locally in Phase 1 beyond leave-flow choice; may sync via join/leave API. |
+| **Classic apps** | **Not implemented** in iOS or Android native apps. |
+| **Confusion** | Leave sheet option 2 ("prevent curators from viewing hidden coordinates") maps here — distinct from removing obs from project. |
+| **Tickets** | E3 (leave), P2-1 (join). |
+
+### Project observation (PO)
+
+| | |
+|---|---|
+| **Definition** | Server record linking **one observation** to **one traditional project** (manual membership). |
+| **API** | `project_observations`; POST body flat: `{ observation_id, project_id, uuid }`. |
+| **RN / Realm** | Embedded `ProjectObservation` on `Observation` (client `uuid`, server `projectObsId`). |
+| **Classic apps** | iOS `ExploreProjectObservationRealm`; Android `project_observations` with `is_new` / `is_deleted`. |
+| **Confusion** | Not "an observation made for a project" in casual language — it is the **join row**. Requires server `observation_id`; uploads **after** OFVs. |
+| **Tickets** | A1, A2, C1, D1; iOS audit §2.4, §5.1, Android §2, §6. |
+
+### `project_observation_rules`
+
+| | |
+|---|---|
+| **Definition** | Membership **rule rows** on a project — operators like `in_taxon?`, `georeferenced?`, `verifiable?` that the server evaluates when creating a **PO**. |
+| **API** | `project_observation_rules[]` on project payload with `operator`, `operand_type`, `operand_id`; expanded operands when `rule_details: true`. |
+| **RN / Realm** | Cached on `Project` (A3) for offline B9 validation in chooser. |
+| **Classic apps** | Not validated client-side in native apps. |
+| **Confusion** | These **cause 422** on traditional `POST /v1/project_observations`. Distinct from `rule_preferences` (display/ES on traditional). |
+| **Tickets** | A3, B9, E7; see build plan [B9 spike appendix](traditional-projects-build-plan.md#b9-spike-appendix--project-rules-validation). |
+
+### Project observation field (POF)
+
+| | |
+|---|---|
+| **Definition** | Configuration attaching an observation field to a **specific project**: required flag, sort position. |
+| **API** | `project_observation_fields[]` on project payload: `{ id, required, position, observation_field }`. |
+| **RN / Realm** | Embedded `ProjectObservationField` on `Project` (or denormalized on join sync). |
+| **Classic apps** | iOS `ExploreProjectObsFieldRealm`; Android `project_fields` with `is_required`, `position`. |
+| **Confusion** | Same global field can appear on multiple projects with different `required` / `position`. |
+| **Tickets** | A2, A3, B3; iOS audit §2.3–2.4, Android §2. |
+
+### Rule combination (membership rules)
+
+| | |
+|---|---|
+| **Definition** | How multiple `project_observation_rules` combine when validating a PO. |
+| **API** | Rails `validates_rules_from :project` in `lib/ruler/ruler/has_rules_for.rb`. |
+| **RN / Realm** | B9 `validateProjectRules` must mirror: **OR within same `operator`**, **AND across different operators**. |
+| **Classic apps** | Server-only; native apps do not pre-validate. |
+| **Confusion** | Three `in_taxon?` rules = match **any** listed taxon tree; `in_taxon?` + `georeferenced?` = **both** required. |
+| **Tickets** | B9; `spec/models/project_observation_rule_spec.rb`. |
+
+### `rule_preferences`
+
+| | |
+|---|---|
+| **Definition** | Collection-project **search filter** preferences (`quality_grade`, `photos`, `d1`, `month`, `native`, etc.) stored on project and indexed for ES. |
+| **API** | `rule_preferences[]` — `{ field, value }` from `Project::RULE_PREFERENCES`. |
+| **RN / Realm** | Cached on `Project` (A3); displayed in `ProjectRequirements.tsx` and E7. |
+| **Classic apps** | Shown on web requirements UI; **not enforced** on traditional PO create. |
+| **Confusion** | RN must **show** prefs for UI parity but **not SAVE-gate** on prefs alone — traditional enforces via `project_observation_rules` operators instead. |
+| **Tickets** | A3, B9 (display), E7; build plan B9 appendix. |
+
+### Select field
+
+| | |
+|---|---|
+| **Definition** | UI pattern for choosing one of several fixed answers — **not** an API `datatype`. |
+| **API** | Inferred when `datatype` is `text` or `dna` and `allowed_values` has **more than one** entry (iOS/Android). |
+| **RN** | Product decision: RN select UI applies to **`text` only**; `dna` always uses free-text input (MOB-1504). |
+| **RN / Realm** | Render with `RadioButtonSheet` / list picker (B5). |
+| **Classic apps** | iOS `ProjectObsFieldViewController`; Android Spinner. |
+| **Confusion** | Exactly one allowed value ⇒ render as free text, not select. Zero allowed values ⇒ free text. |
+| **Tickets** | B5; iOS audit §2.2, §3.4, Android §5. |
+
+### Traditional project
+
+| | |
+|---|---|
+| **Definition** | Original iNaturalist project type: users **manually** add observations and fill custom fields. |
+| **API** | `project_type` is `""` (empty string) or absent/null — **not** `collection` or `umbrella`. |
+| **RN / Realm** | `isTraditionalProject(project)` ⇒ `project_type !== "collection" && project_type !== "umbrella"`. |
+| **Classic apps** | iOS `ExploreProjectTypeOldStyle` / `!isNewStyleProject`; Android anything not collection/umbrella. |
+| **Confusion** | POD scope is **only** traditional manual add — not changing collection/umbrella behavior. |
+| **Tickets** | All B-track, E-track; iOS audit §2.1. |
+
+### Umbrella project
+
+| | |
+|---|---|
+| **Definition** | Container project grouping other projects; observations included by rules, not manual add. |
+| **API** | `project_type: "umbrella"`. |
+| **RN / Realm** | Same read-only treatment as collection in chooser footer. |
+| **Classic apps** | iOS `ExploreProjectTypeUmbrella`; Android `PROJECT_TYPE_UMBRELLA`. |
+| **Confusion** | Listed in chooser explainer only — no toggle. |
+| **Tickets** | B2; iOS audit §2.1. |
+
+### Upload order
+
+| | |
+|---|---|
+| **Definition** | Sequence of API calls when syncing an observation with project data. |
+| **API** | 1) Observation POST/PUT 2) photos/sounds 3) **OFVs** 4) **POs**. Deletes: **PO** before **OFV** before other children. |
+| **RN / Realm** | Extend `src/uploaders/observationUploader.ts` Step 3b. |
+| **Classic apps** | iOS `childrenNeedingUpload` order; Android `syncObservationFields` then `postProjectObservations`. |
+| **Confusion** | PO POST often fails with 422 if required OFVs missing — hence OFVs first. |
+| **Tickets** | D1, D2; iOS audit §5.2, Android §6. |
diff --git a/docs/traditional-projects-porting-analysis_Android.md b/docs/traditional-projects-porting-analysis_Android.md
new file mode 100644
index 000000000..5f9d2d416
--- /dev/null
+++ b/docs/traditional-projects-porting-analysis_Android.md
@@ -0,0 +1,839 @@
+# Traditional Projects in iNaturalistAndroid — Porting Analysis
+
+This document maps where every part of the Traditional Project feature lives in the iNaturalistAndroid repo, with direct code references. It covers the four POD work streams from the "Traditional Project Support POD Scope": add-to-project in the obs editor, the per-project obs field form (all field types + validation), join/leave flows, and offline sync. It ends with gaps where the Android app does NOT implement something the POD scope requires.
+
+All file paths are relative to the iNaturalistAndroid repository root. Line numbers refer to the state of the repo as of June 2026 (`master`).
+
+## 1. Architecture overview
+
+```mermaid
+flowchart TD
+ subgraph ui [UI Layer]
+ ObsEditor[ObservationEditor]
+ Selector[ProjectSelectorActivity]
+ FieldViewer[ProjectFieldViewer per field]
+ ProjDetails[ProjectDetails join/leave]
+ ObsViewer[ObservationViewerFragment]
+ end
+ subgraph db [SQLite via ObservationProvider]
+ Projects[(projects)]
+ ProjObs[(project_observations)]
+ ProjFields[(project_fields)]
+ ProjFieldVals[(project_field_values)]
+ end
+ subgraph api [API api.inaturalist.org/v1]
+ JoinAPI["POST/DELETE /projects/:id/join|leave"]
+ POAPI["POST/DELETE /project_observations"]
+ OFVAPI["POST /observation_field_values"]
+ UserProjAPI["GET /users/:login/projects"]
+ end
+ ObsEditor -->|"requestCode 102"| Selector
+ Selector --> FieldViewer
+ ObsEditor -->|saveProjects + saveProjectFields| ProjObs
+ ObsEditor --> ProjFieldVals
+ ProjDetails -->|service actions| JoinAPI
+ JoinAPI --> Projects
+ JoinAPI --> ProjFields
+ Sync[INaturalistServiceImplementation sync] --> POAPI
+ Sync --> OFVAPI
+ Sync --> UserProjAPI
+ ProjObs -->|"is_new / is_deleted queue"| Sync
+ ProjFieldVals -->|"_updated_at > _synced_at queue"| Sync
+ ObsViewer -->|read-only| ProjObs
+```
+
+Key design choice: all project selection AND project-field editing happens inside `ProjectSelectorActivity` (launched from the obs editor); the editor itself only stores results and persists them on save. Sync is a flag-based offline queue processed by a background service.
+
+## 2. Local data model (offline persistence)
+
+All four tables live in `inaturalist.db` (version 23), created in `iNaturalist/src/main/java/org/inaturalist/android/ObservationProvider.java` `onCreate`. No SQL foreign keys — relationships are logical. This is the model the RN app's Realm schema must reproduce.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ObservationProvider.java L65-L72
+public void onCreate(SQLiteDatabase db) {
+ db.execSQL(Observation.sqlCreate());
+ db.execSQL(ObservationPhoto.sqlCreate());
+ db.execSQL(ObservationSound.sqlCreate());
+ db.execSQL(Project.sqlCreate());
+ db.execSQL(ProjectObservation.sqlCreate());
+ db.execSQL(ProjectField.sqlCreate());
+ db.execSQL(ProjectFieldValue.sqlCreate());
+}
+```
+
+### `projects` — joined projects (`Project.java`)
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/Project.java L143-L152
+public static String sqlCreate() {
+ return "CREATE TABLE " + TABLE_NAME + " ("
+ + Project._ID + " INTEGER PRIMARY KEY,"
+ + "title TEXT,"
+ + "description TEXT,"
+ + "icon_url TEXT,"
+ + "project_type TEXT,"
+ + "id INTEGER,"
+ + "check_list_id INTEGER"
+ + ");";
+}
+```
+
+- Traditional-by-negation: only collection/umbrella constants exist; anything else (incl. null) is treated as traditional/selectable.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/Project.java L28-L29
+public static final String PROJECT_TYPE_COLLECTION = "collection";
+public static final String PROJECT_TYPE_UMBRELLA = "umbrella";
+```
+
+- No sync flags; the table is wiped and re-inserted from the server on every sync (`saveJoinedProjects()`, see section 6).
+
+### `project_observations` — obs-to-project join + offline queue (`ProjectObservation.java`)
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ProjectObservation.java L110-L119
+public static String sqlCreate() {
+ return "CREATE TABLE " + TABLE_NAME + " ("
+ + ProjectObservation._ID + " INTEGER PRIMARY KEY,"
+ + "project_id INTEGER,"
+ + "observation_id INTEGER,"
+ + "is_deleted INTEGER,"
+ + "is_new INTEGER, "
+ + "id INTEGER, "
+ + "UNIQUE(project_id, observation_id) ON CONFLICT REPLACE"
+ + ");";
+}
+```
+
+- `is_new = 1` means "pending POST", `is_deleted = 1` means "pending DELETE". This is the entire offline add/remove queue. `id` is the server-side project_observation id once synced.
+- `observation_id` duality: holds local `Observation._id` before the obs is uploaded; once the obs gets a server ID, the provider rewrites `observation_id` in both `project_observations` and `project_field_values`:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ObservationProvider.java L586-L596
+if ((count > 0) && (values.containsKey(Observation.ID)) && (values.get(Observation.ID) != null)) {
+ ContentValues cv = new ContentValues();
+ cv.put(ProjectObservation.OBSERVATION_ID, values.getAsInteger(Observation.ID));
+ Logger.tag(TAG).debug("Update project observation from " + id + " to " + values.getAsInteger(Observation.ID));
+ db.update(ProjectObservation.TABLE_NAME, cv, ProjectObservation.OBSERVATION_ID + "=" + id, null);
+
+ cv = new ContentValues();
+ cv.put(ProjectFieldValue.OBSERVATION_ID, values.getAsInteger(Observation.ID));
+ db.update(ProjectFieldValue.TABLE_NAME, cv, ProjectFieldValue.OBSERVATION_ID + "=" + id, null);
+}
+```
+
+### `project_fields` — field definitions per project (`ProjectField.java`)
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ProjectField.java L148-L160
+public static String sqlCreate() {
+ return "CREATE TABLE " + TABLE_NAME + " ("
+ + ProjectField._ID + " INTEGER PRIMARY KEY,"
+ + "field_id INTEGER,"
+ + "project_id INTEGER,"
+ + "name TEXT, "
+ + "description TEXT, "
+ + "data_type TEXT, "
+ + "allowed_values TEXT, "
+ + "is_required INTEGER, "
+ + "position INTEGER, "
+ + "UNIQUE(field_id, project_id) ON CONFLICT REPLACE"
+ + ");";
+}
+```
+
+- `allowed_values` is a pipe-separated string (e.g. `"a|b|c"`). Sourced from API `project_observation_fields` (nested `observation_field` object + `required` + `position`). Replaced wholesale per project on download; no sync flags.
+
+### `project_field_values` — user-entered values + offline queue (`ProjectFieldValue.java`)
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ProjectFieldValue.java L141-L154
+public static String sqlCreate() {
+ return "CREATE TABLE " + TABLE_NAME + " ("
+ + ProjectFieldValue._ID + " INTEGER PRIMARY KEY,"
+ + "_created_at INTEGER,"
+ + "_synced_at INTEGER,"
+ + "_updated_at INTEGER,"
+ + "created_at INTEGER,"
+ + "id INTEGER,"
+ + "observation_id INTEGER,"
+ + "updated_at INTEGER,"
+ + "value TEXT,"
+ + "field_id INTEGER,"
+ + "UNIQUE(field_id, observation_id) ON CONFLICT REPLACE"
+ + ");";
+}
+```
+
+- `value` is always TEXT — taxon IDs, dates, numbers are all stored as strings.
+- Dirty state = `(_synced_at IS NULL) OR (_updated_at > _synced_at)` — same timestamp pattern as observations:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L1931-L1934
+c = mContext.getContentResolver().query(ProjectFieldValue.CONTENT_URI,
+ ProjectFieldValue.PROJECTION,
+ "(_synced_at IS NULL) OR (_updated_at > _synced_at AND _synced_at IS NOT NULL)",
+```
+
+## 3. Add-to-project flow in the observation editor
+
+### Entry point (`ObservationEditor.java`)
+
+- UI row `R.id.select_projects` + count badge; label logic in `refreshProjectList()` (lines 274-285): "Add to projects" when 0, "Projects" + count otherwise.
+- State held across rotation, keyed by `field_id`:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L2334-L2336
+@State public ArrayList mProjectIds;
+private ArrayList mProjectFields;
+@State public HashMap mProjectFieldValues = null;
+```
+
+- Initial load for an existing observation: query `project_observations` filtering soft-deleted rows. Also supports preselecting a project via intent extra `OBSERVATION_PROJECT`.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L1202-L1214
+// Get IDs of project-observations
+if ((mObservation.id == null) && (mObservation._id == null)) {
+ mProjectIds = new ArrayList();
+} else {
+ int obsId = (mObservation.id == null ? mObservation._id : mObservation.id);
+ Cursor c = getActivity().getContentResolver().query(ProjectObservation.CONTENT_URI, ProjectObservation.PROJECTION,
+ "(observation_id = " + obsId + ") AND ((is_deleted = 0) OR (is_deleted is NULL))",
+ null, ProjectObservation.DEFAULT_SORT_ORDER);
+```
+
+- Launch picker (request code `PROJECT_SELECTOR_REQUEST_CODE = 102`) passing: observation ID, `IS_CONFIRMATION=true`, current field-value map, selected project IDs, and the IDs of collection/umbrella projects the obs is auto-included in (from obs JSON `non_traditional_projects`) as `UMBRELLA_PROJECT_IDs`.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L948-L957
+mProjectSelector.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent(getActivity(), ProjectSelectorActivity.class);
+ intent.putExtra(INaturalistService.OBSERVATION_ID, (mObservation.id == null ? mObservation._id : mObservation.id));
+ intent.putExtra(ProjectSelectorActivity.IS_CONFIRMATION, true);
+ intent.putExtra(ProjectSelectorActivity.PROJECT_FIELDS, mProjectFieldValues);
+
+ // Show both "regular" projects and umbrella/collection projects the observation belongs to
+ intent.putIntegerArrayListExtra(INaturalistService.PROJECT_ID, mProjectIds);
+```
+
+- Result handling: replaces `mProjectIds` and `mProjectFieldValues` wholesale from the picker result.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L3006-L3017
+} else if (requestCode == PROJECT_SELECTOR_REQUEST_CODE) {
+ if (resultCode == Activity.RESULT_OK) {
+ ArrayList projectIds = data.getIntegerArrayListExtra(ProjectSelectorActivity.PROJECT_IDS);
+ HashMap values = (HashMap) data.getSerializableExtra(ProjectSelectorActivity.PROJECT_FIELDS);
+
+ if (!mProjectIds.equals(projectIds)) {
+ AnalyticsClient.getInstance().logEvent(AnalyticsClient.EVENT_NAME_OBS_PROJECTS_CHANGED);
+ }
+
+ mProjectIds = projectIds;
+ mProjectFieldValues = values;
+```
+
+### Persisting on observation save
+
+- `saveProjects()`, three-phase soft delete against `project_observations`:
+ 1. rows whose project is no longer selected get `is_deleted = true` (lines 2737-2751)
+ 2. re-selected rows get `is_deleted = false` (lines 2754-2773)
+ 3. newly selected projects get a new row with `is_new = true, is_deleted = false`:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L2775-L2788
+// Finally, add new project-observation records
+ArrayList newIds = (ArrayList) CollectionUtils.subtract(mProjectIds, existingIds);
+
+for (int i = 0; i < newIds.size(); i++) {
+ updatedProjects = true;
+ int projectId = newIds.get(i);
+ ProjectObservation projectObservation = new ProjectObservation();
+ projectObservation.project_id = projectId;
+ projectObservation.observation_id = obsId;
+ projectObservation.is_new = true;
+ projectObservation.is_deleted = false;
+
+ getActivity().getContentResolver().insert(ProjectObservation.CONTENT_URI, projectObservation.getContentValues());
+}
+```
+
+- `saveProjectFields()`: upserts each non-null value into `project_field_values`; new rows are written with `_synced_at = now - 100` so `_updated_at > _synced_at` marks them dirty for upload.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L2707-L2726
+private void saveProjectFields() {
+ if (mProjectFieldValues == null) return;
+
+ for (ProjectFieldValue fieldValue : mProjectFieldValues.values()) {
+ if (fieldValue.value == null) {
+ continue;
+ }
+
+ if (fieldValue._id == null) {
+ // New field value
+ ContentValues cv = fieldValue.getContentValues();
+ cv.put(ProjectFieldValue._SYNCED_AT, System.currentTimeMillis() - 100);
+ Uri newRow = getActivity().getContentResolver().insert(ProjectFieldValue.CONTENT_URI, cv);
+ getActivity().getContentResolver().update(newRow, fieldValue.getContentValues(), null, null);
+ } else {
+ // Update field value
+ getActivity().getContentResolver().update(fieldValue.getUri(), fieldValue.getContentValues(), null, null);
+ }
+ }
+}
+```
+
+- Any project change bumps the observation's `_updated_at`, which enqueues the parent observation for sync.
+
+### Loading field definitions/values
+
+- `refreshProjectFields()` delegates to static helper `ProjectFieldViewer.getProjectFields()` (`ProjectFieldViewer.java` lines 670-706): queries `project_fields` per selected project + `project_field_values` for the observation, returns a `field_id → value` map. Fields filtered/sorted by `position` (`sortProjectFields`, lines 709-739).
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L4630-L4642
+ProjectFieldViewer.getProjectFields(getActivity(), mProjectIds, (mObservation.id == null ? mObservation._id : mObservation.id), new ProjectFieldViewer.ProjectFieldsResults() {
+ @Override
+ public void onProjectFieldsResults(ArrayList projectFields, HashMap projectValues) {
+ mProjectFields = projectFields;
+
+ if (mProjectFieldValues == null) {
+ mProjectFieldValues = projectValues;
+ }
+
+ addProjectFieldViewers();
+ }
+});
+```
+
+## 4. Project picker + per-project field form (`ProjectSelectorActivity.java`)
+
+- Loads joined projects offline-first via service action `ACTION_GET_JOINED_PROJECTS` (reads local `projects` table). Receiver at lines 88-197 sorts alphabetically and splits the list: traditional projects on top, then a header ("Collection and Umbrella Projects") and the non-selectable collection/umbrella projects.
+- Collection/umbrella rows cannot be toggled — `onItemClick` returns early; they only show a read-only "included" indicator if the obs is in them.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ProjectSelectorActivity.java L656-L675
+String projectType = project.getString("project_type");
+boolean isUmbrellaProject = ((projectType != null) && ((projectType.equals(Project.PROJECT_TYPE_COLLECTION)) || (projectType.equals(Project.PROJECT_TYPE_UMBRELLA))));
+
+if (isUmbrellaProject) {
+ // Umbrella/collection projects cannot be selected / expanded
+ return;
+}
+
+Integer projectId = Integer.valueOf(project.getInt("id"));
+
+if (mObservationProjects.contains(projectId)) {
+ mObservationProjects.remove(projectId);
+} else {
+ mObservationProjects.add(projectId);
+}
+
+mAdapter.notifyDataSetChanged();
+```
+
+- Text search over project titles (lines 299-312, 397-427).
+- When a traditional project is checked (confirmation mode), the row expands inline with its field form: one `ProjectFieldViewer` per field (adapter `getView`, lines 496-628; layout `project_selector_confirmation_item.xml`), plus a "required" indicator if any field is required.
+- Field values are harvested from the viewers on every list rebind and on save (`saveProjectFieldValues()`, lines 362-381) into a `field_id → ProjectFieldValue` map.
+- Confirm (action-bar save): runs `validateProjectFields()`, then returns `PROJECT_IDS` + `PROJECT_FIELDS` to the editor.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ProjectSelectorActivity.java L210-L225
+case R.id.save_projects:
+ saveProjectFieldValues();
+
+ if (!validateProjectFields()) {
+ return false;
+ }
+
+ Intent intent = new Intent();
+ Bundle bundle = new Bundle();
+ bundle.putIntegerArrayList(PROJECT_IDS, mObservationProjects);
+ bundle.putSerializable(PROJECT_FIELDS, mProjectFieldValues);
+ intent.putExtras(bundle);
+
+ setResult(RESULT_OK, intent);
+ finish();
+```
+
+## 5. Observation field types and validation (`ProjectFieldViewer.java`)
+
+Datatype rendering (one widget shown per `data_type`, lines ~409-541). This is the canonical list of field types the RN form must support:
+
+- `text` without `allowed_values` → free-text EditText
+- `text` with `allowed_values` → Spinner/dropdown; values parsed by pipe-splitting
+- `numeric` → numeric-keyboard EditText; value must parse as float
+- `date` → date picker dialog; stored as `yyyy-MM-dd`
+- `time` → time picker; stored as 24h `HH:mm`
+- `datetime` → datetime picker dialog (`showDateTimeDialog`, lines 548-607); displayed `yyyy-MM-dd HH:mm`, stored as ISO8601
+- `taxon` → launches `TaxonSearchActivity` with extra `FIELD_ID` (request code 301); value stored as the taxon ID string; existing values resolved back to a taxon via service `ACTION_GET_TAXON`
+- any other datatype → rendered as nothing, `getValue()` returns null
+
+Allowed-values parsing (select fields):
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ProjectFieldViewer.java L409-L413
+if ((mField.data_type.equals("text")) && (mField.allowed_values != null) && (!mField.allowed_values.equals(""))) {
+ mSpinner.setVisibility(View.VISIBLE);
+ String[] allowedValues = mField.allowed_values.split("\\|");
+ mSpinnerAdapter = new ArrayAdapter(mContext, android.R.layout.simple_spinner_item, android.R.id.text1, allowedValues);
+```
+
+Taxon field launch:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ProjectFieldViewer.java L529-L534
+mTaxonContainer.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent(mContext, TaxonSearchActivity.class);
+ intent.putExtra(TaxonSearchActivity.FIELD_ID, mField.field_id);
+ mContext.startActivityForResult(intent, PROJECT_FIELD_TAXON_SEARCH_REQUEST_CODE);
+```
+
+Validation:
+
+- `isValid()`: required fields must be non-empty; numeric values must parse as float.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ProjectFieldViewer.java L272-L292
+public Boolean isValid() {
+ if (mField.is_required) {
+ String value = getValue();
+ if (value == null || value.equals("")) {
+ // Mandatory field
+ return false;
+ }
+ }
+
+ if ((mField.data_type.equals("numeric")) && (!mEditText.getText().toString().equals(""))) {
+ try {
+ float value = Float.valueOf(mEditText.getText().toString());
+ } catch (Exception exc) {
+ // Invalid number
+ return false;
+ }
+ }
+
+
+ return true;
+}
+```
+
+- `ProjectSelectorActivity.validateProjectFields()`: on confirm, validates all viewers of all checked projects; on failure shows toast `R.string.invalid_project_field` ("Please enter a valid value for field '%1s'") and blocks the save.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ProjectSelectorActivity.java L693-L715
+private boolean validateProjectFields() {
+ if (mIsConfirmation) {
+ HashMap> finalProjectFields = new HashMap>();
+ for (int projectId : mObservationProjects) {
+ finalProjectFields.put(projectId, mProjectFieldViewers.get(projectId));
+ }
+ for (int projectId : finalProjectFields.keySet()) {
+ List fields = finalProjectFields.get(projectId);
+ if (fields == null) break;
+ for (ProjectFieldViewer fieldViewer : fields) {
+ if (!fieldViewer.isValid()) {
+ Toast.makeText(this, String.format(getString(R.string.invalid_project_field), fieldViewer.getField().name), Toast.LENGTH_LONG).show();
+ return false;
+ }
+ }
+ }
+ mProjectFieldViewers = finalProjectFields;
+ }
+ return true;
+}
+```
+
+- Important behavior to know when porting: validation runs ONLY on the picker's confirm action. It does NOT run on checkbox toggle, on observation save, or before upload. Server-side rejections are handled post-hoc (section 6). The POD requires blocking upload client-side — stronger than Android's behavior. (Also note the `if (fields == null) break;` quirk above: it aborts validation of all remaining projects instead of skipping one.)
+
+## 6. Sync and API layer (`INaturalistServiceImplementation.java`)
+
+Hosts:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistService.java L428-L429
+public static String HOST = "https://www.inaturalist.org";
+public static String API_HOST = "https://api.inaturalist.org/v1";
+```
+
+### Endpoints
+
+- Join: `POST {API_HOST}/projects/{id}/join` (empty body), then `GET {API_HOST}/projects/{id}` to fetch `project_observation_fields`:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L4894-L4914
+public void joinProject(int projectId) throws AuthenticationException {
+ post(String.format(Locale.ENGLISH, "%s/projects/%d/join", API_HOST, projectId), (JSONObject) null);
+
+ try {
+ JSONArray result = get(String.format(Locale.ENGLISH, "%s/projects/%d", API_HOST, projectId));
+ if (result == null) return;
+ JSONArray results = result.getJSONObject(0).getJSONArray("results");
+ BetterJSONObject jsonProject = new BetterJSONObject(results.getJSONObject(0));
+ Project project = new Project(jsonProject);
+
+ Cursor c = mContext.getContentResolver().query(Project.CONTENT_URI, Project.PROJECTION, "id = ?", new String[]{String.valueOf(project.id)}, null);
+
+ if (c.getCount() == 0) {
+ // Add joined project locally
+ ContentValues cv = project.getContentValues();
+ mContext.getContentResolver().insert(Project.CONTENT_URI, cv);
+ }
+ c.close();
+
+ // Save project fields
+ addProjectFields(jsonProject.getJSONArray("project_observation_fields").getJSONArray(), jsonProject.getInt("id"));
+```
+
+- Leave: `DELETE {API_HOST}/projects/{id}/leave`:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L4921-L4926
+public void leaveProject(int projectId) throws AuthenticationException {
+ delete(String.format(Locale.ENGLISH, "%s/projects/%d/leave", API_HOST, projectId), null);
+
+ // Remove locally saved project (because we left it)
+ mContext.getContentResolver().delete(Project.CONTENT_URI, "(id IS NOT NULL) and (id = " + projectId + ")", null);
+}
+```
+
+- Add obs to project: `POST {API_HOST}/project_observations`:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L4955-L4968
+String url = API_HOST + "/project_observations";
+
+JSONObject params = new JSONObject();
+JSONObject projectObs = new JSONObject();
+try {
+ projectObs.put("observation_id", observationId);
+ projectObs.put("project_id", projectId);
+ params.put("project_observation", projectObs);
+} catch (JSONException e) {
+ e.printStackTrace();
+ return null;
+}
+
+JSONArray json = post(url, params);
+```
+
+- Remove obs from project: `DELETE {API_HOST}/project_observations/{id}` when the server id is known; legacy fallback on the Rails host otherwise:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L4934-L4937
+String url = projectObservationId != null ?
+ String.format(Locale.ENGLISH, "%s/project_observations/%d", API_HOST, projectObservationId) :
+ String.format(Locale.ENGLISH, "%s/projects/%d/remove.json?observation_id=%d", HOST, projectId, observationId);
+JSONArray json = request(url, "delete", null, null, true, true, false);
+```
+
+- Field values: `POST {API_HOST}/observation_field_values`. There is NO PUT/DELETE for field values anywhere; clearing a value never syncs.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L5435-L5446
+JSONObject params = new JSONObject();
+JSONObject obsFieldValue = new JSONObject();
+try {
+ obsFieldValue.put("observation_id", localField.observation_id);
+ obsFieldValue.put("observation_field_id", localField.field_id);
+ obsFieldValue.put("value", localField.value);
+
+ params.put("observation_field_value", obsFieldValue);
+} catch (JSONException e) {
+ e.printStackTrace();
+}
+JSONArray result = post(API_HOST + "/observation_field_values", params);
+```
+
+- Joined projects list: `GET {API_HOST}/users/{login}/projects?per_page=100&page=N`, paginated; each result's `project_observation_fields` is stored locally via `addProjectFields()` (delete-all-then-reinsert per project, lines 4832-4853):
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L5133-L5153 (abridged)
+do {
+ String url = API_HOST + "/users/" + Uri.encode(mLogin) + "/projects?per_page=100&page=" + page;
+ JSONArray json = get(url, true);
+ // ...
+ for (int i = 0; i < results.length(); i++) {
+ JSONObject project = results.getJSONObject(i);
+ project.put("joined", true);
+ finalJson.put(project);
+ addProjectFields(project.getJSONArray("project_observation_fields"), project.optInt("id"));
+ }
+} while (projectsDownloaded < totalResults);
+```
+
+- Standalone field metadata (for values referencing fields not in any joined project): `GET {HOST}/observation_fields/{id}.json` — `addProjectField()` lines 5694-5710.
+- User obs download includes project data via `extra=observation_photos,projects,fields` (`getUserObservations()` lines 5317-5347).
+
+### Offline queue processing (per-observation upload order)
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L2035-L2036
+syncObservationFields(observation);
+postProjectObservations(observation);
+```
+
+1. POST observation body
+2. photos/sounds
+3. `syncObservationFields(observation)` — uploads dirty field values, with last-writer-wins conflict resolution against remote OFVs (lines 5356-5514); sets `_synced_at` on success
+4. `postProjectObservations(observation)` — DELETEs rows with `is_deleted = 1` (then hard-deletes locally), POSTs rows with `is_new = 1` (then clears flag and stores server `id`). Skips entirely if the observation has no server id yet:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L2716-L2720
+private boolean postProjectObservations(Observation observation) throws AuthenticationException, CancelSyncException, SyncFailedException {
+ if (observation.id == null) {
+ // Observation not synced yet - cannot sync its project associations yet
+ return true;
+ }
+```
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L2759-L2791
+// Next, add new project observations
+c = mContext.getContentResolver().query(ProjectObservation.CONTENT_URI,
+ ProjectObservation.PROJECTION,
+ "is_new = 1 AND observation_id = ?",
+ new String[]{String.valueOf(observation.id)},
+ ProjectObservation.DEFAULT_SORT_ORDER);
+
+c.moveToFirst();
+while (c.isAfterLast() == false) {
+ checkForCancelSync();
+ ProjectObservation projectObservation = new ProjectObservation(c);
+ BetterJSONObject result = addObservationToProject(projectObservation.observation_id, projectObservation.project_id);
+
+ if ((result == null) && (mResponseErrors == null)) {
+ c.close();
+ throw new SyncFailedException();
+ }
+
+ increaseProgressForObservation(observation);
+
+ if (mResponseErrors != null) {
+ handleProjectFieldErrors(projectObservation.observation_id, projectObservation.project_id);
+ } else {
+ // Unmark as new
+ projectObservation.is_new = false;
+ // Save external ID
+ projectObservation.id = result.getInt("id");
+ ContentValues cv = projectObservation.getContentValues();
+ mContext.getContentResolver().update(projectObservation.getUri(), cv, null, null);
+
+ // Clean the errors for the observation
+ mApp.setErrorsForObservation(projectObservation.observation_id, projectObservation.project_id, new JSONArray());
+ }
+```
+
+Field values upload BEFORE project membership so required-field validation passes server-side. End of full sync: `saveJoinedProjects()` (wipe + re-insert `projects` table, lines 3010-3038) and `storeProjectObservations()` (insert-only reconciliation of downloaded memberships, lines 2968-2993).
+
+The queue-discovery query that decides which observations have pending project changes (handles both local and server observation IDs):
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L2188-L2191
+c = mContext.getContentResolver().query(ProjectObservation.CONTENT_URI,
+ ProjectObservation.PROJECTION,
+ "((is_deleted = 1) OR (is_new = 1)) AND " +
+ "((observation_id = ?) OR (observation_id = ?))",
+```
+
+Join/leave are NOT queued offline — they fire immediately from `ProjectDetails` and silently fail without network (no retry queue, no rollback of the optimistic UI).
+
+### Error handling (server rejects add-to-project)
+
+- A failed `POST /project_observations` or `/observation_field_values` with API `errors` is a soft failure: row stays `is_new = 1` (retried next sync), `handleProjectFieldErrors()` (lines 2901-2965) formats the error (strings `failed_to_add_to_project` / `failed_to_add_obs_to_project`), stores it per observation+project in SharedPreferences via `INaturalistApp.setErrorsForObservation()` (`INaturalistApp.java` lines 671-689), and shows a toast. See the `mResponseErrors != null` branch in the `postProjectObservations` citation above.
+- Stored errors surface in the editor (`ObservationEditor` ~line 1934), obs detail (`ObservationViewerFragment` ~2006), and obs list rows (`ObservationCursorAdapter` ~517).
+
+## 7. Join / leave flows (`ProjectDetails.java`)
+
+- Join: if project has `terms`, shows confirm dialog "Do you agree to the following?" with the raw terms text; on agree (or no terms) optimistically flips the button, fires `ACTION_JOIN_PROJECT`. Local effect on success: insert `projects` row + replace `project_fields` for that project. Requires login (redirects to onboarding otherwise).
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ProjectDetails.java L253-L269 (abridged)
+} else {
+ String terms = mProject.getString("terms");
+ if ((terms != null) && (terms.length() > 0)) {
+ mHelper.confirm(getString(R.string.do_you_agree_to_the_following), mProject.getString("terms"), new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialogInterface, int i) {
+ joinProject();
+ }
+ // ... cancel listener ...
+ }, R.string.yes, R.string.no);
+ } else {
+ joinProject();
+ }
+}
+```
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ProjectDetails.java L277-L289
+private void joinProject() {
+ if (!isLoggedIn()) {
+ // User not logged-in - redirect to onboarding screen
+ startActivity(new Intent(ProjectDetails.this, OnboardingActivity.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP));
+ return;
+ }
+
+ mJoinLeaveProject.setText(R.string.leave);
+ mProject.put("joined", true);
+
+ Intent serviceIntent = new Intent(INaturalistService.ACTION_JOIN_PROJECT, null, ProjectDetails.this, INaturalistService.class);
+ serviceIntent.putExtra(INaturalistService.PROJECT_ID, mProject.getInt("id"));
+ INaturalistService.callService(this, serviceIntent);
+```
+
+- Leave: single confirm dialog — title `leave_project` ("Leave Project"), message `leave_project_confirmation` ("Are you sure you want to leave this project?"), Yes/No. Fires `ACTION_LEAVE_PROJECT`. Local effect: deletes the `projects` row only — `project_fields`, `project_observations`, `project_field_values` are left in place.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/ProjectDetails.java L234-L244
+if ((isJoined != null) && (isJoined == true)) {
+ mHelper.confirm(getString(R.string.leave_project), getString(R.string.leave_project_confirmation),
+ new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int buttonId) {
+ // Leave the project
+ mJoinLeaveProject.setText(R.string.join);
+ mProject.put("joined", false);
+
+ Intent serviceIntent = new Intent(INaturalistService.ACTION_LEAVE_PROJECT, null, ProjectDetails.this, INaturalistService.class);
+ serviceIntent.putExtra(INaturalistService.PROJECT_ID, mProject.getInt("id"));
+ INaturalistService.callService(ProjectDetails.this, serviceIntent);
+```
+
+- Project browsing: `ProjectsActivity.java` hosts Joined/Nearby/Featured tabs (`BaseTab.java` does the loading); "joined" state for nearby/featured lists is computed by checking whether the project id exists in the local `projects` table. No project-type badge or joined indicator is shown in browse lists.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L4770-L4780
+// Determine which projects are already joined
+for (int i = 0; i < json.length(); i++) {
+ Cursor c;
+ try {
+ c = mContext.getContentResolver().query(Project.CONTENT_URI, Project.PROJECTION, "id = '" + json.getJSONObject(i).getInt("id") + "'", null, Project.DEFAULT_SORT_ORDER);
+ c.moveToFirst();
+ int count = c.getCount();
+ c.close();
+ if (count > 0) {
+ json.getJSONObject(i).put("joined", true);
+ }
+```
+
+- Read-only display of an observation's projects: `ObservationViewerFragment` "Included in N projects" row → `ObservationProjectsViewer.java` (list only; field values are never displayed on the obs detail screen).
+
+## 8. Gaps: what the Android app does NOT have (vs POD scope)
+
+These items are in the POD scope but have no Android reference implementation — they will need design/API research from web behavior instead:
+
+- Hidden-coordinate access permission at join time: `preferred_curator_coordinate_access` appears nowhere in this codebase. Join is a bare POST. (Web-only today, as the POD notes.)
+- Leave flow with "keep or remove my observations": Android shows only a generic yes/no confirmation; no observation-retention option and no related API param.
+- Client-side blocking of upload on unfilled required fields: Android only validates inside the picker's confirm action; the upload path itself never re-validates (server rejection is handled as a retryable soft error). The POD requires a hard pre-upload gate.
+- Deleting a field value remotely: no DELETE for `observation_field_values`; clearing a value locally never propagates.
+- Field-value map keyed by `field_id` only — the same observation field shared by two selected projects collides (a known Android quirk to avoid reproducing).
+
+## 9. Porting checklist (behavioral spec for RN, no engineering yet)
+
+- Persist locally: joined projects (with `project_type`), per-project field definitions (`field_id`, `data_type`, `allowed_values`, `is_required`, `position`), obs-project links with pending add/remove state, and field values with dirty tracking — all must survive offline.
+- Only allow manual add for traditional projects (project_type not collection/umbrella); show collection/umbrella membership read-only.
+- Field form supports: free text, select (pipe-separated `allowed_values`), numeric, date, time, datetime, taxon (taxon picker, value = taxon id as string).
+- Validate required + numeric fields before letting the user confirm project selection AND before upload (stricter than Android).
+- Upload ordering: observation first, then field values, then project_observations; handle local-id → server-id remapping for queued records.
+- Server validation errors on add-to-project: keep the pending record, surface a per-observation/per-project error, retry on next sync.
+- Join: POST join → fetch project → cache `project_observation_fields` locally (fields must be available offline for the form). Leave: DELETE leave → remove local project (+ decide cleanup policy for orphaned local data, which Android gets wrong).
+- Refresh joined-projects + field definitions on every full sync via `GET /users/{login}/projects` (paginated).
+
+## 10. Offline behavior in detail
+
+The core "add observation to project" flow works offline; join/leave does not.
+
+### Works offline
+
+- Selecting projects and filling fields: the picker reads joined projects from the local `projects` table, not the network — `ACTION_GET_JOINED_PROJECTS` resolves to `getJoinedProjectsOffline()`. Field definitions (datatype, required flag, allowed values) are cached in `project_fields` at join/sync time, so the field form renders offline.
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L5097-L5102
+private SerializableJSONArray getJoinedProjectsOffline() {
+ JSONArray projects = new JSONArray();
+ Cursor c = mContext.getContentResolver().query(Project.CONTENT_URI, Project.PROJECTION, null, null, Project.DEFAULT_SORT_ORDER);
+
+ c.moveToFirst();
+ int count = c.getCount();
+```
+
+- Queuing changes (no network call on save):
+ - Project memberships → `project_observations` rows with `is_new = 1` (add) or `is_deleted = 1` (remove), written in `ObservationEditor.saveProjects()` (lines 2729-2790).
+ - Field values → `project_field_values` rows marked dirty via `_updated_at > _synced_at`, written in `saveProjectFields()` (lines 2707-2726).
+- Sync later: on the next sync the queue is flushed per observation — `syncObservationFields(observation)` then `postProjectObservations(observation)` (lines 2035-2036). If the observation itself hasn't been uploaded yet, the queued rows reference its local `_id`; once the obs gets a server ID, `ObservationProvider` rewrites `observation_id` in both tables (lines 586-596).
+
+### Does NOT work offline
+
+- Joining/leaving a project: `ProjectDetails` fires `ACTION_JOIN_PROJECT` / `ACTION_LEAVE_PROJECT` immediately; on failure there is no retry queue, and the optimistically flipped button is never rolled back. You cannot join a new project offline — which also means its field definitions never get cached.
+- Taxon-type fields: the taxon picker (`TaxonSearchActivity`) and resolving an existing taxon-ID value back to a name (`ACTION_GET_TAXON`) both require network.
+- Clearing a field value: never syncs at all (online or offline) — there is no DELETE for observation field values in the codebase.
+
+The POD requirement "observations can be added to projects while offline" matches Android's behavior only for projects joined while online — the local caching of projects + field definitions is what makes it possible and is the pattern the RN port must replicate.
+
+## 11. Upload-time reconciliation
+
+Two different things can change server-side between the user filling the form and the upload: field values and field definitions. They are handled very differently.
+
+### Field values: last-writer-wins reconciliation
+
+`syncObservationFields(observation)` (`INaturalistServiceImplementation.java` lines 5356-5514) does real conflict resolution before pushing. For each dirty local value it fetches the observation's remote `observation_field_values` via `GET /observations/{id}` (lines 5382-5409), then decides direction:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L5422-L5431
+if (!fields.containsKey(Integer.valueOf(localField.field_id))) {
+ // No remote field - add it
+ shouldOverwriteRemote = true;
+} else {
+ remoteField = fields.get(Integer.valueOf(localField.field_id));
+
+ if ((remoteField.updated_at != null) && (remoteField.updated_at.before(localField._updated_at))) {
+ shouldOverwriteRemote = true;
+ }
+}
+```
+
+- Remote field missing, or remote `updated_at` older than local `_updated_at` → POST the local value (lines 5433-5446, see the OFV POST citation in section 6).
+- Remote newer → overwrite local with the remote value, no API call:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L5467-L5475
+} else {
+ // Overwrite local value
+ localField.created_at = remoteField.created_at;
+ localField.id = remoteField.id;
+ localField.observation_id = remoteField.observation_id;
+ localField.field_id = remoteField.field_id;
+ localField.value = remoteField.value;
+ localField.updated_at = remoteField.updated_at;
+}
+```
+
+- Remote values the device has never seen are inserted locally afterwards (lines 5488-5512); unknown field metadata is fetched via `addProjectField()`.
+
+Caveat: the comparison is server timestamp vs. device clock (`remoteField.updated_at.before(localField._updated_at)`), so it is clock-skew sensitive.
+
+### Field definitions: no reconciliation at upload time
+
+If `project_observation_fields` changed server-side (new required field, changed `allowed_values`, removed field), the upload proceeds against the stale cached `project_fields` definitions. There is no schema re-fetch and no client-side re-validation before upload — validation only ever ran in the picker UI. Definitions are refreshed only after all uploads, at the tail of the sync:
+
+```java
+// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L2056-L2060
+if (mApp.loggedIn() && mIsSyncing) {
+ // Update observation comments/IDs for the observations
+ storeProjectObservations();
+ saveJoinedProjects();
+}
+```
+
+Resulting failure modes:
+
+- New required field added since the form was filled → `POST /project_observations` rejected server-side; soft failure: row keeps `is_new = 1` (retried every sync), `handleProjectFieldErrors()` (lines 2901-2965) stores the error per observation+project and shows a toast. The user must re-open the editor (definitions cache is fresh by then), fill the new field, and sync again.
+- `allowed_values` changed → stale value POSTed as-is; server accepts or rejects (same soft-failure path).
+- Field removed from project → the orphaned local value still POSTs to `/observation_field_values` and generally succeeds, since observation field values are not project-scoped server-side.
+
+Server is the validator of record for schema drift; the client's only "reconciliation" is retry-with-error-surface. For the RN port, the POD's pre-upload validation requirement means validating against a potentially stale schema — decide whether to re-fetch `project_observation_fields` at upload time or accept Android's eventual-consistency behavior.
diff --git a/docs/traditional-projects-porting-reference_iOS.md b/docs/traditional-projects-porting-reference_iOS.md
new file mode 100644
index 000000000..5f173bcec
--- /dev/null
+++ b/docs/traditional-projects-porting-reference_iOS.md
@@ -0,0 +1,2163 @@
+# Traditional Projects — iOS Feature Analysis (Porting Reference)
+
+Audit deliverable for the Traditional Project Support POD (Phase 1/2): where the "add observation to a Traditional Project" feature lives in the classic Objective-C iOS app (`INaturalistIOS`), with verbatim code citations so this document is readable without the iOS repo checked out.
+
+All file paths are relative to the `INaturalistIOS` repository root. Citation blocks are formatted as `startLine:endLine:path` and were verified against the source at the time of writing.
+
+Scope note: this document covers the **iOS** classic app only. The POD's Phase 1 audit also calls for classic Android and web flows (including the web-only hidden-coordinates join permission), which must be audited in those codebases separately. Release mechanics like feature flags have no precedent in this app and are likewise out of scope here.
+
+---
+
+## 1. Architecture at a glance
+
+The feature spans four layers. All live data is **Realm** (Core Data models are legacy migration sources only); API JSON is parsed via transient **Mantle** models; all network traffic goes through the **Node API** (`https://api.inaturalist.org/v1`).
+
+```mermaid
+flowchart TD
+ ObsEdit[ObsEditV2ViewController - Projects row] --> Chooser[ProjectObservationsViewController - Choose Projects]
+ Chooser -->|toggle ON| PO[ExploreProjectObservationRealm plus default OFVs]
+ Chooser -->|field tap| Inputs[Per-type input UIs]
+ Inputs --> OFV[ExploreObsFieldValueRealm]
+ ObsEdit -->|validatedSave| RealmDB[(Realm)]
+ RealmDB --> UploadMgr[UploadManager / UploadObservationOperation]
+ UploadMgr -->|"POST /v1/project_observations"| API[Node API]
+ UploadMgr -->|"POST /v1/observation_field_values"| API
+ ProjectsTab[ProjectsViewController] --> Detail[ProjectDetailV2ViewController join and leave]
+ Detail -->|"POST join / DELETE leave"| API
+ API --> User[ExploreUserRealm.joinedProjects]
+ User --> Chooser
+```
+
+Entity relationships:
+
+```mermaid
+flowchart TD
+ UserRealm[ExploreUserRealm PK userId] -->|joinedProjects| ProjectRealm[ExploreProjectRealm PK projectId]
+ ProjectRealm -->|projectObsFields| POF[ExploreProjectObsFieldRealm PK projectObsFieldId]
+ POF -->|obsField| ObsField[ExploreObsFieldRealm PK obsFieldId]
+ ObsRealm[ExploreObservationRealm PK uuid] -->|projectObservations| PORealm[ExploreProjectObservationRealm PK uuid]
+ PORealm -->|project| ProjectRealm
+ ObsRealm -->|observationFieldValues| OFVRealm[ExploreObsFieldValueRealm PK uuid]
+ OFVRealm -->|obsField| ObsField
+```
+
+---
+
+## 2. Data models
+
+### 2.1 Project type detection (traditional vs collection/umbrella)
+
+The project type enum:
+
+```12:16:INaturalistIOS/Models/ViewProtocols/ProjectVisualization.h
+typedef NS_ENUM(NSInteger, ExploreProjectType) {
+ ExploreProjectTypeCollection,
+ ExploreProjectTypeUmbrella,
+ ExploreProjectTypeOldStyle
+};
+```
+
+`OldStyle` = Traditional. The API's `project_type` string maps to the enum; an empty string maps to OldStyle:
+
+```44:52:INaturalistIOS/Models/Mantle/ExploreProject.m
++ (NSValueTransformer *)typeJSONTransformer {
+ NSDictionary *typeMappings = @{
+ @"collection": @(ExploreProjectTypeCollection),
+ @"umbrella": @(ExploreProjectTypeUmbrella),
+ @"": @(ExploreProjectTypeOldStyle),
+ };
+
+ return [NSValueTransformer mtl_valueMappingTransformerWithDictionary:typeMappings];
+}
+```
+
+A missing/nil `project_type` also defaults to OldStyle:
+
+```55:67:INaturalistIOS/Models/Mantle/ExploreProject.m
+- (void)setNilValueForKey:(NSString *)key {
+ if ([key isEqualToString:@"locationId"]) {
+ self.locationId = 0;
+ } else if ([key isEqualToString:@"latitude"]) {
+ self.latitude = kCLLocationCoordinate2DInvalid.latitude;
+ } else if ([key isEqualToString:@"longitude"]) {
+ self.longitude = kCLLocationCoordinate2DInvalid.longitude;
+ } else if ([key isEqualToString:@"type"]) {
+ self.type = ExploreProjectTypeOldStyle;
+ } else {
+ [super setNilValueForKey:key];
+ }
+}
+```
+
+The single check that gates all traditional-only UI, plus the user-facing type labels:
+
+```153:165:INaturalistIOS/Models/Realm/ExploreProjectRealm.m
+- (BOOL)isNewStyleProject {
+ return self.type == ExploreProjectTypeUmbrella || self.type == ExploreProjectTypeCollection;
+}
+
+- (NSString *)titleForTypeOfProject {
+ if (self.type == ExploreProjectTypeCollection) {
+ return NSLocalizedString(@"Collection Project", @"Collection type of project, which automatically collects observations into it.");
+ } else if (self.type == ExploreProjectTypeUmbrella) {
+ return NSLocalizedString(@"Umbrella Project", @"Umbrella type of project, which contains other projects within it.");
+ } else {
+ return NSLocalizedString(@"Traditional Project", @"Traditional inat type of project, where users have to manually add observations to the project.");
+ }
+}
+```
+
+Traditional = `!isNewStyleProject`.
+
+### 2.2 Observation field datatypes (the "7 types")
+
+```11:19:INaturalistIOS/Models/Mantle/ExploreObsField.h
+typedef NS_ENUM(NSInteger, ExploreObsFieldDataType) {
+ ExploreObsFieldDataTypeText,
+ ExploreObsFieldDataTypeNumeric,
+ ExploreObsFieldDataTypeDate,
+ ExploreObsFieldDataTypeTime,
+ ExploreObsFieldDataTypeDateTime,
+ ExploreObsFieldDataTypeTaxon,
+ ExploreObsFieldDataTypeDna
+};
+```
+
+API `datatype` string mapping:
+
+```29:41:INaturalistIOS/Models/Mantle/ExploreObsField.m
++ (NSValueTransformer *)dataTypeJSONTransformer {
+ NSDictionary *typeMappings = @{
+ @"text": @(ExploreObsFieldDataTypeText),
+ @"numeric": @(ExploreObsFieldDataTypeNumeric),
+ @"date": @(ExploreObsFieldDataTypeDate),
+ @"time": @(ExploreObsFieldDataTypeTime),
+ @"datetime": @(ExploreObsFieldDataTypeDateTime),
+ @"taxon": @(ExploreObsFieldDataTypeTaxon),
+ @"dna": @(ExploreObsFieldDataTypeDna),
+ };
+
+ return [NSValueTransformer mtl_valueMappingTransformerWithDictionary:typeMappings];
+}
+```
+
+**There is no "select" datatype.** Select fields are inferred at runtime: text/dna fields with more than one allowed value (see section 3.4). The API sends `allowed_values` as a pipe-delimited string, split on `|`:
+
+```23:27:INaturalistIOS/Models/Mantle/ExploreObsField.m
++ (NSValueTransformer *)allowedValuesJSONTransformer {
+ return [MTLValueTransformer transformerWithBlock:^id(NSString *allowedValues) {
+ return [allowedValues componentsSeparatedByString:@"|"];
+ }];
+}
+```
+
+The text-or-dna helper used everywhere:
+
+```80:82:INaturalistIOS/Models/Realm/ExploreObsFieldRealm.m
+- (BOOL)canBeTreatedAsText {
+ return self.dataType == ExploreObsFieldDataTypeText || self.dataType == ExploreObsFieldDataTypeDna;
+}
+```
+
+### 2.3 Mantle models (API JSON parsing)
+
+`ExploreProject` JSON key mappings — note `project_observation_fields` is included in project payloads, which is what enables offline field definitions:
+
+```16:30:INaturalistIOS/Models/Mantle/ExploreProject.m
++ (NSDictionary *)JSONKeyPathsByPropertyKey{
+ return @{
+ @"title": @"title",
+ @"projectId": @"id",
+ @"locationId": @"place_id",
+ @"latitude": @"latitude",
+ @"longitude": @"longitude",
+ @"iconUrl": @"icon",
+ @"type": @"project_type",
+ @"bannerColorString": @"banner_color",
+ @"bannerImageUrl": @"header_image_url",
+ @"inatDescription": @"description",
+ @"projectObsFields": @"project_observation_fields",
+ };
+}
+```
+
+Note: Mantle instances always report `joined == NO`; joined state is Realm-only:
+
+```69:71:INaturalistIOS/Models/Mantle/ExploreProject.m
+- (BOOL)joined {
+ return NO;
+}
+```
+
+`ExploreProjectObsField` (the per-project field config carrying `required` and `position`):
+
+```13:22:INaturalistIOS/Models/Mantle/ExploreProjectObsField.h
+@interface ExploreProjectObsField : MTLModel
+
+@property (nonatomic, assign) BOOL required;
+@property (nonatomic, assign) NSInteger position;
+@property (nonatomic, assign) NSInteger projectObsFieldId;
+@property (nonatomic) ExploreObsField *obsField;
+
+@end
+```
+
+```15:30:INaturalistIOS/Models/Mantle/ExploreProjectObsField.m
++ (NSDictionary *)JSONKeyPathsByPropertyKey{
+ return @{
+ @"required": @"required",
+ @"position": @"position",
+ @"projectObsFieldId": @"id",
+ @"obsField": @"observation_field",
+ };
+}
+
+- (void)setNilValueForKey:(NSString *)key {
+ if ([key isEqualToString:@"required"]) {
+ self.required = FALSE;
+ } else if ([key isEqualToString:@"position"]) {
+ self.position = 0;
+ }
+}
+```
+
+`ExploreObsField`:
+
+```13:21:INaturalistIOS/Models/Mantle/ExploreObsField.m
++ (NSDictionary *)JSONKeyPathsByPropertyKey{
+ return @{
+ @"allowedValues": @"allowed_values",
+ @"name": @"name",
+ @"inatDescription": @"description",
+ @"obsFieldId": @"id",
+ @"dataType": @"datatype",
+ };
+}
+```
+
+`ExploreProjectObservation` (the observation-to-project join record as fetched from the server):
+
+```13:19:INaturalistIOS/Models/Mantle/ExploreProjectObservation.m
++ (NSDictionary *)JSONKeyPathsByPropertyKey{
+ return @{
+ @"projectObsId": @"id",
+ @"uuid": @"uuid",
+ @"project": @"project",
+ };
+}
+```
+
+`ExploreObsFieldValue`:
+
+```14:21:INaturalistIOS/Models/Mantle/ExploreObsFieldValue.m
++ (NSDictionary *)JSONKeyPathsByPropertyKey{
+ return @{
+ @"obsFieldValueId": @"id",
+ @"value": @"value",
+ @"obsField": @"observation_field",
+ @"uuid": @"uuid",
+ };
+}
+```
+
+On fetched observations, the relevant nested JSON keys are `project_observations` and `ofvs` (excerpt of the larger mapping):
+
+```52:66:INaturalistIOS/Models/Mantle/ExploreObservation.m
+ @"user": @"user",
+ @"observationPhotos": @"observation_photos",
+ @"observationSounds": @"observation_sounds",
+ @"comments": @"comments",
+ @"identifications": @"identifications",
+ @"faves": @"faves",
+ @"projectObservations": @"project_observations",
+ @"taxon": @"taxon",
+ @"dataQuality": @"quality_grade",
+ @"uuid": @"uuid",
+ @"captive": @"captive",
+ @"geoprivacy": @"geoprivacy",
+ @"ownersIdentificationFromVision": @"owners_identification_from_vision",
+ @"observationFieldValues": @"ofvs",
+ };
+```
+
+```136:142:INaturalistIOS/Models/Mantle/ExploreObservation.m
++ (NSValueTransformer *)projectObservationsJSONTransformer {
+ return [NSValueTransformer mtl_JSONArrayTransformerWithModelClass:ExploreProjectObservation.class];
+}
+
++ (NSValueTransformer *)observationFieldValuesJSONTransformer {
+ return [NSValueTransformer mtl_JSONArrayTransformerWithModelClass:ExploreObsFieldValue.class];
+}
+```
+
+### 2.4 Realm models (source of truth)
+
+`ExploreProjectRealm` — PK `projectId`:
+
+```15:41:INaturalistIOS/Models/Realm/ExploreProjectRealm.h
+@interface ExploreProjectRealm : RLMObject
+
+@property NSString *title;
+@property NSInteger projectId;
+@property NSInteger locationId;
+@property CLLocationDegrees latitude;
+@property CLLocationDegrees longitude;
+@property NSString *iconUrlString;
+@property NSString *bannerImageUrlString;
+@property NSString *bannerColorString;
+@property ExploreProjectType type;
+@property NSString *inatDescription;
+
+- (BOOL)isNewStyleProject;
+
+- (instancetype)initWithMantleModel:(ExploreProject *)model;
+
+// to-many relationships
+@property RLMArray *projectObsFields;
+
++ (NSDictionary *)valueForMantleModel:(ExploreProject *)model;
++ (NSDictionary *)valueForCoreDataModel:(id)model;
+
+- (NSString *)titleForTypeOfProject;
+
+
+@end
+```
+
+```120:122:INaturalistIOS/Models/Realm/ExploreProjectRealm.m
++ (NSString *)primaryKey {
+ return @"projectId";
+}
+```
+
+Fields are displayed sorted by `position`:
+
+```146:151:INaturalistIOS/Models/Realm/ExploreProjectRealm.m
+- (NSArray *)sortedProjectObservationFields {
+ RLMSortDescriptor *positionSort = [RLMSortDescriptor sortDescriptorWithKeyPath:@"position" ascending:YES];
+ RLMResults *sortedResults = [self.projectObsFields sortedResultsUsingDescriptors:@[ positionSort ]];
+ // convert to NSArray
+ return [sortedResults valueForKey:@"self"];
+}
+```
+
+`ExploreProjectObsFieldRealm` — PK `projectObsFieldId`, carries the per-project `required` flag, with an inverse link back to its project:
+
+```16:29:INaturalistIOS/Models/Realm/ExploreProjectObsFieldRealm.h
+@interface ExploreProjectObsFieldRealm : RLMObject
+
+@property BOOL required;
+@property NSInteger position;
+@property NSInteger projectObsFieldId;
+@property ExploreObsFieldRealm *obsField;
+
+@property (readonly) ExploreProjectRealm *project;
+
+- (instancetype)initWithMantleModel:(ExploreProjectObsField *)model;
++ (NSDictionary *)valueForMantleModel:(ExploreProjectObsField *)model;
++ (NSDictionary *)valueForCoreDataModel:(id)model;
+
+@end
+```
+
+```74:88:INaturalistIOS/Models/Realm/ExploreProjectObsFieldRealm.m
++ (NSString *)primaryKey {
+ return @"projectObsFieldId";
+}
+
++ (NSDictionary *)linkingObjectsProperties {
+ return @{
+ @"projects": [RLMPropertyDescriptor descriptorWithClass:ExploreProjectRealm.class
+ propertyName:@"projectObsFields"],
+ };
+}
+
+- (ExploreProjectRealm *)project {
+ // should only be one project attached to this linking object property
+ return [self.projects firstObject];
+}
+```
+
+`ExploreObsFieldRealm` — PK `obsFieldId`:
+
+```12:26:INaturalistIOS/Models/Realm/ExploreObsFieldRealm.h
+@interface ExploreObsFieldRealm : RLMObject
+
+@property RLMArray *allowedValues;
+@property NSString *name;
+@property NSString *inatDescription;
+@property NSInteger obsFieldId;
+@property ExploreObsFieldDataType dataType;
+
+- (instancetype)initWithMantleModel:(ExploreObsField *)model;
++ (NSDictionary *)valueForMantleModel:(ExploreObsField *)model;
++ (NSDictionary *)valueForCoreDataModel:(id)model;
+
+- (BOOL)canBeTreatedAsText;
+
+@end
+```
+
+`ExploreProjectObservationRealm` — PK is a **client-generated `uuid`**; `projectObsId` is the server id (0 until uploaded):
+
+```17:31:INaturalistIOS/Models/Realm/ExploreProjectObservationRealm.h
+@interface ExploreProjectObservationRealm : RLMObject
+
+@property NSInteger projectObsId;
+@property NSString *uuid;
+@property ExploreProjectRealm *project;
+
+@property NSDate *timeSynced;
+@property NSDate *timeUpdatedLocally;
+
+@property (readonly) ExploreObservationRealm *observation;
+
++ (NSDictionary *)valueForMantleModel:(ExploreProjectObservation *)model;
++ (NSDictionary *)valueForCoreDataModel:(id)model;
+
+@end
+```
+
+```88:102:INaturalistIOS/Models/Realm/ExploreProjectObservationRealm.m
++ (NSString *)primaryKey {
+ return @"uuid";
+}
+
++ (NSDictionary *)linkingObjectsProperties {
+ return @{
+ @"observations": [RLMPropertyDescriptor descriptorWithClass:ExploreObservationRealm.class
+ propertyName:@"projectObservations"],
+ };
+}
+
+- (ExploreObservationRealm *)observation {
+ // should only be one observation attached to this linking object property
+ return [self.observations firstObject];
+}
+```
+
+`ExploreObsFieldValueRealm` — PK client `uuid`; `value` is **always a string** (taxon ids stored as strings):
+
+```17:32:INaturalistIOS/Models/Realm/ExploreObsFieldValueRealm.h
+@interface ExploreObsFieldValueRealm : RLMObject
+
+@property NSInteger obsFieldValueId;
+@property NSString *value;
+@property NSString *uuid;
+@property ExploreObsFieldRealm *obsField;
+
+@property NSDate *timeSynced;
+@property NSDate *timeUpdatedLocally;
+
+@property (readonly) ExploreObservationRealm *observation;
+
++ (NSDictionary *)valueForMantleModel:(ExploreObsFieldValue *)model;
++ (NSDictionary *)valueForCoreDataModel:(id)model;
+
+@end
+```
+
+```99:113:INaturalistIOS/Models/Realm/ExploreObsFieldValueRealm.m
++ (NSString *)primaryKey {
+ return @"uuid";
+}
+
++ (NSDictionary *)linkingObjectsProperties {
+ return @{
+ @"observations": [RLMPropertyDescriptor descriptorWithClass:ExploreObservationRealm.class
+ propertyName:@"observationFieldValues"],
+ };
+}
+
+- (ExploreObservationRealm *)observation {
+ // should only be one observation attached to this linking object property
+ return [self.observations firstObject];
+}
+```
+
+`ExploreObservationRealm` holds the to-many arrays plus `validationErrorMsg` (the upload-failure flag):
+
+```58:69:INaturalistIOS/Models/Realm/ExploreObservationRealm.h
+// to-many relationships
+@property RLMArray *observationPhotos;
+@property RLMArray *observationSounds;
+@property RLMArray *comments;
+@property RLMArray *identifications;
+@property RLMArray *faves;
+@property RLMArray *observationFieldValues;
+@property RLMArray *projectObservations;
+
+@property (readonly) NSArray *observationMedia;
+
+@property NSString *validationErrorMsg;
+```
+
+OFV lookup by field, used by all the field UI:
+
+```402:410:INaturalistIOS/Models/Realm/ExploreObservationRealm.m
+- (ExploreObsFieldValueRealm *)valueForObsField:(ExploreObsFieldRealm *)field {
+ for (ExploreObsFieldValueRealm *ofv in self.observationFieldValues) {
+ if (ofv.obsField.obsFieldId == field.obsFieldId) {
+ return ofv;
+ }
+ }
+
+ return nil;
+}
+```
+
+Cascade delete of project links and field values when an observation is deleted (excerpt):
+
+```729:744:INaturalistIOS/Models/Realm/ExploreObservationRealm.m
+ // the server will cascade delete these for us
+ // so just cascade the local stuff
+ [realm deleteObjects:observation.observationPhotos];
+ [realm deleteObjects:observation.observationSounds];
+ [realm deleteObjects:observation.projectObservations];
+ [realm deleteObjects:observation.observationFieldValues];
+ [realm deleteObjects:observation.comments];
+ [realm deleteObjects:observation.identifications];
+
+ // create a deleted record for the observation
+ ExploreDeletedRecord *dr = [observation deletedRecordForModel];
+ [realm addOrUpdateObject:dr];
+
+ // delete the observation
+ [realm deleteObject:observation];
+ [realm commitWriteTransaction];
+```
+
+### 2.5 Joined-projects persistence
+
+Membership is a user-to-projects list — there is **no** `joined` flag on the project model:
+
+```28:29:INaturalistIOS/Models/Realm/ExploreUserRealm.h
+@property RLMArray *joinedProjects;
+- (BOOL)hasJoinedProjectWithId:(NSInteger)projectId;
+```
+
+```111:116:INaturalistIOS/Models/Realm/ExploreUserRealm.m
+- (BOOL)hasJoinedProjectWithId:(NSInteger)projectId {
+ for (ExploreProjectRealm *project in self.joinedProjects) {
+ if (project.projectId == projectId) { return YES; }
+ }
+ return NO;
+}
+```
+
+### 2.6 Legacy Core Data
+
+`Project`, `ProjectObservation`, `ProjectObservationField`, `ObservationField`, `ObservationFieldValue`, `ProjectUser` under `INaturalistIOS/Models/CoreData/` are migration sources only. Each Realm class has a `valueForCoreDataModel:` (e.g. `ExploreProjectRealm.m` lines 41–103) converting string project types and pipe-delimited allowed values. Not relevant to the RN port beyond confirming the active store is Realm.
+
+---
+
+## 3. Add-to-project flow in observation edit
+
+### 3.1 Entry point: the Projects row in obs edit
+
+The obs edit table sections:
+
+```50:55:INaturalistIOS/Controllers/Observations/Observation Details/ObsEditV2ViewController.m
+typedef NS_ENUM(NSInteger, ConfirmObsSection) {
+ ConfirmObsSectionPhotos = 0,
+ ConfirmObsSectionIdentify,
+ ConfirmObsSectionNotes,
+ ConfirmObsSectionDelete,
+};
+```
+
+The Projects row is Notes section, item 5. Cell builder (shows count of attached projects):
+
+```1701:1716:INaturalistIOS/Controllers/Observations/Observation Details/ObsEditV2ViewController.m
+- (UITableViewCell *)projectsCellInTableView:(UITableView *)tableView {
+ DisclosureCell *cell = [tableView dequeueReusableCellWithIdentifier:@"disclosure"];
+
+ cell.titleLabel.text = [self projectsTitle];
+ FAKIcon *project = [FAKIonIcons iosBriefcaseOutlineIconWithSize:44];
+ [project addAttribute:NSForegroundColorAttributeName value:[UIColor colorWithHexString:@"#777777"]];
+ cell.cellImageView.image = [project imageWithSize:CGSizeMake(44, 44)];
+
+ if (self.standaloneObservation.projectObservations.count > 0) {
+ cell.secondaryLabel.text = [NSString stringWithFormat:@"%ld", (unsigned long)self.standaloneObservation.projectObservations.count];
+ }
+
+ cell.selectionStyle = UITableViewCellSelectionStyleNone;
+ cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
+ return cell;
+}
+```
+
+```1744:1746:INaturalistIOS/Controllers/Observations/Observation Details/ObsEditV2ViewController.m
+- (NSString *)projectsTitle {
+ return NSLocalizedString(@"Projects", @"choose projects button title.");
+}
+```
+
+Tap handler — requires login, then pushes the chooser with the in-flight observation and itself as delegate:
+
+```1419:1435:INaturalistIOS/Controllers/Observations/Observation Details/ObsEditV2ViewController.m
+ } else if (indexPath.item == 5) {
+ INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
+ if (appDelegate.loginController.isLoggedIn) {
+ UIStoryboard *sb = [UIStoryboard storyboardWithName:@"MainStoryboard" bundle:[NSBundle mainBundle]];
+ ProjectObservationsViewController *vc = [sb instantiateViewControllerWithIdentifier:@"projectObservationsVC"];
+ vc.observation = self.standaloneObservation;
+ vc.delegate = self;
+ [self.navigationController pushViewController:vc animated:YES];
+ } else {
+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"You must be logged in!", nil)
+ message:NSLocalizedString(@"You must be logged in to access projects.", nil)
+ preferredStyle:UIAlertControllerStyleAlert];
+ [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK",nil)
+ style:UIAlertActionStyleCancel
+ handler:nil]];
+ [self presentViewController:alert animated:YES completion:nil];
+ }
+```
+
+### 3.2 The chooser: `ProjectObservationsViewController`
+
+Public interface and the delegate protocol used to stage removals back in obs edit:
+
+```14:26:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.h
+@protocol ProjectObservationsViewControllerDelegate
+@optional
+- (void)projectObsDelegateDeletedProjectObservation:(ExploreProjectObservationRealm *)po;
+- (void)projectObsDelegateDeletedObsFieldValue:(ExploreObsFieldValueRealm *)ofv;
+@end
+
+
+@interface ProjectObservationsViewController : UITableViewController
+
+@property ExploreObservationRealm *observation;
+@property (weak, nonatomic) id delegate;
+
+@end
+```
+
+`viewDidLoad` reads joined projects from Realm and only re-syncs when the network is reachable (this is the offline-aware path):
+
+```86:141:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (void)viewDidLoad {
+ [super viewDidLoad];
+
+ NSArray *sorts = @[
+ [RLMSortDescriptor sortDescriptorWithKeyPath:@"type" ascending:NO],
+ [RLMSortDescriptor sortDescriptorWithKeyPath:@"title" ascending:YES],
+ ];
+
+ INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
+ if (appDelegate.loginController.isLoggedIn) {
+ ExploreUserRealm *meUser = appDelegate.loginController.meUserLocal;
+ if (meUser) {
+ self.joinedProjects = [[meUser joinedProjects] sortedResultsUsingDescriptors:sorts];
+ __weak typeof(self)weakSelf = self;
+ self.joinedToken = [self.joinedProjects addNotificationBlock:^(RLMResults * _Nullable results, RLMCollectionChange * _Nullable change, NSError * _Nullable error) {
+ [weakSelf.tableView reloadData];
+ }];
+ }
+ }
+
+ self.title = NSLocalizedString(@"Choose Projects", @"title for project observations chooser");
+
+ self.tableView.tableHeaderView = ({
+ InsetLabel *label = [InsetLabel new];
+ label.insets = UIEdgeInsetsMake(10, 10, 10, 10);
+ label.text = NSLocalizedString(@"Please note: Observations will be automatically included in a collection project if they meet its requirements.",
+ @"helpful note about observations and collection projects on the screen where you can add observations to projects.");
+ label.numberOfLines = 0;
+ label.backgroundColor = [[UIColor lightGrayColor] colorWithAlphaComponent:0.2];
+ label;
+ });
+ [self.tableView.tableHeaderView sizeToFit];
+
+ self.tableView.backgroundColor = [UIColor whiteColor];
+ self.tableView.estimatedRowHeight = 44.0f;
+ self.tableView.rowHeight = UITableViewAutomaticDimension;
+ [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"cell"];
+ [self.tableView registerClass:[ObsFieldSimpleValueCell class] forCellReuseIdentifier:SimpleFieldIdentifier];
+ [self.tableView registerClass:[ObsFieldLongTextValueCell class] forCellReuseIdentifier:LongTextFieldIdentifier];
+
+ if ([[INatReachability sharedClient] isNetworkReachable]) {
+ INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
+ if ([appDelegate.loginController isLoggedIn]) {
+ ExploreUserRealm *me = [appDelegate.loginController meUserLocal];
+ // start by clearing all joined projects
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ [realm beginWriteTransaction];
+ [me.joinedProjects removeAllObjects];
+ [realm commitWriteTransaction];
+
+ // sync first page, that will trigger page 2 if
+ // necessary and so on
+ [self syncUserProjectsUserId:me.userId page:1];
+ }
+ }
+}
+```
+
+The paginated joined-projects sync (each result is upserted by PK, which refreshes the project's `projectObsFields` to latest server state):
+
+```59:84:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (void)syncUserProjectsUserId:(NSInteger)userId page:(NSInteger)page {
+
+ __weak typeof(self)weakSelf = self;
+ [[self projectsApi] projectsForUser:userId page:page handler:^(NSArray *results, NSInteger totalCount, NSError *error) {
+ ExploreUserRealm *meUser = [ExploreUserRealm objectForPrimaryKey:@(userId)];
+ if (!meUser) { return; } // can't join projects if we don't have a me user
+
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ [realm beginWriteTransaction];
+ for (ExploreProject *eg in results) {
+ NSDictionary *value = [ExploreProjectRealm valueForMantleModel:eg];
+ ExploreProjectRealm *project = [ExploreProjectRealm createOrUpdateInDefaultRealmWithValue:value];
+ [meUser.joinedProjects addObject:project];
+ }
+ [realm commitWriteTransaction];
+
+ // update tableview
+ [weakSelf.tableView reloadData];
+
+ NSInteger totalReceived = results.count + ((page-1) * [[weakSelf projectsApi] projectsPerPage]);
+ if (totalReceived < totalCount) {
+ // recursively fetch another page of joined projects
+ [weakSelf syncUserProjectsUserId:userId page:page+1];
+ }
+ }];
+}
+```
+
+Table structure: one section per joined project; field rows only for traditional projects whose switch is ON:
+
+```413:429:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
+ ExploreProjectRealm *project = [self projectForSection:section];
+ if ([project isNewStyleProject]) {
+ // don't show fields for new style projects
+ return 0;
+ }
+
+ if ([self projectIsSelected:project]) {
+ return project.projectObsFields.count;
+ } else {
+ return 0;
+ }
+}
+
+- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
+ return [self.joinedProjects count];
+}
+```
+
+Section header: project icon/title/type plus the toggle switch — hidden for collection/umbrella:
+
+```355:393:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section {
+
+ ExploreProjectRealm *project = [self projectForSection:section];
+ BOOL projectIsSelected = [self projectIsSelected:project];
+
+ CGFloat height = [self tableView:tableView heightForHeaderInSection:section];
+
+ UINib *nib = [UINib nibWithNibName:@"ProjectObservationHeaderView" bundle:[NSBundle mainBundle]];
+ ProjectObservationHeaderView *header = [[nib instantiateWithOwner:nil options:nil] firstObject];
+ header.frame = CGRectMake(0, 0, tableView.bounds.size.width, height);
+
+ header.projectTitleLabel.text = project.title;
+ header.projectTypeLabel.text = [project titleForTypeOfProject];
+
+ if ([project isNewStyleProject]) {
+ header.selectedSwitch.hidden = YES;
+ header.backgroundColor = [[UIColor lightGrayColor] colorWithAlphaComponent:0.2];
+ } else {
+ header.selectedSwitch.hidden = NO;
+ [header.selectedSwitch setOn:projectIsSelected animated:NO];
+ header.selectedSwitch.tag = section;
+ [header.selectedSwitch addTarget:self action:@selector(selectedChanged:) forControlEvents:UIControlEventValueChanged];
+ header.backgroundColor = [UIColor whiteColor];
+ }
+
+
+ if ([project iconUrl]) {
+ header.projectThumbnailImageView.backgroundColor = [UIColor clearColor];
+ header.projectThumbnailImageView.contentMode = UIViewContentModeScaleAspectFill;
+ [header.projectThumbnailImageView setImageWithURL:[project iconUrl]];
+ } else {
+ // use standard projects icon
+ header.projectThumbnailImageView.backgroundColor = [UIColor colorWithHexString:@"#cccccc"];
+ header.projectThumbnailImageView.image = [UIImage inat_defaultProjectImage];
+ header.projectThumbnailImageView.contentMode = UIViewContentModeCenter;
+ }
+
+ return header;
+}
+```
+
+Header view outlets:
+
+```11:18:INaturalistIOS/Views/ProjectObservationHeaderView.h
+@interface ProjectObservationHeaderView : UIView
+
+@property IBOutlet UIImageView *projectThumbnailImageView;
+@property IBOutlet UILabel *projectTitleLabel;
+@property IBOutlet UILabel *projectTypeLabel;
+@property IBOutlet UISwitch *selectedSwitch;
+
+@end
+```
+
+"Is this observation in this project?" is answered by scanning the observation's project links:
+
+```657:665:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (BOOL)projectIsSelected:(ExploreProjectRealm *)project {
+ for (ExploreProjectObservationRealm *po in self.observation.projectObservations) {
+ if (po.project.projectId == project.projectId) {
+ return YES;
+ }
+ }
+
+ return NO;
+}
+```
+
+### 3.3 Toggling a project ON/OFF
+
+Toggle ON creates a `ExploreProjectObservationRealm` plus one default OFV per project field, **written to the default Realm immediately** (even for unsaved observations). Toggle OFF stages deletions via the delegate:
+
+```675:732:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (void)selectedChanged:(UISwitch *)switcher {
+ NSInteger section = switcher.tag;
+ ExploreProjectRealm *project = [self projectForSection:section];
+ if (!project) return;
+
+ NSIndexPath *sectionIp = [NSIndexPath indexPathForRow:NSNotFound inSection:section];
+
+ if (switcher.isOn) {
+ // have to create a ProjectObs and some OFVs for this observation
+
+ // create and add project observation
+ ExploreProjectObservationRealm *po = [ExploreProjectObservationRealm new];
+ po.project = project;
+ po.uuid = [[[NSUUID UUID] UUIDString] lowercaseString];
+
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ [realm beginWriteTransaction];
+ [realm addOrUpdateObject:po];
+ [self.observation.projectObservations addObject:po];
+ [realm commitWriteTransaction];
+
+ for (ExploreProjectObsFieldRealm *pof in project.sortedProjectObservationFields) {
+ ExploreObsFieldValueRealm *ofv = [ExploreObsFieldValueRealm new];
+ ofv.uuid = [[[NSUUID UUID] UUIDString] lowercaseString];
+ ofv.obsField = pof.obsField;
+ ofv.value = pof.obsField.allowedValues.firstObject;
+
+ [realm beginWriteTransaction];
+ [realm addOrUpdateObject:ofv];
+ [self.observation.observationFieldValues addObject:ofv];
+ [realm commitWriteTransaction];
+ }
+ } else {
+ ExploreProjectObservationRealm *poToDelete = nil;
+ for (ExploreProjectObservationRealm *po in self.observation.projectObservations) {
+ if (po.project.projectId == project.projectId) {
+ poToDelete = po;
+ }
+ }
+
+ if (poToDelete) {
+ [self.delegate projectObsDelegateDeletedProjectObservation:poToDelete];
+ }
+
+ // do the ofvs for this project's pofs
+ for (ExploreProjectObsFieldRealm *pof in project.projectObsFields) {
+ ExploreObsFieldValueRealm *ofvToDelete = [self.observation valueForObsField:pof.obsField];
+ if (ofvToDelete) {
+ [self.delegate projectObsDelegateDeletedObsFieldValue:ofvToDelete];
+ }
+ }
+ }
+
+ [self.tableView reloadData];
+ [self.tableView scrollToRowAtIndexPath:sectionIp
+ atScrollPosition:UITableViewScrollPositionTop
+ animated:YES];
+}
+```
+
+The delegate methods in obs edit stage the removals into `recordsToDelete` (committed only at save):
+
+```1099:1115:INaturalistIOS/Controllers/Observations/Observation Details/ObsEditV2ViewController.m
+-(void)projectObsDelegateDeletedProjectObservation:(ExploreProjectObservationRealm *)po {
+ NSInteger indexOfProjectObs = [self.standaloneObservation.projectObservations indexOfObject:po];
+
+ if (indexOfProjectObs != NSNotFound) {
+ [self.recordsToDelete addObject:po];
+ [self.standaloneObservation.projectObservations removeObjectAtIndex:indexOfProjectObs];
+ }
+}
+
+- (void)projectObsDelegateDeletedObsFieldValue:(ExploreObsFieldValueRealm *)ofv {
+ NSInteger indexOfObsFieldValue = [self.standaloneObservation.observationFieldValues indexOfObject:ofv];
+
+ if (indexOfObsFieldValue != NSNotFound) {
+ [self.recordsToDelete addObject:ofv];
+ [self.standaloneObservation.observationFieldValues removeObjectAtIndex:indexOfObsFieldValue];
+ }
+}
+```
+
+### 3.4 Field rows: cell selection decision tree
+
+```324:353:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
+ ExploreProjectRealm *project = [self projectForSection:indexPath.section];
+ ExploreProjectObsFieldRealm *pof = [[project sortedProjectObservationFields] objectAtIndex:indexPath.item];
+
+ if ([pof.obsField canBeTreatedAsText]) {
+ if (pof.obsField.allowedValues.count > 1) {
+ // simple value cell
+ ObsFieldSimpleValueCell *cell = [tableView dequeueReusableCellWithIdentifier:SimpleFieldIdentifier];
+ [self configureSimpleCell:cell forProjectObsField:pof];
+ return cell;
+ } else {
+ ObsFieldLongTextValueCell *cell = [tableView dequeueReusableCellWithIdentifier:LongTextFieldIdentifier];
+ [self configureLongTextCell:cell forProjectObsField:pof];
+ return cell;
+ }
+ } else if (pof.obsField.dataType == ExploreObsFieldDataTypeNumeric ||
+ pof.obsField.dataType == ExploreObsFieldDataTypeDate ||
+ pof.obsField.dataType == ExploreObsFieldDataTypeTime ||
+ pof.obsField.dataType == ExploreObsFieldDataTypeDateTime ||
+ pof.obsField.dataType == ExploreObsFieldDataTypeTaxon) {
+
+ ObsFieldSimpleValueCell *cell = [tableView dequeueReusableCellWithIdentifier:SimpleFieldIdentifier];
+ [self configureSimpleCell:cell forProjectObsField:pof];
+ return cell;
+ } else {
+ UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath];
+ [self configureCell:cell forIndexPath:indexPath];
+ return cell;
+ }
+}
+```
+
+Summary of the mapping:
+
+- text/dna with more than 1 allowed value: select list (push `ProjectObsFieldViewController`)
+- text/dna with 0 or 1 allowed values: inline free-text (`ObsFieldLongTextValueCell`)
+- numeric: inline overlay text field with decimal pad
+- taxon: push `TaxaSearchViewController`
+- date and datetime: `ActionSheetDatePicker` in DateAndTime mode
+- time: `ActionSheetDatePicker` in Time mode
+
+### 3.5 Per-type input dispatch on row tap
+
+```431:560:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
+ [tableView deselectRowAtIndexPath:indexPath animated:YES];
+
+ ExploreProjectRealm *project = [self projectForSection:indexPath.section];
+
+ if ([project isNewStyleProject]) {
+ // there shouldn't be any rows for new style projects
+ // bail just in case
+ return;
+ }
+
+ ExploreProjectObsFieldRealm *pof = [[project sortedProjectObservationFields] objectAtIndex:indexPath.item];
+ ExploreObsFieldValueRealm *ofv = [self.observation valueForObsField:pof.obsField];
+
+ NSInteger initialSelection = 0;
+
+ if (ofv) {
+ // will be set to NSNotFound if it's not in the allowed values
+ initialSelection = [pof.obsField.allowedValues indexOfObject:ofv.value];
+ }
+
+ if ([pof.obsField canBeTreatedAsText]) {
+ if (pof.obsField.allowedValues.count > 1) {
+ // text field, multiselect
+ ProjectObsFieldViewController *pofVC = [[ProjectObsFieldViewController alloc] initWithNibName:nil bundle:nil];
+ pofVC.pof = pof;
+ pofVC.ofv = ofv;
+
+ [self.navigationController pushViewController:pofVC animated:YES];
+ } else {
+ // text field, raw entry
+
+ // activate the textfield
+ ObsFieldLongTextValueCell *cell = (ObsFieldLongTextValueCell *)[tableView cellForRowAtIndexPath:indexPath];
+ [cell.textField becomeFirstResponder];
+
+ self.tapAwayGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapAway:)];
+ [self.tableView addGestureRecognizer:self.tapAwayGesture];
+ }
+ } else if (pof.obsField.dataType == ExploreObsFieldDataTypeNumeric) {
+ // numeric text entry
+
+ // setup a textfield above the label
+ ObsFieldSimpleValueCell *cell = [tableView cellForRowAtIndexPath:indexPath];
+ cell.valueLabel.hidden = YES;
+
+ UITextField *tf = [[UITextField alloc] initWithFrame:cell.valueLabel.frame];
+ tf.keyboardType = UIKeyboardTypeDecimalPad;
+ tf.textAlignment = NSTextAlignmentRight;
+ tf.returnKeyType = UIReturnKeyDone;
+ tf.text = cell.valueLabel.text;
+ tf.delegate = self;
+ [cell.contentView addSubview:tf];
+
+ [tf becomeFirstResponder];
+
+ self.tapAwayGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapAway:)];
+ [self.tableView addGestureRecognizer:self.tapAwayGesture];
+ } else if (pof.obsField.dataType == ExploreObsFieldDataTypeTaxon) {
+ // taxon picker
+ UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"MainStoryboard" bundle:nil];
+
+ TaxaSearchViewController *search = [storyboard instantiateViewControllerWithIdentifier:@"TaxaSearchViewController"];
+ search.hidesDoneButton = YES;
+ search.delegate = self;
+ // only prime the query if there's a placeholder, not a taxon)
+ if (self.observation.speciesGuess && ! self.observation.taxon) {
+ search.query = self.observation.speciesGuess;
+ }
+ [self.navigationController pushViewController:search animated:YES];
+
+ // stash the selected index path so we know what ofv to update
+ self.taxaSearchIndexPath = indexPath;
+ } else if (pof.obsField.dataType == ExploreObsFieldDataTypeDate
+ || pof.obsField.dataType == ExploreObsFieldDataTypeDateTime) {
+
+ ObsFieldSimpleValueCell *cell = [tableView cellForRowAtIndexPath:indexPath];
+ static NSDateFormatter *dateFormatter;
+ if (!dateFormatter) {
+ dateFormatter = [[NSDateFormatter alloc] init];
+ dateFormatter.dateFormat = @"dd MMM yyyy HH:mm:ss ZZZ";
+ }
+ NSDate *date;
+ if (cell.valueLabel.text && cell.valueLabel.text.length > 0) {
+ date = [dateFormatter dateFromString:cell.valueLabel.text];
+ }
+ if (!date) {
+ date = [NSDate date];
+ }
+
+ __weak typeof(self) weakSelf = self;
+ [[[ActionSheetDatePicker alloc] initWithTitle:pof.obsField.name
+ datePickerMode:UIDatePickerModeDateAndTime
+ selectedDate:date
+ doneBlock:^(ActionSheetDatePicker *picker, id selectedDate, id origin) {
+ NSDate *date = (NSDate *)selectedDate;
+ cell.valueLabel.text = [dateFormatter stringFromDate:date];
+ [weakSelf saveVisibleObservationFieldValues];
+ } cancelBlock:nil
+ origin:self.view] showActionSheetPicker];
+
+ } else if (pof.obsField.dataType == ExploreObsFieldDataTypeTime) {
+
+ ObsFieldSimpleValueCell *cell = [tableView cellForRowAtIndexPath:indexPath];
+ static NSDateFormatter *dateFormatter;
+ if (!dateFormatter) {
+ dateFormatter = [[NSDateFormatter alloc] init];
+ dateFormatter.dateFormat = @"HH:mm:ss";
+ }
+ NSDate *date;
+ if (cell.valueLabel.text && cell.valueLabel.text.length > 0) {
+ date = [dateFormatter dateFromString:cell.valueLabel.text];
+ }
+ if (!date) {
+ date = [NSDate date];
+ }
+
+ __weak typeof(self) weakSelf = self;
+ [[[ActionSheetDatePicker alloc] initWithTitle:pof.obsField.name
+ datePickerMode:UIDatePickerModeTime
+ selectedDate:date
+ doneBlock:^(ActionSheetDatePicker *picker, id selectedDate, id origin) {
+ NSDate *date = (NSDate *)selectedDate;
+ cell.valueLabel.text = [dateFormatter stringFromDate:date];
+ [weakSelf saveVisibleObservationFieldValues];
+ } cancelBlock:nil
+ origin:self.view] showActionSheetPicker];
+
+ }
+}
+```
+
+Notes:
+
+- `date` fields use the **date+time** picker mode (`UIDatePickerModeDateAndTime`), same as `datetime` — likely a bug worth fixing in RN with a date-only picker. Date format: `dd MMM yyyy HH:mm:ss ZZZ`; time format: `HH:mm:ss`.
+- Taxon field values come back via the taxon search delegate, stored as the taxon id string:
+
+```289:316:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (void)taxaSearchViewControllerChoseTaxon:(id )taxon chosenViaVision:(BOOL)visionFlag {
+ [self.navigationController popToViewController:self animated:YES];
+
+ if (!self.taxaSearchIndexPath) { return; }
+
+
+ ExploreProjectRealm *project = [self projectForSection:self.taxaSearchIndexPath.section];
+ if (!project) return;
+
+ ExploreProjectObsFieldRealm *pof = [project.sortedProjectObservationFields objectAtIndex:self.taxaSearchIndexPath.item];
+
+ ExploreObsFieldValueRealm *ofv = [self.observation valueForObsField:pof.obsField];
+ if (ofv) {
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ [realm beginWriteTransaction];
+ ofv.value = [NSString stringWithFormat:@"%ld", (long)taxon.taxonId];
+ ofv.timeUpdatedLocally = [NSDate date];
+ [realm commitWriteTransaction];
+ }
+
+ [self.tableView beginUpdates];
+ [self.tableView reloadRowsAtIndexPaths:@[ self.taxaSearchIndexPath ]
+ withRowAnimation:UITableViewRowAnimationNone];
+ [self.tableView endUpdates];
+
+ self.taxaSearchIndexPath = nil;
+ [self saveVisibleObservationFieldValues];
+}
+```
+
+### 3.6 The select picker: `ProjectObsFieldViewController`
+
+A simple checkmark list over the allowed values. Selecting writes straight to the OFV in Realm and pops:
+
+```79:98:INaturalistIOS/Controllers/Projects/ProjectObsFieldViewController.m
+- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)selectedIndexPath {
+ // update selection
+ for (NSIndexPath *indexPath in tableView.indexPathsForVisibleRows) {
+ UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
+ if ([selectedIndexPath isEqual:indexPath]) {
+ cell.accessoryType = UITableViewCellAccessoryCheckmark;
+ } else {
+ cell.accessoryType = UITableViewCellAccessoryNone;
+ }
+ }
+
+ // update model
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ [realm beginWriteTransaction];
+ self.ofv.value = [self.pof.obsField.allowedValues objectAtIndex:selectedIndexPath.item];
+ [realm commitWriteTransaction];
+
+ // pop
+ [self.navigationController popViewControllerAnimated:YES];
+}
+```
+
+```106:117:INaturalistIOS/Controllers/Projects/ProjectObsFieldViewController.m
+- (void)configureCell:(UITableViewCell *)cell forIndexPath:(NSIndexPath *)indexPath {
+ NSString *valueForRow = [self.pof.obsField.allowedValues objectAtIndex:indexPath.item];
+
+ cell.textLabel.text = valueForRow;
+ cell.textLabel.numberOfLines = 0;
+
+ if ([valueForRow isEqualToString:self.ofv.value]) {
+ cell.accessoryType = UITableViewCellAccessoryCheckmark;
+ } else {
+ cell.accessoryType = UITableViewCellAccessoryNone;
+ }
+}
+```
+
+Note: this controller assumes `ofv` is non-nil (guaranteed because toggling a project ON creates default OFVs).
+
+### 3.7 Persisting field values
+
+Values are persisted by reading the **visible** cells back into OFVs — a fragile pattern (scrolled-off edits depend on `endEditing`/save being called) that should not be replicated in RN:
+
+```207:238:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (void)saveVisibleObservationFieldValues {
+ RLMRealm *realm = [RLMRealm defaultRealm];
+
+ for (NSIndexPath *indexPath in self.tableView.indexPathsForVisibleRows) {
+ ExploreProjectRealm *project = [self projectForSection:indexPath.section];
+ if (!project) return;
+
+ ExploreProjectObsFieldRealm *pof = [[project sortedProjectObservationFields] objectAtIndex:indexPath.item];
+
+
+ ExploreObsFieldValueRealm *ofv = [self.observation valueForObsField:pof.obsField];
+ if (ofv) {
+ if (![ofv.value isEqualToString:[self currentValueForIndexPath:indexPath]]) {
+ [realm beginWriteTransaction];
+ ofv.value = [self currentValueForIndexPath:indexPath];
+ ofv.timeUpdatedLocally = [NSDate date];
+ [realm commitWriteTransaction];
+ }
+ } else {
+ ofv = [ExploreObsFieldValueRealm new];
+ ofv.uuid = [[[NSUUID UUID] UUIDString] lowercaseString];
+ ofv.obsField = pof.obsField;
+ ofv.value = [self currentValueForIndexPath:indexPath];
+ ofv.timeUpdatedLocally = [NSDate date];
+
+ [realm beginWriteTransaction];
+ [realm addObject:ofv];
+ [self.observation.observationFieldValues addObject:ofv];
+ [realm commitWriteTransaction];
+ }
+ }
+}
+```
+
+It is triggered from text field end-editing, the date/time picker done blocks, the taxon delegate, and `backPressed:`:
+
+```242:252:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (void)textFieldDidEndEditing:(UITextField *)textField {
+ if ([textField.superview.superview isKindOfClass:[ObsFieldSimpleValueCell class]]) {
+ // this textfield needs to be cleared and the value set
+ ObsFieldSimpleValueCell *cell = (ObsFieldSimpleValueCell *)textField.superview.superview;
+ cell.valueLabel.text = textField.text;
+ [textField removeFromSuperview];
+ cell.valueLabel.hidden = NO;
+ }
+
+ [self saveVisibleObservationFieldValues];
+}
+```
+
+### 3.8 Required-field indication and the visually distinct field form
+
+Required fields are shown **bold**:
+
+```607:634:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (void)configureSimpleCell:(ObsFieldSimpleValueCell *)cell forProjectObsField:(ExploreProjectObsFieldRealm *)pof {
+
+ cell.fieldLabel.text = pof.obsField.name;
+ if (pof.required) {
+ cell.fieldLabel.font = [UIFont boldSystemFontOfSize:cell.fieldLabel.font.pointSize];
+ } else {
+ cell.fieldLabel.font = [UIFont systemFontOfSize:cell.fieldLabel.font.pointSize];
+ }
+
+ ExploreObsFieldValueRealm *ofv = [self.observation valueForObsField:pof.obsField];
+ if (ofv) {
+ if (pof.obsField.dataType == ExploreObsFieldDataTypeTaxon) {
+ ExploreTaxonRealm *taxon = [ExploreTaxonRealm objectForPrimaryKey:@(ofv.value.integerValue)];
+ if (taxon) {
+ cell.valueLabel.text = taxon.commonName ?: taxon.scientificName;
+ } else {
+ cell.valueLabel.text = (ofv.value.length == 0) ? @"unknown" : ofv.value;
+ }
+ } else {
+ cell.valueLabel.text = ofv.value ?: pof.obsField.allowedValues.firstObject;
+ }
+ } else {
+ // show default
+ cell.valueLabel.text = pof.obsField.allowedValues.firstObject;
+ }
+
+ cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
+}
+```
+
+```636:655:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (void)configureLongTextCell:(ObsFieldLongTextValueCell *)cell forProjectObsField:(ExploreProjectObsFieldRealm *)pof {
+ cell.fieldLabel.text = pof.obsField.name;
+
+ if (pof.required) {
+ cell.fieldLabel.font = [UIFont boldSystemFontOfSize:cell.fieldLabel.font.pointSize];
+ } else {
+ cell.fieldLabel.font = [UIFont systemFontOfSize:cell.fieldLabel.font.pointSize];
+ }
+
+ cell.textField.delegate = self;
+
+ ExploreObsFieldValueRealm *ofv = [self.observation valueForObsField:pof.obsField];
+ if (ofv) {
+ cell.textField.text = ofv.value ?: ofv.obsField.allowedValues.firstObject;
+ } else {
+ cell.textField.text = nil;
+ }
+
+ cell.accessoryType = UITableViewCellAccessoryNone;
+}
+```
+
+The field form is visually distinguished from the rest of the obs edit flow by a green-tinted cell background (POD design requirement "visually distinct"):
+
+```16:21:INaturalistIOS/Views/ObsFieldSimpleValueCell.m
+- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
+
+ if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
+
+ self.backgroundColor = [UIColor colorWithHexString:@"#f1f7e5"];
+ self.indentationLevel = 3;
+```
+
+```15:19:INaturalistIOS/Views/ObsFieldLongTextValueCell.m
+- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
+ if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
+
+ self.backgroundColor = [UIColor colorWithHexString:@"#f1f7e5"];
+```
+
+The free-text placeholder:
+
+```30:38:INaturalistIOS/Views/ObsFieldLongTextValueCell.m
+ self.textField = ({
+ UITextField *tf = [[UITextField alloc] initWithFrame:CGRectZero];
+ tf.translatesAutoresizingMaskIntoConstraints = NO;
+
+ tf.font = [UIFont systemFontOfSize:14.0f];
+ tf.placeholder = NSLocalizedString(@"Your response here", @"Placeholder for free text observation field value");
+
+ tf;
+ });
+```
+
+Row heights also use bold vs regular font for measurement:
+
+```400:411:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
+ ExploreProjectRealm *project = [self projectForSection:indexPath.section];
+ ExploreProjectObsFieldRealm *pof = [[project sortedProjectObservationFields] objectAtIndex:indexPath.item];
+
+ UIFont *fieldFont = pof.required ? [UIFont boldSystemFontOfSize:17] : [UIFont systemFontOfSize:17];
+
+ if ([[pof obsField] canBeTreatedAsText] && [[[pof obsField] allowedValues] count] > 1) {
+ return [self heightForSimpleProjectField:pof inTableView:tableView font:fieldFont];
+ } else {
+ return [self heightForLongTextProjectField:pof inTableView:tableView font:fieldFont];
+ }
+}
+```
+
+Arbitrary field-list length is handled structurally (one table section per project, one row per field), but persistence relies on the visible-rows-only save above — the fragile part for long field lists.
+
+### 3.9 Required-field validation (critical gotcha: it is unwired)
+
+A client-side validator exists:
+
+```187:205:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (BOOL)validateProjectObservationsForObservation:(ExploreObservationRealm *)observation
+ failedProject:(out NSString **)failedProjectName
+ failedField:(out NSString **)failedFieldName {
+
+ for (ExploreProjectObservationRealm *po in self.observation.projectObservations) {
+ for (ExploreProjectObsFieldRealm *pof in po.project.projectObsFields) {
+ if (pof.required) {
+ ExploreObsFieldValueRealm *ofv = [self.observation valueForObsField:pof.obsField];
+ if (!ofv || ofv.value == nil || ofv.value.length == 0) {
+ *failedProjectName = po.project.title;
+ *failedFieldName = pof.obsField.name;
+ return NO;
+ }
+ }
+ }
+ }
+
+ return YES;
+}
+```
+
+And a back handler that runs it with a "Missing Required Field" alert:
+
+```155:183:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
+- (void)backPressed:(UIBarButtonItem *)button {
+ // end editing on any rows
+ [self.tableView endEditing:YES];
+
+ // save the ofvs
+ [self saveVisibleObservationFieldValues];
+
+ NSString *projectNameFailingValidation = [NSString string];
+ NSString *projectFieldFailingValidation = [NSString string];
+
+ BOOL validated = [self validateProjectObservationsForObservation:self.observation
+ failedProject:&projectNameFailingValidation
+ failedField:&projectFieldFailingValidation];
+
+ if (validated) {
+ [self.navigationController popViewControllerAnimated:YES];
+ } else {
+ NSString *msg = [NSString stringWithFormat:NSLocalizedString(@"'%@' requires that you fill out the '%@' field.",nil),
+ projectNameFailingValidation,
+ projectFieldFailingValidation];
+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Missing Required Field",nil)
+ message:msg
+ preferredStyle:UIAlertControllerStyleAlert];
+ [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK",nil)
+ style:UIAlertActionStyleCancel
+ handler:nil]];
+ [self presentViewController:alert animated:YES completion:nil];
+ }
+}
+```
+
+**However, `backPressed:` is never wired up anywhere** — a repo-wide search finds only its definition at `ProjectObservationsViewController.m:155`, no `addTarget:`/selector references. The standard navigation back button and swipe-back skip validation entirely. This is dead/incomplete code. In practice required-field enforcement is server-side (section 5.4). Also note that toggling a project on seeds each OFV with `allowedValues.firstObject` (section 3.3) — a non-empty default silently satisfies "required" without user interaction. The RN port must implement real pre-upload client validation per the POD requirement; port the validator logic, not the broken wiring.
+
+### 3.10 Saving the observation: `validatedSave`
+
+Obs edit's save does **not** run project required-field validation. It clears `validationErrorMsg`, persists the observation, then materializes the staged removals as delete tombstones:
+
+```969:1016:INaturalistIOS/Controllers/Observations/Observation Details/ObsEditV2ViewController.m
+- (void)validatedSave {
+ [self.view endEditing:YES];
+
+ self.shouldContinueUpdatingLocation = NO;
+ [self stopUpdatingLocation];
+
+ // clear upload validation error message
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ [realm beginWriteTransaction];
+ self.standaloneObservation.validationErrorMsg = nil;
+ [realm commitWriteTransaction];
+
+ if (self.isMakingNewObservation) {
+ // insert new standalone observation into realm
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ [realm beginWriteTransaction];
+ [realm addObject:self.standaloneObservation];
+ [realm commitWriteTransaction];
+ } else {
+ // merge observation with standalone editing copy
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ [realm beginWriteTransaction];
+ // use addOrUpdateObject: instead of createOrUpdateInRealm: because
+ // we want to allow users to delete photos and clear records
+ [realm addOrUpdateObject:self.standaloneObservation];
+ [realm commitWriteTransaction];
+
+
+ // time to make deleted records for our stuff
+ // would be nice to make this an inherited or protocol method
+ [realm beginWriteTransaction];
+ for (RLMObject *recordToDelete in self.recordsToDelete) {
+ [realm addOrUpdateObject:[recordToDelete deletedRecordForModel]];
+ }
+ [realm commitWriteTransaction];
+
+ // purge from realm
+ // have to do this carefully since the handle we have on realm might
+ // not be the realm handle where the deleted record was made
+ for (RLMObject *recordToDelete in self.recordsToDelete) {
+ if ([recordToDelete realm]) {
+ RLMRealm *realm = [recordToDelete realm];
+ [realm beginWriteTransaction];
+ [realm deleteObject:recordToDelete];
+ [realm commitWriteTransaction];
+ }
+ }
+ }
+
+ [self.view.window.rootViewController dismissViewControllerAnimated:YES completion:^{
+```
+
+---
+
+## 4. API endpoints
+
+All project traffic goes through the Node API:
+
+```36:38:INaturalistIOS/API Endpoints/Node API/INatAPI.m
+- (NSString *)apiBaseUrl {
+ return @"https://api.inaturalist.org/v1";
+}
+```
+
+`INatAPI` appends a `locale` query parameter to every request and attaches a JWT `Authorization` header for logged-in requests.
+
+The complete `ProjectsAPI` surface relevant to this feature:
+
+```21:35:INaturalistIOS/API Endpoints/Node API/ProjectsAPI.m
+- (NSInteger)projectsPerPage {
+ return 100;
+}
+
+- (NSInteger)observationsProjectPerPage {
+ return 200;
+}
+
+- (void)projectsForUser:(NSInteger)userId page:(NSInteger)page handler:(INatAPIFetchCompletionCountHandler)done {
+ [[Analytics sharedClient] debugLog:@"Network - fetch a page of user projects from node"];
+ NSString *path = [NSString stringWithFormat:@"/v1/users/%ld/projects", (long)userId];
+ NSString *query = [NSString stringWithFormat:@"per_page=%ld&page=%ld",
+ (long)self.projectsPerPage, (long)page];
+ [self fetch:path query:query classMapping:ExploreProject.class handler:done];
+}
+```
+
+```61:73:INaturalistIOS/API Endpoints/Node API/ProjectsAPI.m
+- (void)joinProject:(NSInteger)projectId handler:(INatAPIFetchCompletionCountHandler)done {
+ [[Analytics sharedClient] debugLog:@"Network - join project via node"];
+ NSString *path =[NSString stringWithFormat:@"/v1/projects/%ld/join",
+ (long)projectId];
+ [self post:path query:nil params:nil classMapping:ExploreProject.class handler:done];
+}
+
+- (void)leaveProject:(NSInteger)projectId handler:(INatAPIFetchCompletionCountHandler)done {
+ [[Analytics sharedClient] debugLog:@"Network - join project via node"];
+ NSString *path = [NSString stringWithFormat:@"/v1/projects/%ld/leave",
+ (long)projectId];
+ [self delete:path query:nil handler:done];
+}
+```
+
+Endpoint summary:
+
+- `GET /v1/users/{userId}/projects?per_page=100&page=N` — joined projects (paginated; includes `project_observation_fields`)
+- `POST /v1/projects/{id}/join` — join (no body)
+- `DELETE /v1/projects/{id}/leave` — leave
+- `POST/PUT /v1/project_observations[/{id}]` — attach observation to project (see 5.1)
+- `POST/PUT /v1/observation_field_values[/{id}]` — field values (see 5.1)
+- `DELETE /v1/project_observations/{id}` and `DELETE /v1/observation_field_values/{id}` — removals (see 5.3)
+
+There is **no** `GET /v1/projects/{id}` single-project fetch anywhere in `ProjectsAPI.m` — project metadata comes from list/join/search responses. Project detail tab counts use `GET /v1/observations`, `/v1/observations/species_counts`, `/v1/observations/observers`, `/v1/observations/identifiers` filtered by `project_id` (`ProjectsAPI.m` lines 77–104).
+
+---
+
+## 5. Upload / sync pipeline
+
+### 5.1 Upload payloads
+
+Both record types implement the `Uploadable` protocol:
+
+```13:31:INaturalistIOS/Helpers/Uploader/Uploadable.h
+@protocol Uploadable
+
+- (NSArray *)childrenNeedingUpload;
+- (BOOL)needsUpload;
++ (NSArray *)needingUpload;
+- (NSDictionary *)uploadableRepresentation;
+- (NSString *)uuid;
++ (NSString *)endpointName;
+- (NSDate *)timeSynced;
+- (void)setTimeSynced:(NSDate *)date;
+- (void)setRecordId:(NSInteger)newRecordId;
+- (NSInteger)recordId;
+
+// uploadable stuff needs to be deletable, too
+- (ExploreDeletedRecord *)deletedRecordForModel;
+// would be nice to be generic here
++ (void)syncedDelete:(id )model;
++ (void)deleteWithoutSync:(id )model;
+```
+
+Project observation: **flat** body, endpoint `project_observations`. Requires the parent observation's server id, so it can only upload after the observation exists server-side:
+
+```122:136:INaturalistIOS/Models/Realm/ExploreProjectObservationRealm.m
+- (NSDictionary *)uploadableRepresentation {
+ if (self.observation && self.project) {
+ return @{
+ @"observation_id": @(self.observation.observationId),
+ @"project_id": @(self.project.projectId),
+ @"uuid": self.uuid,
+ };
+ } else {
+ return nil;
+ }
+}
+
++ (NSString *)endpointName {
+ return @"project_observations";
+}
+```
+
+Observation field value: **nested** body under `observation_field_value`, endpoint `observation_field_values`. The asymmetry with the PO payload is intentional in this codebase:
+
+```132:149:INaturalistIOS/Models/Realm/ExploreObsFieldValueRealm.m
+- (NSDictionary *)uploadableRepresentation {
+ if (self.obsField && self.observation && self.uuid && self.value) {
+ return @{
+ @"observation_field_value": @{
+ @"uuid": self.uuid,
+ @"value": self.value,
+ @"observation_id": @(self.observation.observationId),
+ @"observation_field_id": @(self.obsField.obsFieldId),
+ },
+ };
+ } else {
+ return nil;
+ }
+}
+
++ (NSString *)endpointName {
+ return @"observation_field_values";
+}
+```
+
+Dirty-tracking on both children (`timeSynced` vs `timeUpdatedLocally`):
+
+```110:115:INaturalistIOS/Models/Realm/ExploreProjectObservationRealm.m
+- (BOOL)needsUpload {
+ if (self.uploadableRepresentation == nil) { return NO; } // nothing to upload
+ if (!self.timeSynced) { return YES; } // never uploaded, needs upload
+ if ([self.timeSynced timeIntervalSinceDate:self.timeUpdatedLocally] < 0) { return YES; } // updated since last sync, needs upload
+ return NO; // doesn't need upload
+}
+```
+
+### 5.2 Upload ordering
+
+Child upload order is fixed: photos, then sounds, then **OFVs**, then **project observations**:
+
+```541:573:INaturalistIOS/Models/Realm/ExploreObservationRealm.m
+- (NSArray *)childrenNeedingUpload {
+ NSMutableArray *recordsToUpload = [NSMutableArray array];
+
+ for (ExploreObservationPhotoRealm *op in self.observationPhotos) {
+ if ([op needsUpload]) {
+ [recordsToUpload addObject:op];
+ }
+ }
+
+ for (ExploreObservationSoundRealm *os in self.observationSounds) {
+ if ([os needsUpload]) {
+ [recordsToUpload addObject:os];
+ }
+ }
+
+ for (ExploreObsFieldValueRealm *ofv in self.observationFieldValues) {
+ if ([ofv needsUpload]) {
+ [recordsToUpload addObject:ofv];
+ }
+ }
+
+ for (ExploreProjectObservationRealm *po in self.projectObservations) {
+ if ([po needsUpload]) {
+ [recordsToUpload addObject:po];
+ }
+ }
+
+ return [NSArray arrayWithArray:recordsToUpload];
+}
+
+- (BOOL)needsUpload {
+ return self.timeSynced == nil || [self.timeSynced timeIntervalSinceDate:self.timeUpdatedLocally] < 0;
+}
+```
+
+Per-observation upload starts with the observation itself (POST for new, PUT for updates), then children serially:
+
+```125:133:INaturalistIOS/Helpers/Uploader/UploadObservationOperation.m
+ if (o.needsUpload) {
+ NSString *httpMethod = o.timeSynced ? @"PUT" : @"POST";
+ [self syncObservation:o method:httpMethod];
+ } else if (o.childrenNeedingUpload.count > 0) {
+ [self syncChildRecord:o.childrenNeedingUpload.firstObject
+ ofObservation:o];
+ } else {
+ [self syncObservationFinishedSuccess:YES syncError:nil];
+ }
+}
+```
+
+Child dispatch: POST for never-synced records, PUT to `/v1/{endpointName}/{recordId}` for updates:
+
+```233:236:INaturalistIOS/Helpers/Uploader/UploadObservationOperation.m
+- (void)syncChildRecord:(id )child ofObservation:(ExploreObservationRealm *)observation {
+ NSString *HTTPMethod = child.timeSynced ? @"PUT" : @"POST";
+
+ NSString *childUUID = [child uuid];
+```
+
+```358:371:INaturalistIOS/Helpers/Uploader/UploadObservationOperation.m
+ NSString *path = nil;
+ if ([HTTPMethod isEqualToString:@"PUT"]) {
+ path = [NSString stringWithFormat:@"/v1/%@/%ld",
+ [[child class] endpointName],
+ (long)[child recordId]];
+ path = [path stringByAppendingFormat:@"?%@", localeQuery];
+
+ [self.nodeSessionManager PUT:path
+ parameters:[child uploadableRepresentation]
+ success:successBlock
+ failure:failureBlock];
+ } else {
+ path = [NSString stringWithFormat:@"/v1/%@", [[child class] endpointName]];
+ path = [path stringByAppendingFormat:@"?%@", localeQuery];
+```
+
+On success the server id is written back to the child (`recordId` maps to `projectObsId` / `obsFieldValueId`):
+
+```250:261:INaturalistIOS/Helpers/Uploader/UploadObservationOperation.m
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ // this observation has been synced
+ [realm beginWriteTransaction];
+ localChild.timeSynced = [NSDate date];
+ [realm commitWriteTransaction];
+
+ // record ids come from the server
+ if ([responseObject valueForKey:@"id"]) {
+ [realm beginWriteTransaction];
+ [localChild setRecordId:[[responseObject valueForKey:@"id"] integerValue]];
+ [realm commitWriteTransaction];
+ }
+```
+
+### 5.3 Deletions: tombstones and ordering
+
+Removals are tracked as `ExploreDeletedRecord` tombstones:
+
+```11:19:INaturalistIOS/Models/Realm/ExploreDeletedRecord.h
+@interface ExploreDeletedRecord : RLMObject
+
+@property NSInteger recordId;
+@property NSString *modelName;
+@property NSString *endpointName;
+@property BOOL synced;
+// synthetic primary key
+@property NSString *modelAndRecordId;
+```
+
+Created from the child records (e.g. project observation):
+
+```181:187:INaturalistIOS/Models/Realm/ExploreProjectObservationRealm.m
+- (ExploreDeletedRecord *)deletedRecordForModel {
+ ExploreDeletedRecord *dr = [[ExploreDeletedRecord alloc] initWithRecordId:self.recordId
+ modelName:@"ProjectObservation"];
+ dr.endpointName = [self.class endpointName];
+ dr.synced = NO;
+ return dr;
+}
+```
+
+Deletes run in a **specific model order** — project observations before field values — to avoid server 422s:
+
+```187:206:INaturalistIOS/Helpers/Uploader/UploadManager.m
+/*
+ Arrange deleted records. We need to delete in a specific order in order to avoid
+ invalidation errors on the server. For example, a project may require certain fields
+ or photos to be a member - deleting the fields or the photos before deleting the
+ project observation will result in a 422 validation error from the server.
+
+ This is a public method so that the UI can know if there are records to delete
+ or not.
+ */
+
+- (NSArray *)deletedRecordsNeedingSync {
+ // delete in a specific order
+ NSMutableArray *recordsToDelete = [NSMutableArray array];
+ for (NSString *modelName in @[ @"Observation", @"ProjectObservation", @"ObservationPhoto", @"ObservationFieldValue", ]) {
+ RLMResults *needingDelete = [ExploreDeletedRecord needingSyncForModelName:modelName];
+ // convert to array and add to our list of all things to delete
+ [recordsToDelete addObjectsFromArray:[needingDelete valueForKey:@"self"]];
+ }
+ return [NSArray arrayWithArray:recordsToDelete];
+}
+```
+
+Deletes run before uploads in a session:
+
+```70:75:INaturalistIOS/Helpers/Uploader/UploadManager.m
+ if (self.deletedRecordsNeedingSync.count > 0) {
+ [self syncDeletes];
+ } else {
+ [self syncUploads];
+ }
+}
+```
+
+The delete operation hits `DELETE /v1/{endpointName}/{recordId}` and treats 404/403 as success:
+
+```55:56:INaturalistIOS/Helpers/Uploader/DeleteRecordOperation.m
+ NSString *deletePath = [NSString stringWithFormat:@"/v1/%@/%ld", self.endpointName, (long)self.recordId];
+
+```
+
+```68:85:INaturalistIOS/Helpers/Uploader/DeleteRecordOperation.m
+ } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
+ BOOL actualSuccess = NO;
+ NSHTTPURLResponse *r = [error.userInfo valueForKey:AFNetworkingOperationFailingURLResponseErrorKey];
+ if (r) {
+ if (r.statusCode == 404 || r.statusCode == 403) {
+ // treat 404s and 403s as successful deletions
+ // 404 means it was already deleted
+ // 403 means you don't own the resource and can't delete it
+ // in either case don't block the user from doing other stuff
+ ExploreDeletedRecord *dr = [ExploreDeletedRecord deletedRecordId:self.recordId withModelName:self.modelName];
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ [realm beginWriteTransaction];
+ dr.synced = YES;
+ [realm commitWriteTransaction];
+
+ actualSuccess = YES;
+ }
+ }
+```
+
+### 5.4 Server-side validation (422) and `validationErrorMsg`
+
+When a child upload fails with HTTP 422, the error is extracted and stored on the **observation**:
+
+```293:333:INaturalistIOS/Helpers/Uploader/UploadObservationOperation.m
+ if ([[error userInfo] valueForKey:AFNetworkingOperationFailingURLResponseErrorKey]) {
+ NSHTTPURLResponse *response = [[error userInfo] valueForKey:AFNetworkingOperationFailingURLResponseErrorKey];
+ if (response.statusCode == 422) {
+
+ // try to extract a validation error from the json response
+ NSData *data = [[error userInfo] valueForKey:AFNetworkingOperationFailingURLResponseDataErrorKey];
+ NSError *jsonDecodeError = nil;
+ id json = [NSJSONSerialization JSONObjectWithData:data
+ options:NSJSONReadingAllowFragments
+ error:&jsonDecodeError];
+
+ NSString *validationError = error.localizedDescription;
+ NSArray *validationErrors = [json valueForKey:@"errors"];
+ if (validationErrors && validationErrors.count > 0) {
+ validationError = validationErrors.firstObject;
+ }
+
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ if ([localChild isKindOfClass:ExploreProjectObservationRealm.class]) {
+ // add project validation error notice
+ ExploreObservationRealm *eor = [ExploreObservationRealm objectForPrimaryKey:self.rootObjectUUID];
+ ExploreProjectObservationRealm *po = [ExploreProjectObservationRealm objectForPrimaryKey:childUUID];
+ NSString *baseErrMsg = NSLocalizedString(@"Couldn't be added to project %@. %@",
+ @"Project validation error. first string is project title, second is the specific error");
+ [realm beginWriteTransaction];
+ eor.validationErrorMsg = [NSString stringWithFormat:baseErrMsg,
+ po.project.title, validationError];
+ [realm commitWriteTransaction];
+
+ // fall through to failing and reporting the error
+ } else if ([localChild isKindOfClass:ExploreObsFieldValueRealm.class]) {
+ // add observation field validation error notice
+ ExploreObservationRealm *eor = [ExploreObservationRealm objectForPrimaryKey:self.rootObjectUUID];
+ NSString *baseErrMsg = NSLocalizedString(@"Observation Field Validation error: %@",
+ @"Project validation error, with the specific error");
+ [realm beginWriteTransaction];
+ eor.validationErrorMsg = [NSString stringWithFormat:baseErrMsg, validationError];
+ [realm commitWriteTransaction];
+
+ // fall through to failing and reporting the error
+ }
+ }
+ }
+ [self syncObservationFinishedSuccess:NO syncError:error];
+```
+
+Observations carrying a `validationErrorMsg` are excluded from autoupload:
+
+```219:239:INaturalistIOS/Helpers/Uploader/UploadManager.m
+/*
+ Upload all pending content. The exclude flag allows us to exclude any pending
+ content that failed to upload last time due to server-side data validation issues.
+ */
+- (void)autouploadPendingContentExcludeInvalids:(BOOL)excludeInvalids {
+ if (!self.shouldAutoupload) { return; }
+
+ // invalid observations failed validation their last upload
+ NSPredicate *noInvalids = [NSPredicate predicateWithBlock:^BOOL(ExploreObservationRealm *observation, NSDictionary *bindings) {
+ return !(observation.validationErrorMsg && observation.validationErrorMsg.length > 0);
+ }];
+
+ NSArray *observationsToUpload = [ExploreObservationRealm needingUpload];
+ if (excludeInvalids) {
+ observationsToUpload = [observationsToUpload filteredArrayUsingPredicate:noInvalids];
+ }
+
+ if (self.deletedRecordsNeedingSync.count > 0 || self.observationsNeedingUpload.count > 0) {
+ [self syncDeletedRecordsThenUploadObservations];
+ }
+}
+```
+
+`validationErrorMsg` is cleared in two places only: `validatedSave` (section 3.10) when the user re-saves, and at the start of the next upload attempt:
+
+```98:102:INaturalistIOS/Helpers/Uploader/UploadObservationOperation.m
+ // clear any validation errors
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ [realm beginWriteTransaction];
+ o.validationErrorMsg = nil;
+ [realm commitWriteTransaction];
+```
+
+### 5.5 Upload-time reconciliation: there is none
+
+The uploader is a dumb replay of whatever was persisted in Realm at edit time. `syncChildRecord:` simply POSTs/PUTs the stored `uploadableRepresentation` (section 5.2) — there is no re-fetch of the project or its `project_observation_fields`, no diffing of locally stored field definitions against the server, and no re-validation of stored OFVs before sending. Payloads carry only ids and raw string values, so any staleness travels straight to the server.
+
+If the project definition changed between edit and upload (admin added a required field, deleted a field, changed allowed values), the server rejects with 422 and the flow in section 5.4 takes over: the observation sync fails, `validationErrorMsg` is set, autoupload excludes the observation, and recovery is fully manual (reopen, fix, re-save). Note the upload order (OFVs before POs) means a "missing required field" 422 typically lands on the `project_observations` create.
+
+The only "refresh" that exists is UI-time, not upload-time: opening the chooser while online wipes and re-fetches joined projects (section 3.2), and `createOrUpdateInDefaultRealmWithValue:` upserts each `ExploreProjectRealm` by `projectId`, updating its `projectObsFields` to the latest server state. Nothing reconciles records already queued for upload — e.g. an OFV pointing at a since-deleted field stays queued and will 422.
+
+---
+
+## 6. Join / leave flows
+
+### 6.1 Join/leave entry point and alert texts
+
+```195:228:INaturalistIOS/Controllers/Projects/Project Details/ProjectDetailV2ViewController.m
+- (void)joinTapped:(UIButton *)button {
+ if (![[INatReachability sharedClient] isNetworkReachable]) {
+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Internet required", nil)
+ message:NSLocalizedString(@"You must be connected to the Internet to do this.", nil)
+ preferredStyle:UIAlertControllerStyleAlert];
+ [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK",nil)
+ style:UIAlertActionStyleCancel
+ handler:nil]];
+ [self presentViewController:alert animated:YES completion:nil];
+ return;
+ }
+
+ INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
+ if ([appDelegate.loginController.meUserLocal hasJoinedProjectWithId:self.project.projectId]) {
+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Are you sure you want to leave this project?", nil)
+ message:NSLocalizedString(@"This will also remove your observations from this project.",nil)
+ preferredStyle:UIAlertControllerStyleAlert];
+ [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", nil)
+ style:UIAlertActionStyleCancel
+ handler:nil]];
+ [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Leave", nil)
+ style:UIAlertActionStyleDestructive
+ handler:^(UIAlertAction * _Nonnull action) {
+ [self leave];
+ }]];
+ [self presentViewController:alert animated:YES completion:nil];
+ } else {
+ if ([(INaturalistAppDelegate *)UIApplication.sharedApplication.delegate loggedIn]) {
+ [self join];
+ } else {
+ [self presentSignupPrompt:NSLocalizedString(@"You must be signed in to join a project.", @"Reason text for signup prompt while trying to join a project.")];
+ }
+ }
+}
+```
+
+Observations:
+
+- Join/leave is **hard-blocked offline** ("Internet required") — there is no offline join queue.
+- The leave warning ("This will also remove your observations from this project.") describes **server** behavior; the app does no local cleanup of `ExploreProjectObservationRealm` records on leave.
+- **There is no hidden-coordinates / curator-trust prompt at join time anywhere in this codebase.** The full join path is the code above plus `-join` below; searches for coordinate/curator/trust/hidden prompts in the project controllers find nothing. The POD's "hidden location access permission option at join" is net-new for RN (web-only today).
+- The POD's "keep or remove observations on leave" option also does not exist here; the classic app only warns. Net-new for RN.
+
+Join button label reflects membership:
+
+```238:247:INaturalistIOS/Controllers/Projects/Project Details/ProjectDetailV2ViewController.m
+- (void)configureJoinButton {
+ INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
+ if ([appDelegate.loginController.meUserLocal hasJoinedProjectWithId:self.project.projectId]) {
+ [self.joinButton setTitle:[NSLocalizedString(@"Leave", @"Leave project button") uppercaseString]
+ forState:UIControlStateNormal];
+ } else {
+ [self.joinButton setTitle:[NSLocalizedString(@"Join", @"Join project button") uppercaseString]
+ forState:UIControlStateNormal];
+ }
+}
+```
+
+### 6.2 Join: network + local effects
+
+`POST /v1/projects/{id}/join`, then upsert the project into Realm and append to `joinedProjects`. The join API response body is otherwise ignored:
+
+```258:310:INaturalistIOS/Controllers/Projects/Project Details/ProjectDetailV2ViewController.m
+- (void)join {
+
+ MBProgressHUD *hud = [MBProgressHUD showHUDAddedTo:self.view animated:YES];
+ hud.labelText = NSLocalizedString(@"Joining...",nil);
+ hud.removeFromSuperViewOnHide = YES;
+ hud.dimBackground = YES;
+
+ __weak typeof(self) weakSelf = self;
+ [[self projectsApi] joinProject:self.project.projectId
+ handler:^(NSArray *results, NSInteger count, NSError *error) {
+
+ [hud hide:YES];
+
+ if (error) {
+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error", nil)
+ message:error.localizedDescription
+ preferredStyle:UIAlertControllerStyleAlert];
+ [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", nil)
+ style:UIAlertActionStyleDefault
+ handler:nil]];
+ [weakSelf presentViewController:alert animated:YES completion:nil];
+ } else {
+ RLMRealm *realm = [RLMRealm defaultRealm];
+
+ if ([weakSelf.project isKindOfClass:[ExploreProject class]]) {
+ ExploreProject *ep = (ExploreProject *)weakSelf.project;
+ // make this project in realm, set joined to true
+ NSDictionary *value = [ExploreProjectRealm valueForMantleModel:ep];
+ [realm beginWriteTransaction];
+ ExploreProjectRealm *epr = [ExploreProjectRealm createOrUpdateInDefaultRealmWithValue:value];
+ [realm commitWriteTransaction];
+
+ // set self.project pointer to the new realm project
+ weakSelf.project = epr;
+
+ INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
+ [realm beginWriteTransaction];
+ [appDelegate.loginController.meUserLocal.joinedProjects addObject:epr];
+ [realm commitWriteTransaction];
+ } else if ([weakSelf.project isKindOfClass:[ExploreProjectRealm class]]) {
+ // update this project in realm
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
+ [realm beginWriteTransaction];
+ [appDelegate.loginController.meUserLocal.joinedProjects addObject:(ExploreProjectRealm *)weakSelf.project];
+ [realm commitWriteTransaction];
+ }
+
+ [self configureJoinButton];
+ }
+
+ }];
+}
+```
+
+### 6.3 Leave: network + local effects
+
+`DELETE /v1/projects/{id}/leave`, then remove from `joinedProjects`. The project row itself is not deleted, and existing local project observations are untouched:
+
+```312:346:INaturalistIOS/Controllers/Projects/Project Details/ProjectDetailV2ViewController.m
+- (void)leave {
+
+ MBProgressHUD *hud = [MBProgressHUD showHUDAddedTo:self.view animated:YES];
+ hud.labelText = NSLocalizedString(@"Leaving...",nil);
+ hud.removeFromSuperViewOnHide = YES;
+ hud.dimBackground = YES;
+
+ __weak typeof(self) weakSelf = self;
+ [[self projectsApi] leaveProject:self.project.projectId
+ handler:^(NSArray *results, NSInteger count, NSError *error) {
+
+ [hud hide:YES];
+
+ if (error) {
+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error", nil)
+ message:error.localizedDescription
+ preferredStyle:UIAlertControllerStyleAlert];
+ [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", nil)
+ style:UIAlertActionStyleDefault
+ handler:nil]];
+ [weakSelf presentViewController:alert animated:YES completion:nil];
+ } else {
+ ExploreProjectRealm *projectToLeave = (ExploreProjectRealm *)self.project;
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ // update this project in realm
+ INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
+ NSInteger indexOfProjectToLeave = [appDelegate.loginController.meUserLocal.joinedProjects indexOfObject:projectToLeave];
+ [realm beginWriteTransaction];
+ [appDelegate.loginController.meUserLocal.joinedProjects removeObjectAtIndex:indexOfProjectToLeave];
+ [realm commitWriteTransaction];
+
+ [self configureJoinButton];
+ }
+ }];
+}
+```
+
+### 6.4 Projects tab
+
+The Joined segment reads `joinedProjects` from Realm:
+
+```59:90:INaturalistIOS/Controllers/Projects/ProjectsViewController.m
+- (NSArray *)projects {
+ if (self.searchController.isActive) {
+ // show searched projects
+ return self.matchingProjects;
+ } else {
+ // show projects for context
+ switch (self.listControl.selectedSegmentIndex) {
+ case ListControlIndexFeatured:
+ return [self featuredProjects];
+ break;
+ case ListControlIndexNearby:
+ return [self nearbyProjects];
+ break;
+ case ListControlIndexUser: {
+ INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
+ ExploreUserRealm *me = appDelegate.loginController.meUserLocal;
+ if (me) {
+ return [[me.joinedProjects sortedResultsUsingKeyPath:@"title" ascending:YES] valueForKey:@"self"];
+ } else {
+ return nil;
+ }
+
+ break;
+ }
+ default:
+ return @[];
+ break;
+ }
+ }
+
+ return @[];
+}
+```
+
+The Projects tab refresh **wipes all** `ExploreProjectRealm` rows before re-fetching — aggressive; port carefully if you cache project metadata referenced by observations:
+
+```128:148:INaturalistIOS/Controllers/Projects/ProjectsViewController.m
+- (void)syncUserProjects {
+ // start by deleting all projects stored in realm
+ self->activityCount += 1;
+ RLMRealm *realm = [RLMRealm defaultRealm];
+ [realm beginWriteTransaction];
+ [realm deleteObjects:[ExploreProjectRealm allObjects]];
+ [realm commitWriteTransaction];
+
+ // empty the UI
+ [self.tableView reloadData];
+
+ // fetch first page of joined projects, if we can
+ INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
+ if ([appDelegate.loginController isLoggedIn]) {
+ ExploreUserRealm *me = [appDelegate.loginController meUserLocal];
+ [self syncUserProjectsUserId:me.userId page:1];
+ } else {
+ [self showSignupPrompt:NSLocalizedString(@"You must be logged in to sync user projects.", @"Signup prompt reason when user tries to sync user projects.")];
+ }
+ [self syncFinished];
+}
+```
+
+List cells show title + icon only — **no traditional/collection/umbrella indicator** in project lists (the type label appears only in the obs-edit chooser headers, section 3.2). The POD's traditional-project indicator in lists is net-new for RN.
+
+---
+
+## 7. Offline behavior
+
+What works offline:
+
+- **Attaching an observation to a project and filling fields.** The chooser reads joined projects from Realm; the network re-sync in `viewDidLoad` only runs when reachable (section 3.2, lines 126–140). Offline it uses the cached `meUser.joinedProjects`.
+- **Field definitions are cached.** The joined-projects sync persists each project including its `project_observation_fields` (sections 2.3 and 3.2), so field names, datatypes, allowed values, and required flags are available locally.
+- **Persistence and deferred upload.** Toggling a project on writes the PO + default OFVs to Realm immediately with client-generated UUID PKs and `timeSynced == nil` (section 3.3). The upload queue picks them up later; autoupload triggers on reachability changes. Removals are queued as tombstones and replayed (section 5.3).
+
+What does not work offline:
+
+- **Joining or leaving a project** — hard-blocked with the "Internet required" alert (section 6.1); no offline join queue.
+- **First-time availability** — joined projects and their obs fields must have been synced at least once while online; a fresh install offline has nothing to show.
+- **Taxon-type fields** — the taxon picker (`TaxaSearchViewController`) searches via the API, so picking a taxon for a taxon field effectively needs connectivity.
+- **Required-field validation feedback** — since the client-side validator is unwired (section 3.9), validation errors only surface as server 422s after reconnecting, via `validationErrorMsg`.
+
+POD relevance: the POD's offline requirement matches what this app already does (Realm-cached joined projects with embedded obs fields, UUID-keyed offline records, sync queue). Gaps for RN: no offline join/leave, and weak offline "failure states" because required-field validation is server-driven.
+
+---
+
+## 8. Things this app does NOT do (absence claims)
+
+- **No client-enforced required-field validation before upload.** The validator exists but `backPressed:` is never wired (section 3.9 — only its definition exists at `ProjectObservationsViewController.m:155`); `validatedSave` in obs edit performs no project-field checks (section 3.10). Enforcement is server 422 (section 5.4).
+- **No upload-time reconciliation** of stale project definitions (section 5.5).
+- **No hidden-coordinates / trust prompt at join time** — the full join path is `joinTapped:` → `join` (sections 6.1–6.2); nothing else happens.
+- **No keep/remove-observations choice on leave** — only the warning alert (section 6.1).
+- **No `GET /v1/projects/{id}`** — `ProjectsAPI.m` (section 4) is the complete project API surface; detail screens reuse list/join/search payloads.
+- **No project-type indicator in project lists or project detail** — type label shown only in the obs-edit chooser header (sections 3.2 and 6.4).
+- **No projects or field values on the observation detail screen** — `ObsDetailV2ViewController.m` contains zero references to projects or OFVs; they appear only in the edit flow. There is no read-only display parity target in this app.
+- **No offline join/leave queue** (section 6.1).
+
+---
+
+## 9. Porting gotchas (summary)
+
+1. Traditional = any project whose `project_type` is not `collection`/`umbrella` (empty string or missing both map to OldStyle).
+2. Select is not a datatype: text/dna + more than one `allowed_values` entry; exactly one allowed value renders as free text. `allowed_values` arrives pipe-delimited.
+3. All OFV values are strings; taxon fields store the taxon id as a string; toggling a project on seeds each OFV with `allowedValues.firstObject` — a non-empty default silently satisfies "required".
+4. The `dna` datatype exists and is treated as text (`canBeTreatedAsText`).
+5. Client required-field validation exists but is unwired dead code — RN must implement it properly per the POD requirement.
+6. PO upload body is flat; OFV body is nested under `observation_field_value` — asymmetric on purpose.
+7. Strict orderings: upload photos → sounds → OFVs → POs; delete Observation → ProjectObservation → Photo → ObservationFieldValue (POs before OFVs to avoid 422s).
+8. `date` fields use a date+time picker (`UIDatePickerModeDateAndTime`) — likely a bug worth fixing in RN with a date-only picker. Formats: `dd MMM yyyy HH:mm:ss ZZZ` (date/datetime), `HH:mm:ss` (time).
+9. `saveVisibleObservationFieldValues` only persists visible rows — fragile for long field lists; do not replicate.
+10. Toggling a project ON writes to Realm immediately, even for unsaved observations; removals are staged in `recordsToDelete` and committed at save.
+11. Membership is `ExploreUserRealm.joinedProjects` (a user→projects list), not a flag on the project; checks are linear scans.
+12. `syncUserProjects` on the Projects tab deletes **all** `ExploreProjectRealm` rows before re-fetching; the chooser's variant only clears the `joinedProjects` list. Both rely on PK upsert to re-link.
+13. Client-generated lowercase UUIDs are the primary keys for PO/OFV records and are sent on create for server-side idempotency; server ids are written back to `projectObsId` / `obsFieldValueId` after upload.
+14. 404/403 on a queued DELETE is treated as success so unsynced or foreign records never block the queue.
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 3c66ae17e..4f92d231f 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -272,5 +272,32 @@ describe( "validateProjectFieldsForObservation", () => {
validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
).toBe( true );
} );
+
+ test.each( [
+ ["abc"],
+ // parseFloat would accept this, Number does not; must stay invalid
+ // to match Android Legacy's Float.valueOf
+ ["1.5abc"],
+ ] )( "should return INVALID_NUMERIC when a numeric field's OFV value is %p", value => {
+ const mockProject = {
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ datatype: "numeric",
+ id: 10,
+ name: "Count",
+ },
+ }],
+ };
+ const mockObservation = {
+ observationFieldValues: [{ obsFieldId: 10, value }],
+ };
+ const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
+ expect( result.valid ).toBe( false );
+ expect( result.errors ).toHaveLength( 1 );
+ expect( result.errors[0].fieldName ).toBe( "Count" );
+ expect( result.errors[0].reason ).toBe( INVALID_NUMERIC );
+ } );
} );
} );
From bd2683945d95fb2481be094cb1b92a309cd91ff1 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 14:52:01 +0200
Subject: [PATCH 021/108] should return INVALID_NUMERIC for an optional numeric
field with text value
---
...alidateProjectFieldsForObservation.test.js | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 4f92d231f..4c6eabc25 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -299,5 +299,24 @@ describe( "validateProjectFieldsForObservation", () => {
expect( result.errors[0].fieldName ).toBe( "Count" );
expect( result.errors[0].reason ).toBe( INVALID_NUMERIC );
} );
+
+ it( "should return INVALID_NUMERIC for an optional numeric field with text value", () => {
+ const mockProject = {
+ projectObservationFields: [{
+ required: false,
+ obsField: {
+ allowedValues: [],
+ datatype: "numeric",
+ id: 10,
+ },
+ }],
+ };
+ const mockObservation = {
+ observationFieldValues: [{ obsFieldId: 10, value: "abc" }],
+ };
+ const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
+ expect( result.valid ).toBe( false );
+ expect( result.errors[0].reason ).toBe( INVALID_NUMERIC );
+ } );
} );
} );
From 6d9475060fadfe9ed351dd7c79092f6ba4d623b8 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 14:52:53 +0200
Subject: [PATCH 022/108] should report a single MISSING_REQUIRED when a
required numeric field is empty
---
.../validateProjectFieldsForObservation.test.js | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 4c6eabc25..c1d1f8ab1 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -318,5 +318,22 @@ describe( "validateProjectFieldsForObservation", () => {
expect( result.valid ).toBe( false );
expect( result.errors[0].reason ).toBe( INVALID_NUMERIC );
} );
+
+ it( "should report a single MISSING_REQUIRED when a required numeric field is empty", () => {
+ const mockProject = {
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ datatype: "numeric",
+ id: 10,
+ },
+ }],
+ };
+ const mockObservation = { observationFieldValues: [] };
+ const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
+ expect( result.errors ).toHaveLength( 1 );
+ expect( result.errors[0].reason ).toBe( MISSING_REQUIRED );
+ } );
} );
} );
From 574b62e5b7b5fb1f2e1226c79735a42dba6b4410 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 14:54:13 +0200
Subject: [PATCH 023/108] should be valid for an optional numeric field
---
...alidateProjectFieldsForObservation.test.js | 20 +++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index c1d1f8ab1..631f79074 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -319,6 +319,26 @@ describe( "validateProjectFieldsForObservation", () => {
expect( result.errors[0].reason ).toBe( INVALID_NUMERIC );
} );
+ test.each( [
+ [[]],
+ [[{ obsFieldId: 10, value: "" }]],
+ ] )( "should be valid for an optional numeric field with OFVs %p", observationFieldValues => {
+ const mockProject = {
+ projectObservationFields: [{
+ required: false,
+ obsField: {
+ allowedValues: [],
+ datatype: "numeric",
+ id: 10,
+ },
+ }],
+ };
+ const mockObservation = { observationFieldValues };
+ expect(
+ validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
+ ).toBe( true );
+ } );
+
it( "should report a single MISSING_REQUIRED when a required numeric field is empty", () => {
const mockProject = {
projectObservationFields: [{
From 5d12a5fa9df5d44d7948515632fb55b3cc1124f7 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 14:55:45 +0200
Subject: [PATCH 024/108] Remove docs
---
docs/traditional-projects-build-plan.md | 1148 ---------
docs/traditional-projects-glossary.md | 225 --
...ional-projects-porting-analysis_Android.md | 839 -------
...ditional-projects-porting-reference_iOS.md | 2163 -----------------
4 files changed, 4375 deletions(-)
delete mode 100644 docs/traditional-projects-build-plan.md
delete mode 100644 docs/traditional-projects-glossary.md
delete mode 100644 docs/traditional-projects-porting-analysis_Android.md
delete mode 100644 docs/traditional-projects-porting-reference_iOS.md
diff --git a/docs/traditional-projects-build-plan.md b/docs/traditional-projects-build-plan.md
deleted file mode 100644
index 4a97be17f..000000000
--- a/docs/traditional-projects-build-plan.md
+++ /dev/null
@@ -1,1148 +0,0 @@
-# Traditional Projects — Engineering Build Plan
-
-Phase 2 deliverable for the **Traditional Project Support POD**: exhaustive ticket breakdown for Linear, with implementation notes, Figma references, dependencies, and point estimates.
-
-**Linear project:** [Traditional Projects in App](https://linear.app/inaturalist/project/traditional-projects-in-app-85969e27f9f8) — all implementation tickets for this feature belong in this project (team: **Mobile**).
-
-**Abbreviations:** PO = project observation, OFV = observation field value, POF = project observation field. See [traditional-projects-glossary.md](traditional-projects-glossary.md).
-
-**Audits:** [iOS porting reference](traditional-projects-porting-reference_iOS.md) · [Android porting analysis](traditional-projects-porting-analysis_Android.md)
-
-**Designs:** [Figma — Add to Projects section](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-78787&m=dev)
-
----
-
-## Summary
-
-| Phase | Points | ~Ideal days (4 pts/day) | Scope |
-|-------|--------|-------------------------|-------|
-| **Delivered / in-flight** | 74 | — | F0, A1–A4, E1, E8, B1, B2; B3, B4–B7 in progress |
-| **Phase A — Parity (remaining)** | 75 | 19 | A3c, B8, B3b, BUG, C1–C4, C3a, D1–D4, D3, E5a, E6a |
-| **Phase B — Beyond parity** | 77 | 19 | A3b, B9, C3b, E2, E3, E7, E5b, E6b, P2-2, P2-4 |
-| **Total (active)** | **226** | **57** | Excludes cancelled tickets |
-
-**Estimation:** 1 ideal engineer-day = **4 story points**. Use points for Linear sizing.
-
-**Shippable prototype:** Phase A completes classic iOS/Android parity for add-to-project, field form, save/upload, and server 422 surfacing. Basic join/leave and project detail already ship on `main` without the feature flag. Phase B starts after the parity prototype is tested.
-
-**Cancelled (traceability only, 0 pts):** P2-1 (merged into E2), P2-3 (merged into D2), P2-5 (out of scope per 2026-06-10 meeting), E4 (folded into E7 — detail subtitle only; list type already in `ProjectListItem`).
-
----
-
-## Product meeting outcomes (2026-06-10)
-
-**Attendees:** Tony Iwane, Abhas Misraraj, Johannes Klein
-
-| Topic | Decision |
-|-------|----------|
-| **Join flow** | Bottom sheet with **3 radio options** (pattern: geo-privacy sheet). About, curators, and rules live on the **public traditional project detail page** — not a separate join screen. |
-| **Leave flow** | **3-option full sheet only** — drop simple variant (`29821-81792`). |
-| **Incremental release** | Ship **add-to-project first** (feature flag) for already-joined projects; join/leave + project detail enhancements can follow. Foundation/data before UI polish. |
-| **Incomplete chooser data** | Back/save with incomplete projects → **Missing info sheet**; LEAVE keeps **only completed** projects; **clear incomplete project state** (no partial data on ObsEdit). |
-| **Project rules validation** | Show **project rules at top of chooser** with required/checkmark UI (same pattern as observation fields). Validate **client-checkable rules** (photo, sound, location, captive/cultivated, etc.) before save — **primary strategy** to avoid post-upload 422 complexity. |
-| **ObsEdit re-edit** | When editing an obs **already in a traditional project**, ObsEdit must show project requirements/fields (not only via chooser from scratch). |
-| **Hidden coords at join** | **In scope** — part of join bottom sheet (absorbs former P2-1). |
-| **422 / D3** | **Phase A (parity):** surface server 422 at upload time (classic-app behavior). **Phase B (B9):** client-side rules reduce 422 rate. |
-| **P2-5 ObsDetails OFV** | **Out of scope** — not POD scope or classic parity (Tony/Abhas confirmed). |
-| **Default select seeding** | **Do not seed** — explicit user input required (reinforces existing plan). |
-| **Upload model** | Observation uploads first; **project_observation link is created last** and can 422 — cannot block obs upload server-side for project rules. |
-
-**Action items (not blocking implementation):**
-
-- ~~**Johannes:** Spike which project rules are client-checkable vs server-only~~ — **Done** (2026-06-10); findings in [B9 spike appendix](#b9-spike-appendix--project-rules-validation) below.
-- **Tony:** Curator/admin count data for layout.
-- ~~**Abhas:** Finalize join/leave bottom sheets, project detail sections, cursor states; annotate Figma~~ — **Done** (2026-06).
-
----
-
-## Phasing rationale
-
-Work is split into **parity** (Phase A) and **beyond parity** (Phase B). Phase A ends in a **shippable prototype** that matches what the classic iOS and Android apps do. Phase B adds POD-mandated enhancements the classic apps lack and starts only after the prototype is tested.
-
-### Phase A — Classic parity (shippable prototype)
-
-Everything the classic apps support today:
-
-- Add observation to joined traditional projects (chooser, field form, all 7 field types)
-- Required-field validation before save (Android validates at picker confirm; iOS validator is dead code — RN wires C2/C3a)
-- Save PO/OFV to Realm; upload OFVs then POs; sync deletions (D2, incl. OFV clear → DELETE — iOS parity)
-- Server 422 surfacing on failed PO/OFV upload (D3 — classic apps store `validationErrorMsg` / SharedPreferences)
-- ObsDetails local project display for unsynced adds (C4)
-- Joined-projects sync triggers (A3c); post-join fetch (E8, **Done**)
-- Feature flag for beta rollout (E1, **Done**)
-
-**Already on `main` (no ticket):** basic join/leave via `ProjectDetailsContainer` (`joinProject` / `leaveProject`); project detail with type label, description, and requirements link.
-
-**Not in Phase A:** client-side project rules in chooser (B9), join/leave bottom sheets with location permissions (E2/E3), project detail re-layout (E7), upload-time pre-validation blocking (C3b), upload-time schema reconciliation (P2-2), offline join/leave queue (P2-4).
-
-### Phase B — Beyond parity (post-prototype)
-
-POD-mandated items classic apps lack:
-
-- Client-side project rules validation in chooser (B9) + rules metadata sync (A3b)
-- Pre-upload gate for membership rules (C3b)
-- Join flow with hidden-coordinate grant via bottom sheet (E2, formerly P2-1)
-- Leave flow with keep/remove observations and hidden-coordinate revoke (3-option sheet, E3)
-- Project detail About / Project Admins / inline Project Rules sections (E7)
-- Upload-time schema reconciliation (P2-2), offline join/leave queue (P2-4)
-
-### Incremental release strategy
-
-```mermaid
-flowchart LR
- R1["Release 1: Foundation + chooser UI — Done/in-flight"] --> R2["Release 2: Parity prototype — Phase A"]
- R2 --> R3["Release 3+: Beyond parity — Phase B"]
-```
-
-- **Release 1 (done/in-flight):** F0, A1–A4, E1, E8, B1, B2, B3–B7 — add-to-project UI for already-joined projects; feature flag off by default.
-- **Release 2 (Phase A):** B8, C1–C4, C3a, D1–D4, D3, E5a, E6a — save, required-field validation, upload pipeline, 422 surfacing; **shippable parity prototype**.
-- **Release 3+ (Phase B):** A3b, B9, C3b, E7, E2, E3, P2-2, P2-4, E5b, E6b — rules validation, enhanced join/leave/detail, resilience upgrades.
-
----
-
-## Key engineering decisions
-
-| Area | Decision |
-|------|----------|
-| **Joined projects cache** | Standalone Realm `Project` model with embedded `ProjectObservationField` → `ObservationField` (mirrors iOS `ExploreProjectRealm`). |
-| **Per-observation data** | Embedded `ProjectObservation` + `ObservationFieldValue` on `Observation` (same pattern as `ObservationPhoto`). |
-| **In-flight edits** | Zustand observation POJO in `createObservationFlowSlice.ts`; persist on save via `saveLocalObservationForUpload`. |
-| **Upload** | Extend `observationUploader.ts`: after obs + media → OFVs → POs (PO link last; can 422 independently). Deletes: PO before OFV. |
-| **Membership rules vs preferences** | Only `project_observation_rules` cause rule 422s on traditional `POST /v1/project_observations`. `rule_preferences` / `search_parameters` are ES/display for traditional — show in UI, **do not SAVE-gate on prefs alone**. |
-| **Project rules** | **Phase B (B9):** validate `project_observation_rules` in chooser before save/upload. **Phase A (D3):** surface server 422 when rules fail at upload time (classic-app parity). |
-| **Join/leave UI** | **Phase A:** basic join/leave on project detail (`main`). **Phase B (E2/E3):** bottom sheet with 3 radio options (geo-privacy pattern). |
-| **Incremental release** | Phase A = shippable parity prototype; Phase B = enhancements after prototype testing. |
-| **Field semantics** | Traditional = `project_type` not collection/umbrella; select = **text** + >1 `allowed_values` (`dna` = free text in RN); all values strings; **do not** seed required fields with first allowed value (iOS bug). |
-| **OFV semantics** | Global on observation; keyed by `obsFieldId` in Realm (no `projectId` on OFV); iOS `valueForObsField:` parity; upload body has no `project_id`. Schema **v70** introduced by A4. |
-| **UI** | Chooser = full stack screen; reuse `DropdownItem`, `RadioButtonSheet`, `DateTimePicker`, `TaxonSearch`. |
-
----
-
-## Figma design reference map
-
-**Status: Final (2026-06).** Section: [Add to Projects](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-78787&m=dev) (`29821:78787`).
-
-| Frame | Node | Tickets |
-|-------|------|---------|
-| [Obs Edit — No Projects Added](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80570&m=dev) | `29821:80570` | B1 |
-| [Obs Edit — Projects Added](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80653&m=dev) | `29821:80653` | B1 |
-| [Logged out state](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29967-46496&m=dev) | `29967:46496` | B1 |
-| [No Projects Selected](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80612&m=dev) | `29821:80612` | B2 |
-| [Add to Projects — No Projects](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80722&m=dev) | `29821:80722` | B2 |
-| [Project Selected — No Requirements Met](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29876-27135&m=dev) | `29876:27135` | B3, B9, C3 |
-| [Project Selected — Some Requirements Met](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29876-27108&m=dev) | `29876:27108` | B3, B9, C3 |
-| [Project Selected — All Requirements Met](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29876-27162&m=dev) | `29876:27162` | B3 |
-| [Cursor State](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-20243&m=dev) | `30019:20243` | B3, B4 |
-| [Cursor in Progress](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-20915&m=dev) | `30019:20915` | B3, B4 |
-| [Project Rules & Obs Fields — None Met](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30002-15858&m=dev) | `30002:15858` | B4–B7, B9 (catalog) |
-| [Project Rules and Obs Fields — All Met](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30021-58210&m=dev) | `30021:58210` | B4–B7, B9 (catalog) |
-| [Text String Input](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30026-58576&m=dev) | `30026:58576` | B4 |
-| [Number Input](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30026-59552&m=dev) | `30026:59552` | B4 |
-| [Date Input](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30028-13392&m=dev) | `30028:13392` | B6 |
-| [Time Input](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30028-13467&m=dev) | `30028:13467` | B6 |
-| [Date & Time Input](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30028-13542&m=dev) | `30028:13542` | B6 |
-| [Value Input (select)](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30060-87134&m=dev) | `30060:87134` | B5 |
-| [Species Search](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80733&m=dev) | `29821:80733` | B7 |
-| [Missing Info Bottom Sheet](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80736&m=dev) | `29821:80736` | C3 |
-| [Join + Location Permissions](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-21511&m=dev) | `30019:21511` | E2 |
-| [Edit Location Permissions](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-58169&m=dev) | `30019:58169` | E2, E7 |
-| [Leave Project](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-81668&m=dev) | `29821:81668` | E3 |
-| [Traditional Project — Not Joined](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-21238&m=dev) | `30019:21238` | E7 (incl. former E4 subtitle) |
-| [Traditional Project — Joined](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-21546&m=dev) | `30019:21546` | E7 |
-
-**Partial coverage:** E4 Projects tab browse list — no dedicated list-row frame; infer type label from project detail subtitle (`Traditional Project` on `30019:21238`) and existing `ProjectListItem` + `displayProjectType.ts`.
-
----
-
-## Open product questions
-
-1. **Remove all observations on leave** — What API removes a user's existing project observations? Web spike required (part of E3).
-
----
-
-# Delivered and in-flight
-
-Tickets below are **Done** or **In Progress** — not re-estimated in Phase A/B totals.
-
-| ID | Title | Pts | Status | Linear |
-|----|-------|-----|--------|--------|
-| F0 | Engineering glossary | 2 | Done | MOB-1490 |
-| A1 | API wrappers + types | 4 | Done | MOB-1491 |
-| A2 | Realm models + migration | 12 | Done | MOB-1492 |
-| A3 | Joined-projects sync (PoC) | 4 | Done | MOB-1496 |
-| A4 | Download mapping | 8 | Done | MOB-1497 |
-| E1 | Feature flag | 2 | Done | MOB-1493 |
-| E8 | Post-join offline sync | 2 | Done | MOB-1524 |
-| B1 | ObsEdit Projects row | 4 | Done | MOB-1501 |
-| B2 | Project chooser screen (UI shell) | 12 | Done | MOB-1502 |
-| B3 | Per-project field form | 8 | In Progress | MOB-1503 |
-| B4–B7 | Field input components (7 types) | 16 | In Progress | MOB-1504 |
-
-**Parity baseline already on `main` (no ticket):** `ProjectDetailsContainer` join/leave; project detail type label, description, requirements link.
-
----
-
-# Phase A — Classic parity (shippable prototype)
-
-Remaining tickets to reach classic iOS/Android parity. When complete, ship behind `TraditionalProjectsEnabled` for testing.
-
-## Workstream F — Documentation (2 pts) — Done
-
-### F0 — Engineering glossary
-
-| | |
-|---|---|
-| **Points** | 2 |
-| **Status** | **Done** — MOB-1490 |
-| **Linear labels** | `docs`, `traditional-projects`, `parity` |
-
-**Description:** Create and maintain [traditional-projects-glossary.md](traditional-projects-glossary.md) for shared vocabulary across implementers.
-
-**Acceptance criteria:**
-
-- All required terms documented with API key, RN/Realm name, classic-app equivalent, common confusion
-- "How the pieces fit together" flow diagram included
-- Cross-links to audit docs and build-plan tickets
-
----
-
-## Workstream A — Data foundation (parity remaining: 3 pts)
-
-### A1 — API wrappers and TypeScript types — Done
-
-| | |
-|---|---|
-| **Points** | 4 |
-| **Status** | **Done** — MOB-1491 |
-
-*(Acceptance criteria unchanged — see git history or MOB-1491.)*
-
----
-
-### A2 — Realm models and schema migration — Done
-
-| | |
-|---|---|
-| **Points** | 12 |
-| **Status** | **Done** — MOB-1492 |
-
-*(Acceptance criteria unchanged — see git history or MOB-1492.)*
-
----
-
-### A3 — Joined-projects sync to Realm (PoC) — Done
-
-| | |
-|---|---|
-| **Points** | 4 |
-| **Status** | **Done** — MOB-1496, PR #3767 (2026-06-25) |
-
-*(Acceptance criteria unchanged — see MOB-1496.)*
-
----
-
-### A3c — Joined-projects sync triggers and pagination
-
-| | |
-|---|---|
-| **Points** | 3 |
-| **Phase** | A (parity) |
-| **Dependencies** | A3 |
-| **Linear** | MOB-1535 (triggers, **Done**); MOB-1568 (pagination + chooser online guard) |
-| **Linear labels** | `sync`, `offline`, `traditional-projects`, `parity` |
-
-**Description:** Dedicated sync triggers and full pagination so joined projects are cached without requiring the user to visit Projects UI first. Classic apps re-fetch joined projects when opening the chooser.
-
-**MOB-1535 (Done):** `syncJoinedProjects` helper, deferred startup trigger, chooser mount trigger, empty-list prune, deferred startup user guard.
-
-**MOB-1568:** Full pagination, chooser online guard, error swallowing, pagination-aware prune rules.
-
-**Acceptance criteria:**
-
-- Paginate `fetchUserProjects({ per_page: 100, page, fields })` until all pages fetched
-- Additional triggers: deferred startup task (`useDeferredStartup`), chooser screen mount (if online), callable from E8 post-join
-- Optional `useJoinedProjects` Realm query hook: **not needed** — B2 (`AddToProjects`) reads joined traditional projects directly via `RealmContext.useQuery` on `Project`
-
-**Related:** E8 (Done), B2
-
----
-
-### A4 — Download mapping for remote observations — Done
-
-| | |
-|---|---|
-| **Points** | 8 |
-| **Status** | **Done** — MOB-1497 |
-
-*(Acceptance criteria unchanged — see MOB-1497.)*
-
----
-
-## Workstream B — ObsEdit add-to-project UI (parity remaining: 12 pts + BUG)
-
-### B1 — Projects row in ObsEdit — Done
-
-| | |
-|---|---|
-| **Points** | 4 |
-| **Status** | **Done** — MOB-1501 |
-
-*(Acceptance criteria unchanged — see MOB-1501.)*
-
----
-
-### B2 — Project chooser screen — Done (UI shell)
-
-| | |
-|---|---|
-| **Points** | 12 |
-| **Status** | **Done** — MOB-1502 (B2a UI shell) |
-| **Note** | B2b chooser persistence ships in **B8** (Phase A). |
-
-*(B2a acceptance criteria unchanged — see MOB-1502.)*
-
----
-
-### B3 — Per-project observation field form — In Progress
-
-| | |
-|---|---|
-| **Points** | 8 |
-| **Status** | **In Progress** — MOB-1503 |
-| **Linear labels** | `ui`, `traditional-projects`, `parity` |
-
-*(Acceptance criteria unchanged — see MOB-1503.)*
-
----
-
-### B3b — Per-project field form polish
-
-| | |
-|---|---|
-| **Points** | 4 |
-| **Phase** | A (parity) |
-| **Dependencies** | B3 |
-| **Linear** | MOB-1550 |
-| **Linear labels** | `ui`, `traditional-projects`, `parity` |
-
-**Description:** Figma polish for the expandable field form shell delivered in MOB-1503.
-
-**Acceptance criteria:**
-
-- Expand/collapse animation
-- Fields sorted by `position`
-- Row selection icon driven by validation state (filled checkmark when project has no required fields; global pass/fail per project for submit)
-- Text/number fields: inline cursor at placeholder position (`30019:20243`); cursor moves while typing (`30019:20915`); dismiss by tapping another field or outside keyboard
-- Supports arbitrary field count (virtualized list / nested FlashList)
-- Background color per Figma (`#f1f7e5` / grey — match design tokens)
-- Project rules rows not tappable (evaluative only — B9 in Phase B)
-
----
-
-### B4 — Field inputs: text and numeric — In Progress (MOB-1504)
-
-### B5 — Field inputs: select — In Progress (MOB-1504)
-
-### B6 — Field inputs: date, time, datetime — In Progress (MOB-1504)
-
-### B7 — Field inputs: taxon — In Progress (MOB-1504)
-
-*(Full acceptance criteria in MOB-1504 — see original B4–B7 sections in git history.)*
-
----
-
-### B8 — Zustand observation flow state for projects
-
-| | |
-|---|---|
-| **Points** | 6 |
-| **Phase** | A (parity) |
-| **Dependencies** | A4 |
-| **Linear** | MOB-1498 |
-| **Linear labels** | `state`, `traditional-projects`, `parity` |
-
-**Description:** Extend observation POJO in `createObservationFlowSlice` with project selections and OFV map. Includes **B2b chooser persistence**.
-
-**Acceptance criteria:**
-
-- `updateObservationKeys` accepts `projectObservations` and `observationFieldValues` (flat array on observation POJO, not a per-project map)
-- Chooser SAVE merges into current observation in `observations[]`
-- Toggle OFF stages **PO** removal only: track PO uuids to delete at save (synced) vs drop (never synced); do **not** delete/clear OFV when a project is toggled off
-- Survives rotation via existing observation flow patterns
-- Load initial state from Realm when editing existing obs
-- When ObsEdit opens for synced/local obs with existing PO/OFV (A4), hydrate project selections and field values into Zustand so chooser and ObsEdit row show current state
-- **B2b:** Sticky SAVE commits to Zustand and pops navigator; draft selection hydrated from existing POs on open; SAVE disabled when selection unchanged
-
----
-
-### BUG — DateTimePicker datetime date not stored
-
-| | |
-|---|---|
-| **Points** | 2 |
-| **Phase** | A (parity) |
-| **Linear** | MOB-1551 |
-| **Linear labels** | `bug`, `traditional-projects`, `parity` |
-
-**Description:** Fix pre-existing bug in `DateTimePicker.tsx` datetime two-step mode (blocks B6 datetime fields).
-
-**Acceptance criteria:**
-
-- Fix one-line state update in DateTimePicker
-- Add regression unit test
-
----
-
-## Workstream C — Save and validation (parity: 21 pts)
-
-### C1 — Save path for POs and OFVs
-
-| | |
-|---|---|
-| **Points** | 8 |
-| **Phase** | A (parity) |
-| **Dependencies** | A4, B8 |
-| **Linear** | MOB-1508 |
-| **Linear labels** | `realm`, `obs-edit`, `traditional-projects`, `parity` |
-
-**Description:** Persist project observations and field values when saving observation locally.
-
-**Acceptance criteria:**
-
-- `Observation.saveLocalObservationForUpload` writes embedded PO/OFV with `_updated_at`, `_synced_at` null for new/changed
-- New PO/OFV get client UUIDs; new OFVs have **no `projectId`** (global per `obsFieldId`)
-- Removed synced PO/OFV create tombstone / `_pending_deletion` flag for upload pipeline
-- `needs_sync` on parent observation set true when project data changes
-- Re-edit path: when user saves ObsEdit for obs with existing PO/OFV, only changed projects/fields marked dirty
-
----
-
-### C2 — Validation module (required fields)
-
-| | |
-|---|---|
-| **Points** | 4 |
-| **Phase** | A (parity) |
-| **Dependencies** | A4 |
-| **Linear** | MOB-1499 |
-| **Linear labels** | `validation`, `traditional-projects`, `parity` |
-
-**Description:** Pure functions to validate **POF/OFV required fields** before save/upload. Membership rules are **B9 (Phase B)** — separate module, separate error strings.
-
-**Acceptance criteria:**
-
-- `validateProjectFieldsForObservation(obs, projects)`: returns `{ valid, errors: [{ projectTitle, fieldName, reason }] }`
-- Required: non-empty string after trim
-- Numeric: must parse as float when non-empty
-- 2+ required POFs: all must have OFVs (matches `"Missing required observation field: {name}"` server message)
-- Multi-project: two projects sharing a field read the **same** OFV; required checks use `findForObsField` per POF
-- Unit tests in `tests/unit/` covering required, numeric, multi-project cases
-
----
-
-### C3a — Wire required-field validation gates (parity)
-
-| | |
-|---|---|
-| **Points** | 5 |
-| **Phase** | A (parity) |
-| **Dependencies** | C2, B3 |
-| **Linear** | MOB-1509 |
-| **Figma** | [`29821-80736`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-80736&m=dev) |
-| **Linear labels** | `validation`, `ui`, `traditional-projects`, `parity` |
-
-**Description:** Block chooser SAVE when required project fields fail; show Missing info sheet on back. Classic Android validates at picker confirm; iOS has dead-code validator — RN wires C2 here. **Does not include B9 membership rules gate** (see C3b, Phase B).
-
-**Acceptance criteria:**
-
-- Chooser SAVE: run field validation (C2); block pop if invalid; inline pass/fail per field row (B3)
-- Chooser back without SAVE: if invalid/incomplete selections, show "Missing info" sheet — LEAVE / KEEP EDITING
-- LEAVE on Missing info sheet: **only completed projects** persist to Zustand/ObsEdit; **clear incomplete project selections and partial field values**
-- ObsEdit Upload button: run **C2 required-field validation** before `addToUploadQueue` (like `missingBasics`)
-- Offline: validation runs locally without network
-
-**Implementation notes:**
-
-- `BottomButtonsContainer.tsx` insertion point alongside `passesEvidenceTest` / `hasIdentification`.
-- Membership rules: classic apps upload and let server 422 — handled by D3 in Phase A.
-
----
-
-### C4 — ObsDetails local project display
-
-| | |
-|---|---|
-| **Points** | 4 |
-| **Phase** | A (parity) |
-| **Dependencies** | A2 |
-| **Linear** | MOB-1500 |
-| **Linear labels** | `obs-details`, `traditional-projects`, `parity` |
-
-**Description:** Show pending/unsynced project memberships from Realm on observation detail for local observations.
-
-**Acceptance criteria:**
-
-- `ProjectSection` / `ProjectButton` read from Realm observation when local/unsynced, not only remote API
-- Indicate count including not-yet-uploaded traditional project adds
-- Navigate to project list with local data
-
----
-
-## Workstream D — Upload pipeline (parity: 32 pts)
-
-### D1 — Upload OFVs and POs after observation
-
-| | |
-|---|---|
-| **Points** | 12 |
-| **Phase** | A (parity) |
-| **Dependencies** | A1, C1 |
-| **Linear** | MOB-1510 |
-| **Linear labels** | `upload`, `traditional-projects`, `parity` |
-
-**Description:** Extend observation uploader to sync project child records.
-
-**Acceptance criteria:**
-
-- After `attachMediaToObservation` in `observationUploader.ts`: upload dirty OFVs, then dirty POs
-- OFV: POST or PUT per `wasSynced()`; nested body shape (`observation_field_id` only — no `project_id`)
-- PO: POST flat body; requires server `observation.id`
-- `markRecordUploaded` in `realmSync.ts` handles `ProjectObservation` and `ObservationFieldValue`
-- `countTotalIncrements` in upload slice includes new child ops for progress UI
-- Skip children when parent obs has no server id yet (retry next upload)
-
----
-
-### D2 — Deletion sync for POs and OFVs
-
-| | |
-|---|---|
-| **Points** | 8 |
-| **Phase** | A (parity) |
-| **Dependencies** | D1 |
-| **Linear** | MOB-1511 |
-| **Linear labels** | `upload`, `traditional-projects`, `parity` |
-
-**Description:** Sync removals of project links and field values to server before uploads. Absorbs former **P2-3** (OFV clear → DELETE is iOS parity).
-
-**Acceptance criteria:**
-
-- Process tombstoned/deleted PO before OFV (iOS `deletedRecordsNeedingSync` order)
-- `DELETE /v1/project_observations/{id}` and `DELETE /v1/observation_field_values/{id}`
-- OFV DELETE when user **clears** a synced field value (or explicit remove), not when removing a PO — **P2-3 scope**
-- 404/403 treated as success (iOS audit §5.3)
-- Local Realm records removed after successful delete sync
-
----
-
-### D3 — Server 422 surfacing (parity)
-
-| | |
-|---|---|
-| **Points** | 4 |
-| **Phase** | A (parity) |
-| **Dependencies** | D1 |
-| **Linear** | MOB-1512 |
-| **Linear labels** | `upload`, `errors`, `traditional-projects`, `parity` |
-
-**Description:** Surface project validation failures from server during upload. Classic iOS stores `validationErrorMsg`; Android uses SharedPreferences per obs+project. **Phase A primary defense** until B9 ships in Phase B.
-
-**Acceptance criteria:**
-
-- On 422 from PO or OFV upload: parse `errors[]`, set per-obs message on observation (e.g. `validationErrorMsg` field or upload slice)
-- Message includes project title when PO fails: "Couldn't be added to project {title}. {error}"
-- No dedicated MyObs error UI unless product requires — minimal surfacing acceptable
-- Failed PO link does not block observation upload retry; manual retry after user fixes fields
-- Clear validation message on re-save / re-upload attempt
-
-**Implementation notes:**
-
-- Covers all membership rule failures at upload time (classic behavior). B9 (Phase B) adds client-side pre-check to reduce 422 rate.
-
----
-
-### D4 — Multi-obs and edge-case QA
-
-| | |
-|---|---|
-| **Points** | 8 |
-| **Phase** | A (parity) |
-| **Dependencies** | D1, D2 |
-| **Linear** | MOB-1513 |
-| **Linear labels** | `qa`, `traditional-projects`, `parity` |
-
-**Description:** Harden upload/edit flows for synced observations, multi-obs carousel, and id remapping.
-
-**Acceptance criteria:**
-
-- Edit synced observation: add/remove projects, upload deltas only
-- Multi-observation flow: each obs carries independent project state
-- Local observation id → server id remaps PO/OFV `observation_id` references (Android `ObservationProvider` pattern)
-- Manual test checklist documented in ticket/PR
-
----
-
-## Workstream E — Release (parity: 7 pts)
-
-### E1 — Feature flag — Done
-
-| | |
-|---|---|
-| **Points** | 2 |
-| **Status** | **Done** — MOB-1493 |
-
----
-
-### E5a — i18n strings (parity)
-
-| | |
-|---|---|
-| **Points** | 1 |
-| **Phase** | A (parity) |
-| **Linear** | MOB-1495 |
-| **Linear labels** | `i18n`, `traditional-projects`, `parity` |
-
-**Description:** Add parity-scope user-facing strings to `src/i18n/strings.ftl`.
-
-**Acceptance criteria:**
-
-- Chooser labels, field placeholders, validation errors
-- Missing info sheet body (LEAVE / KEEP EDITING)
-- Logged-out alert on ObsEdit (`29967:46496`)
-- Run i18n CLI to regenerate locale JSON
-
----
-
-### E6a — Integration tests (parity prototype)
-
-| | |
-|---|---|
-| **Points** | 6 |
-| **Phase** | A (parity) |
-| **Dependencies** | D1, C3a |
-| **Linear** | MOB-1516 |
-| **Linear labels** | `tests`, `traditional-projects`, `parity` |
-
-**Description:** End-to-end tests for **parity** traditional project flows — sufficient to sign off the shippable prototype.
-
-**Acceptance criteria:**
-
-- Factoria factories: `ProjectWithFields`, `ObservationWithProjectFields`
-- Integration test: open ObsEdit → chooser → toggle project → fill required field → save → verify Realm
-- Integration test: C2 validation blocks upload when required field empty
-- Integration test: offline save persists PO/OFV locally
-- Integration test: D3 surfaces 422 message when PO upload fails rules
-- Tests in `tests/integration/` following `renderApp` patterns
-
----
-
-# Phase B — Beyond parity (post-prototype)
-
-Starts after Phase A parity prototype is tested. All tickets below are enhancements the classic apps lack.
-
-## Workstream A — Rules metadata (5 pts)
-
-### A3b — Joined-projects rules metadata sync
-
-| | |
-|---|---|
-| **Points** | 5 |
-| **Phase** | B (beyond) |
-| **Dependencies** | A3 |
-| **Linear** | MOB-1534 |
-| **Linear labels** | `sync`, `offline`, `realm`, `traditional-projects`, `beyond-parity` |
-
-**Description:** Extend A3 PoC to persist membership rules metadata for offline B9 validation and E7 project detail sections.
-
-**Acceptance criteria:**
-
-- Extend Realm `Project` schema (likely **v71**, after A4 v70): `project_observation_rules[]`, `rule_preferences[]`, `search_parameters[]`, bioblitz `start_time`/`end_time`
-- Fetch with `rule_details: true` + expanded field set (match `ProjectRequirements.tsx` pattern)
-- Map and persist rule operands; cache `taxon.ancestor_ids` / list taxon IDs when API provides them; defer to D3 fallback when omitted
-- Extend `Project.mapApiToRealm` / upsert path — reuse `Project.upsertRemoteProjects`
-
-**Blocks:** B9, E7 (offline rules sections)
-
----
-
-## Workstream B — Client-side project rules (10 pts)
-
-### B9 — Client-side project rules in chooser
-
-| | |
-|---|---|
-| **Points** | 10 |
-| **Phase** | B (beyond) |
-| **Dependencies** | B3, C2, A3b |
-| **Linear** | MOB-1522 |
-| **Figma** | [`29876-27135`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29876-27135&m=dev), [`30002-15858`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30002-15858&m=dev), [`30021-58210`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30021-58210&m=dev) |
-| **Linear labels** | `validation`, `ui`, `traditional-projects`, `beyond-parity` |
-
-**Description:** Display membership rules and preferences at top of each toggled project's field form; validate `project_observation_rules` before SAVE (not `rule_preferences` alone). Classic apps do not validate rules client-side.
-
-**Acceptance criteria:**
-
-- **Two UI sections** per expanded project (rules first, obs fields second):
- 1. **Membership rules** — from `project_observation_rules` (pass/fail indicators; evaluative from obs state; **not tappable**; gates SAVE)
- 2. **Project preferences** — from `rule_preferences` (informational; reuse `ProjectRequirements.tsx` wording; **no SAVE block** on prefs alone)
- 3. **Obs fields** — below rules; pass/fail depends on user input; tappable rows (B4–B7)
-- Implement `validateProjectRules(obs, project)` pure function with OR-within-operator / AND-across-operator semantics (see [appendix](#b9-spike-appendix--project-rules-validation))
-- **P0 validators:** `identified?`, `georeferenced?`, `has_a_photo?`, `has_a_sound?`, `has_media?`, `in_taxon?`, `not_in_taxon?`, `verifiable?`
-- **P1 validators:** `wild?`, `captive?`, `observed_after?`, `observed_before?`, bioblitz window; non-rule: duplicate PO, collection/umbrella block, observer privacy
-- **P2 (optional):** `on_list?` if list taxa cached; `rule_preferences` date/month as UI warning only (not hard block)
-- **Defer to D3:** `observed_in_place?`, `coordinates_shareable_by_project_curators?`, establishment prefs, `members_only` (badge only)
-- Inline warnings when rules fail; block chooser SAVE when P0/P1 membership rules fail
-- Unit tests: 5 spike test cases + OR/AND combination cases in `tests/unit/`
-
----
-
-## Workstream C — Rules validation wiring (3 pts)
-
-### C3b — Wire project-rules validation gates (beyond)
-
-| | |
-|---|---|
-| **Points** | 3 |
-| **Phase** | B (beyond) |
-| **Dependencies** | C3a, B9 |
-| **Linear** | MOB-1561 |
-| **Linear labels** | `validation`, `ui`, `traditional-projects`, `beyond-parity` |
-
-**Description:** Extend C3a gates with B9 membership rules validation. Block upload when client-checkable project rules fail — enhancement beyond classic apps (which upload and 422).
-
-**Acceptance criteria:**
-
-- Chooser SAVE: run C2 + B9; block pop if invalid; inline pass/fail per rule row (B9)
-- ObsEdit Upload button: run B9 validation in addition to C2
-- Gate order: C2 (POF/OFV fields) → B9 (membership rules) → pop/block upload
-
----
-
-## Workstream E — Join/leave, detail, release (41 pts)
-
-### E2 — Join flow: bottom sheet
-
-| | |
-|---|---|
-| **Points** | 10 |
-| **Phase** | B (beyond) |
-| **Dependencies** | E7 |
-| **Linear** | MOB-1514 |
-| **Figma** | [`30019-21511`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-21511&m=dev), [`30019-58169`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-58169&m=dev) |
-| **Linear labels** | `projects`, `ui`, `traditional-projects`, `beyond-parity` |
-
-**Description:** Join traditional project via location-permissions bottom sheet (3 radio options, geo-privacy pattern). Absorbs former P2-1. **Parity baseline:** basic join on project detail already ships on `main`.
-
-**Acceptance criteria:**
-
-- Tapping Join on project detail opens `30019:21511` bottom sheet: 3 location-permission radio options (web parity)
-- **Confirm & Join** sets location permissions AND adds user to project in one action
-- User reads About, Project Admins, and Project Rules on project detail page (E7) before joining
-- Joined traditional projects: **Edit location permissions** button opens `30019:58169`
-- On join success: `POST join` with selected options; triggers **E8** post-join sync
-- Optimistic UI with rollback on join API failure
-
----
-
-### E3 — Leave flow with retention options
-
-| | |
-|---|---|
-| **Points** | 12 |
-| **Phase** | B (beyond) |
-| **Dependencies** | A3 |
-| **Linear** | MOB-1515 |
-| **Figma** | [`29821-81668`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=29821-81668&m=dev) |
-| **Linear labels** | `projects`, `ui`, `traditional-projects`, `beyond-parity` |
-
-**Description:** Leave project sheet with observation retention and hidden-coordinate options. **Parity baseline:** generic leave confirm on `main`.
-
-**Acceptance criteria:**
-
-- **3-option full sheet only:** (1) leave obs in project, curators keep coord access (2) leave obs, revoke hidden coord access (3) remove all user's obs from project
-- CANCEL / LEAVE (destructive) buttons
-- On success: `DELETE leave`, remove `Project` from Realm joined cache
-- **Spike (2 pts included):** document web API for option 3 and `prefers_curator_coordinate_access` update for option 2
-
----
-
-### E7 — Project detail page — sections and membership UI
-
-| | |
-|---|---|
-| **Points** | 10 |
-| **Phase** | B (beyond) |
-| **Dependencies** | A3, A3b |
-| **Linear** | MOB-1523 |
-| **Figma** | [`30019-21238`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-21238&m=dev), [`30019-21546`](https://www.figma.com/design/MChpvx4ZrKEVVwsWKt4lkI/iNaturalist-Mobile-UI-Design?node-id=30019-21546&m=dev) |
-| **Linear labels** | `projects`, `ui`, `traditional-projects`, `beyond-parity` |
-
-**Description:** Re-layout `ProjectDetails.tsx`: About, Project Admins, Project Rules (traditional only), membership UI. **Parity baseline:** type label, description, requirements link already on `main`.
-
-**Acceptance criteria:**
-
-- **All project types:** project-type subtitle under title per Figma `30019:21238`
-- **All project types (joined):** **Manage Membership** heading; **Leave Project** button label
-- **Traditional only:** About section, Project Admins section, inline Project Rules section
-- **Traditional joined:** **Edit location permissions** entry → E2 edit sheet
-- Not joined: Join Project CTA → E2 bottom sheet
-
----
-
-### E5b — i18n strings (beyond parity)
-
-| | |
-|---|---|
-| **Points** | 1 |
-| **Phase** | B (beyond) |
-| **Linear** | MOB-1562 |
-| **Linear labels** | `i18n`, `traditional-projects`, `beyond-parity` |
-
-**Description:** Add beyond-parity strings: join/leave location-permission options, project detail section headings, rules validation messages.
-
-**Acceptance criteria:**
-
-- Location-permission option labels (join + edit sheets)
-- "Confirm & Join", "Manage Membership", "Leave Project", "Edit location permissions"
-- Project Admins / Project Rules section headings
-- B9 rule failure messages
-
----
-
-### E6b — Integration tests (beyond parity)
-
-| | |
-|---|---|
-| **Points** | 6 |
-| **Phase** | B (beyond) |
-| **Dependencies** | D1, C3b, E2, E3 |
-| **Linear** | MOB-1563 |
-| **Linear labels** | `tests`, `traditional-projects`, `beyond-parity` |
-
-**Description:** End-to-end tests for **beyond-parity** flows — run after Phase B features land.
-
-**Acceptance criteria:**
-
-- Integration test: B9 blocks chooser SAVE when membership rule fails
-- Integration test: join bottom sheet with location permission selection
-- Integration test: leave sheet with 3 retention options
-- Integration test: project detail shows About/Admins/Rules sections
-
----
-
-## Workstream P2 — Resilience (20 pts)
-
-### P2-2 — Upload-time schema reconciliation
-
-| | |
-|---|---|
-| **Points** | 8 |
-| **Phase** | B (beyond) |
-| **Dependencies** | D1, C2 |
-| **Linear** | MOB-1518 |
-| **Linear labels** | `upload`, `beyond-parity` |
-
-**Description:** Re-fetch `project_observation_fields` before upload; re-validate OFVs against fresh schema. Neither classic app does this.
-
-**Acceptance criteria:**
-
-- Before uploading PO/OFV for an obs, refresh field definitions for selected projects if stale
-- Re-run validation; block upload with actionable message if new required field added server-side
-
----
-
-### P2-4 — Offline join/leave queue
-
-| | |
-|---|---|
-| **Points** | 12 |
-| **Phase** | B (beyond) |
-| **Dependencies** | E2, E8, E3 |
-| **Linear** | MOB-1520 |
-| **Linear labels** | `offline`, `beyond-parity` |
-
-**Description:** Queue join/leave when offline; replay when online. Classic apps hard-block offline.
-
-**Acceptance criteria:**
-
-- Optimistic UI with rollback on failure
-- Persist pending join/leave ops in Realm or MMKV
-
----
-
-# Cancelled tickets
-
-| ID | Reason | Linear |
-|----|--------|--------|
-| P2-1 | Merged into E2 (hidden coords at join) | MOB-1517 |
-| P2-3 | Merged into D2 (OFV clear → DELETE is iOS parity) | MOB-1519 |
-| P2-5 | Out of scope (ObsDetails OFV display) | MOB-1521 |
-| E4 | Folded into E7 (detail subtitle) | MOB-1494 |
-
----
-
-## B9 spike appendix — project rules validation
-
-**Source:** Rails server spike (2026-06-10). Traditional PO create validates via `validates_rules_from :project` in `lib/ruler/ruler/has_rules_for.rb` — evaluates **`project_observation_rules` only**, not `rule_preferences`.
-
-### Executive summary
-
-| Category | Count |
-|----------|-------|
-| Rules that can 422 at PO create | ~25 (operators + non-rule PO validations) |
-| Client-checkable (yes) | 10 |
-| Client-checkable (partial) | 9 |
-| Server-only | 6 |
-| `rule_preferences` (display on traditional, not PO-validated) | 11 |
-
-### Rule combination semantics
-
-- **Same `operator`** → **OR** (any one rule in group passes)
-- **Different operators** → **AND** (all operator groups must pass)
-- **422 shape:** `{ errors: ["Didn't pass rule: …"] }` or `"Didn't pass rules: A OR B"`
-
-### Recommended validators (B9 priority)
-
-| Priority | Operators / checks | Client-checkable |
-|----------|-------------------|------------------|
-| **P0** | `identified?`, `georeferenced?`, `has_a_photo?`, `has_a_sound?`, `has_media?`, `in_taxon?`, `not_in_taxon?`, `verifiable?` | yes / partial |
-| **P1** | `wild?`, `captive?`, `observed_after?`, `observed_before?`, bioblitz window; duplicate PO; collection/umbrella block; observer privacy | yes / partial |
-| **P2** | `on_list?` (if list cached); `rule_preferences` date/month (warning only) | partial |
-| **Defer** | `observed_in_place?`, `coordinates_shareable_by_project_curators?`, establishment prefs, `members_only` | no |
-
-### Key operator checks (membership rules)
-
-| Operator | Check | Client? |
-|----------|-------|---------|
-| `identified?` | `taxon_id` present (any rank) | yes |
-| `georeferenced?` | lat/lng or private_lat/private_lng (non-zero) | yes |
-| `has_a_photo?` / `has_a_sound?` / `has_media?` | persisted media counts | partial (timing race) |
-| `verifiable?` | `quality_grade IN ('needs_id','research')` | partial (stale QG) |
-| `in_taxon?` / `not_in_taxon?` | taxon ancestry match | partial (needs `ancestor_ids`) |
-| `wild?` / `captive?` | `captive_cultivated` / quality metrics | partial |
-| `observed_after?` / `observed_before?` | `time_observed_at` / `observed_on` vs operand | yes |
-| `observed_in_place?` | PostGIS point-in-polygon | **no** |
-| `on_list?` | exact `listed_taxa.taxon_id` match | partial (needs cached list) |
-
-### `rule_preferences` (display only on traditional PO create)
-
-Shown in chooser/E7 for UI parity; **not SAVE-gated**: `quality_grade`, `photos`, `sounds`, `d1`, `d2`, `observed_on`, `month`, `native`, `introduced`, `members_only`, annotation terms. Traditional projects enforce equivalents via `project_observation_rules` operators (e.g. `verifiable?` not `quality_grade` pref).
-
-### POF/OFV validation (C2 — separate from B9)
-
-| Scenario | 422 message |
-|----------|-------------|
-| 1 required POF | Auto `has_observation_field?` rule |
-| 2+ required POFs | `"Missing required observation field: {name}"` |
-
-Upload order: observation → media → **OFVs** → **POs** last.
-
-### Server-only fallback (D3)
-
-Rules that may still 422 after B9 client checks:
-
-- `observed_in_place?` (PostGIS geometry)
-- `coordinates_shareable_by_project_curators?` (runtime `ProjectUser` prefs)
-- Stale `verifiable?` / quality grade vs server `get_quality_grade`
-- `has_a_photo?` / `has_a_sound?` media join timing race
-- Stale/missing taxon ancestry for `in_taxon?`
-- Observer privacy / invite-only / curators-only when submitter ≠ observer
-- Misconfigured collection-only operators (`observed_by_user?`, `in_project?`)
-
-### API gaps (server backlog)
-
-- `rule_preferences` displayed but not PO-enforced on traditional — client validating prefs may over-block
-- Place rules lack geometry in mobile cache — cannot offline-validate `observed_in_place?`
-- Taxon rules may lack `ancestor_ids` in default payload — include when `rule_details=true`
-- `on_list?` needs `project_list_taxon_ids[]` on project when `rule_details=true`
-
-### Test cases
-
-1. **Pass** — `in_taxon?` (Aves) + `georeferenced?`; obs has child taxon + coords → 200 PO
-2. **Fail** — `in_taxon?` + `georeferenced?`; obs has taxon, no coords → 422 `"must be georeferenced"`
-3. **Fail** — `verifiable?`; obs casual despite photo+coords+date → 422 `"must be verifiable"`
-4. **Edge** — `has_a_photo?` after upload race (PO before photo join) → 422; client may false-pass
-5. **Edge** — `observed_in_place?` with obscured private coords inside place → 200; client checking public coords only would false-negative
-
-### `validateProjectRules` pseudocode
-
-```javascript
-function validateProjectRules(obs, project) {
- const errors = [];
- const rulesByOperator = groupBy(project.project_observation_rules, "operator");
- for (const [operator, rules] of rulesByOperator) {
- const passed = rules.some(rule => evaluateRule(obs, project, rule));
- if (!passed) {
- errors.push(formatRuleTerms(rules)); // OR-join wording per server
- }
- }
- return errors;
-}
-```
-
-### Rails source index
-
-| File | Role |
-|------|------|
-| `app/controllers/project_observations_controller.rb` | PO create API, 422 JSON |
-| `app/models/project_observation.rb` | Rule methods + non-rule validations |
-| `lib/ruler/ruler/has_rules_for.rb` | AND/OR combination, error messages |
-| `app/models/project_observation_rule.rb` | Operator definitions, `terms` strings |
-| `app/models/project.rb` | `RULE_PREFERENCES`, aggregation |
-| `app/models/observation.rb` | `verifiable?`, `georeferenced?`, `captive_cultivated?` |
-| `app/models/project_observation_field.rb` | Required POF → `has_observation_field?` rule |
-| `spec/models/project_observation_rule_spec.rb` | OR/AND semantics tests |
-
----
-
-## Dependency graph
-
-```mermaid
-flowchart TD
- subgraph phaseA [Phase A — Parity prototype]
- A3c[A3c Sync triggers] --> B8[B8 Zustand + B2b]
- A4[A4 Done] --> B8
- B3[B3 Field form] --> B3b[B3b Polish]
- B3 --> C3a[C3a Field validation gates]
- B8 --> C1[C1 Save path]
- C2[C2 Required fields] --> C3a
- B3 --> C3a
- C1 --> D1[D1 Upload OFV/PO]
- D1 --> D2[D2 Deletion sync]
- D1 --> D3[D3 422 surfacing]
- D1 --> D4[D4 Edge QA]
- C3a --> E6a[E6a Parity tests]
- D1 --> E6a
- end
- subgraph phaseB [Phase B — Beyond parity]
- A3b[A3b Rules metadata] --> B9[B9 Project rules]
- B9 --> C3b[C3b Rules validation gates]
- C3a --> C3b
- A3b --> E7[E7 Project detail]
- E7 --> E2[E2 Join sheet]
- E2 --> E3[E3 Leave sheet]
- E2 --> P24[P2-4 Offline join/leave]
- E3 --> P24
- D1 --> P22[P2-2 Schema reconciliation]
- C3b --> E6b[E6b Beyond tests]
- E2 --> E6b
- end
- A3[A3 PoC Done] --> A3c
- A3 --> A3b
- B2[B2 Chooser Done] --> B3
-```
-
----
-
-## Parallelization and milestones
-
-### Phase A milestones (parity prototype)
-
-| Milestone | Tickets | Pts |
-|-----------|---------|-----|
-| **TPOD-A1 Parity: chooser persistence** | A3c, B8, B3b, BUG | 15 |
-| **TPOD-A2 Parity: save + upload** | C1, C2, C3a, C4, D1, D2, D3, D4, E5a, E6a | 60 |
-
-**Shippable prototype gate:** TPOD-A1 + TPOD-A2 complete + B3/B4–B7 (in progress) → test behind feature flag.
-
-### Phase B milestones (beyond parity — after prototype tested)
-
-| Milestone | Tickets | Pts |
-|-----------|---------|-----|
-| **TPOD-B1 Beyond: rules validation** | A3b, B9, C3b, E5b | 19 |
-| **TPOD-B2 Beyond: join/leave/detail** | E7, E2, E3, E6b, P2-2, P2-4 | 58 |
-
-### Completed milestones (unchanged)
-
-| Milestone | Tickets | Status |
-|-----------|---------|--------|
-| TPOD-M0 Foundation | F0, A1, A2, E1 | Done |
-| TPOD-M1 Sync & Realm (partial) | A3, A4, E8 | Done |
-| TPOD-M2 Chooser UI (partial) | B1, B2, B3, B4–B7 | Done / in progress |
-
-### Parallelization notes
-
-- **Phase A critical path:** B3/B4–B7 (in flight) → B8 → C1 → D1 → D3 → E6a
-- **Phase A parallel:** A3c, B3b, BUG, C2, C4, E5a alongside critical path
-- **Phase B critical path:** A3b → B9 → C3b; E7 → E2 → E3 → E6b
-- **Phase B parallel:** P2-2, P2-4, E5b after their dependencies land
-
-### Suggested Linear labels
-
-`traditional-projects`, `parity`, `beyond-parity`, plus area labels: `api`, `realm`, `upload`, `obs-edit`, `ui`, `validation`, `projects`, `tests`, `docs`, `feature-flag`, `offline`, `needs-product`, `needs-design`
-
-*(Legacy labels `phase-1` / `phase-2` map to `parity` / `beyond-parity`.)*
-
----
-
-## Ticket index (quick reference)
-
-| ID | Title | Pts | Phase | Deps | Linear |
-|----|-------|-----|-------|------|--------|
-| F0 | Engineering glossary | 2 | Done | — | MOB-1490 |
-| A1 | API wrappers + types | 4 | Done | — | MOB-1491 |
-| A2 | Realm models + migration | 12 | Done | — | MOB-1492 |
-| A3 | Joined-projects sync (PoC) | 4 | Done | A1, A2 | MOB-1496 |
-| A3c | Sync triggers + pagination | 3 | A | A3 | MOB-1535 |
-| A4 | Download mapping | 8 | Done | A2 | MOB-1497 |
-| A3b | Rules metadata sync | 5 | B | A3 | MOB-1534 |
-| B1 | ObsEdit Projects row | 4 | Done | E1 | MOB-1501 |
-| B2 | Project chooser screen | 12 | Done | A3 | MOB-1502 |
-| B3 | Per-project field form | 8 | In Progress | B2, A4 | MOB-1503 |
-| B3b | Field form polish | 4 | A | B3 | MOB-1550 |
-| B4–B7 | Field inputs (7 types) | 16 | In Progress | B3 | MOB-1504 |
-| B8 | Zustand project state + B2b | 6 | A | A4 | MOB-1498 |
-| BUG | DateTimePicker datetime bug | 2 | A | — | MOB-1551 |
-| B9 | Client-side project rules | 10 | B | B3, C2, A3b | MOB-1522 |
-| C1 | Save POs/OFVs | 8 | A | A4, B8 | MOB-1508 |
-| C2 | Validation module (required fields) | 4 | A | A4 | MOB-1499 |
-| C3a | Required-field validation gates | 5 | A | C2, B3 | MOB-1509 |
-| C3b | Project-rules validation gates | 3 | B | C3a, B9 | MOB-1561 |
-| C4 | ObsDetails local projects | 4 | A | A2 | MOB-1500 |
-| D1 | Upload OFVs + POs | 12 | A | A1, C1 | MOB-1510 |
-| D2 | Deletion sync (+ P2-3) | 8 | A | D1 | MOB-1511 |
-| D3 | Server 422 surfacing | 4 | A | D1 | MOB-1512 |
-| D4 | Multi-obs edge QA | 8 | A | D1, D2 | MOB-1513 |
-| E1 | Feature flag | 2 | Done | — | MOB-1493 |
-| E8 | Post-join offline sync | 2 | Done | A3 | MOB-1524 |
-| E5a | i18n strings (parity) | 1 | A | — | MOB-1495 |
-| E5b | i18n strings (beyond) | 1 | B | — | MOB-1562 |
-| E6a | Integration tests (parity) | 6 | A | D1, C3a | MOB-1516 |
-| E6b | Integration tests (beyond) | 6 | B | D1, C3b, E2, E3 | MOB-1563 |
-| E2 | Join bottom sheet | 10 | B | E7 | MOB-1514 |
-| E3 | Leave retention sheet | 12 | B | A3 | MOB-1515 |
-| E7 | Project detail sections | 10 | B | A3, A3b | MOB-1523 |
-| P2-2 | Upload-time reconciliation | 8 | B | D1, C2 | MOB-1518 |
-| P2-4 | Offline join/leave queue | 12 | B | E2, E8, E3 | MOB-1520 |
-| P2-1 | Hidden coords at join | 0 | Cancelled | → E2 | MOB-1517 |
-| P2-3 | OFV delete propagation | 0 | Cancelled | → D2 | MOB-1519 |
-| P2-5 | OFV on ObsDetails | 0 | Cancelled | — | MOB-1521 |
-| E4 | Project type indicator | 0 | Cancelled | → E7 | MOB-1494 |
-
-**Linear mapping (MOB-1490 – MOB-1563):**
-
-| Ticket | Linear | Ticket | Linear |
-|--------|--------|--------|--------|
-| F0 | MOB-1490 | C1 | MOB-1508 |
-| A1 | MOB-1491 | C3a | MOB-1509 |
-| A2 | MOB-1492 | C3b | MOB-1561 |
-| E1 | MOB-1493 | D1 | MOB-1510 |
-| E4 | MOB-1494 (cancelled → E7) | D2 | MOB-1511 |
-| E5a | MOB-1495 | D3 | MOB-1512 |
-| E5b | MOB-1562 | D4 | MOB-1513 |
-| A3 | MOB-1496 (Done) | E2 | MOB-1514 |
-| A3b | MOB-1534 | E3 | MOB-1515 |
-| A3c | MOB-1535 | E6a | MOB-1516 |
-| A4 | MOB-1497 | E6b | MOB-1563 |
-| B8 | MOB-1498 | P2-1 | MOB-1517 (cancelled) |
-| C2 | MOB-1499 | P2-2 | MOB-1518 |
-| C4 | MOB-1500 | P2-3 | MOB-1519 (cancelled → D2) |
-| B1 | MOB-1501 | P2-4 | MOB-1520 |
-| B2 | MOB-1502 | P2-5 | MOB-1521 (cancelled) |
-| B3 | MOB-1503 | B9 | MOB-1522 |
-| B3b | MOB-1550 | E7 | MOB-1523 |
-| BUG | MOB-1551 | E8 | MOB-1524 (Done) |
-| B4–B7 | MOB-1504 | | |
-| B5 | MOB-1505 (canceled → MOB-1504) | | |
-| B6 | MOB-1506 (canceled → MOB-1504) | | |
-| B7 | MOB-1507 (canceled → MOB-1504) | | |
diff --git a/docs/traditional-projects-glossary.md b/docs/traditional-projects-glossary.md
deleted file mode 100644
index 9beef44bf..000000000
--- a/docs/traditional-projects-glossary.md
+++ /dev/null
@@ -1,225 +0,0 @@
-# Traditional Projects — Engineering Glossary
-
-Reference for mobile engineers implementing Traditional Project support in the React Native app. Abbreviations used across tickets: **PO** = project observation, **OFV** = observation field value, **POF** = project observation field.
-
-**Linear project:** [Traditional Projects in App](https://linear.app/inaturalist/project/traditional-projects-in-app-85969e27f9f8)
-
-Related docs:
-
-- [traditional-projects-build-plan.md](traditional-projects-build-plan.md) — ticket breakdown
-- [traditional-projects-porting-reference_iOS.md](traditional-projects-porting-reference_iOS.md) — iOS audit
-- [traditional-projects-porting-analysis_Android.md](traditional-projects-porting-analysis_Android.md) — Android audit
-
----
-
-## How the pieces fit together
-
-```mermaid
-flowchart LR
- Join[User joins project] --> Cache[Project + POFs cached in Realm]
- Cache --> Chooser[User opens Add to Projects on obs edit]
- Chooser --> Toggle[Toggle traditional project ON]
- Toggle --> Rules[Check membership rules B9]
- Rules --> OFVs[User fills OFVs per POF]
- OFVs --> Save[Save observation locally]
- Save --> Upload[Upload: obs → media → OFVs → POs]
- Upload --> PO[POST project_observation links obs to project]
-```
-
-1. User **joins** a project (online) → project metadata and **project observation fields** (POFs) are cached locally.
-2. While editing an observation, user opens **Add to Projects** and toggles one or more **traditional** joined projects on.
-3. For each toggled project, the app shows **membership rules** (B9 gate) and **POFs**; user enters **OFVs** (answers).
-4. On save, **PO** and **OFV** records are written to Realm with dirty/sync flags.
-5. On upload, after the observation exists server-side: **OFVs** upload first, then **POs** (server validates `project_observation_rules` and required POFs on PO create).
-
----
-
-## Terms (alphabetical)
-
-### `allowed_values`
-
-| | |
-|---|---|
-| **Definition** | Pipe-delimited list of permitted answers for a text or DNA observation field. |
-| **API** | `observation_field.allowed_values` — string like `"male\|female\|unknown"`. |
-| **RN / Realm** | Stored on embedded `ObservationField.allowedValues` (string array after split). |
-| **Classic apps** | iOS: split on `\|` in Mantle transformer; Android: `allowed_values.split("\\|")`. |
-| **Confusion** | Not present on numeric/date/taxon types. Multiple values on text/dna ⇒ UI treats field as **select**, not free text. |
-| **Tickets** | B4, B5; see iOS audit §2.2, Android §5. |
-
-### Collection project
-
-| | |
-|---|---|
-| **Definition** | Project type that **automatically** includes observations matching query rules. Users cannot manually add/remove obs. |
-| **API** | `project_type: "collection"`. |
-| **RN / Realm** | `ApiProject.project_type === "collection"`; read-only in chooser. |
-| **Classic apps** | iOS `ExploreProjectTypeCollection`; Android `PROJECT_TYPE_COLLECTION`. |
-| **Confusion** | Membership on an observation appears as `non_traditional_projects` in API responses — **not** via `project_observations`. |
-| **Tickets** | B2, B3; iOS audit §2.1, §3.2. |
-
-### Dirty flags / tombstone
-
-| | |
-|---|---|
-| **Definition** | Local state tracking whether a record needs upload or deletion on the server. |
-| **API** | N/A (client-only). |
-| **RN / Realm** | `_synced_at`, `_updated_at`, `needs_sync` on Observation; same pattern on embedded PO/OFV. Staged removals use tombstone/deleted-record pattern (see upload pipeline). |
-| **Classic apps** | iOS: `timeSynced` / `timeUpdatedLocally`, `ExploreDeletedRecord`; Android: `is_new` / `is_deleted`, `_synced_at` / `_updated_at`. |
-| **Confusion** | `needs_sync` on Observation does not yet include PO/OFV children — extend in A2, C1, D1. |
-| **Tickets** | A2, C1, D1, D2; iOS audit §5.1–5.3, Android §2. |
-
-### Joined project
-
-| | |
-|---|---|
-| **Definition** | A project the **signed-in user** is a member of. Distinct from an observation being *in* a project. |
-| **API** | `GET /v1/users/{userId}/projects` (paginated, includes `project_observation_fields`). |
-| **RN / Realm** | Cached in standalone `Project` Realm objects; membership list on User or query all `Project` rows synced from that endpoint. |
-| **Classic apps** | iOS: `ExploreUserRealm.joinedProjects`; Android: `projects` table wiped and re-filled on sync. |
-| **Confusion** | Joined ≠ observation is in project. Chooser only lists joined traditional projects for manual toggle. |
-| **Tickets** | A3, B2, E8 (post-join cache), E2 (join UI); iOS audit §2.5, §4, Android §6. |
-
-### `non_traditional_projects`
-
-| | |
-|---|---|
-| **Definition** | On an observation payload: collection/umbrella projects the obs is auto-included in (computed server-side). |
-| **API** | `observation.non_traditional_projects[]` with nested `project`. |
-| **RN / Realm** | Read-only in `ApiObservation`; shown in ObsDetails today via remote fetch. Not manually editable. |
-| **Classic apps** | Android passes as `UMBRELLA_PROJECT_IDs` to picker for read-only display. |
-| **Confusion** | Name says "non_traditional" but means **new-style** (collection/umbrella), not "not traditional". |
-| **Tickets** | B2; existing `ProjectSection.tsx`. |
-
-### Observation field
-
-| | |
-|---|---|
-| **Definition** | Global field **definition** (name, datatype, allowed values) reused across projects. |
-| **API** | Nested as `observation_field` inside `project_observation_fields[]`. |
-| **RN / Realm** | `ObservationField` (embedded on `Project.projectObservationFields` and referenced by OFV). |
-| **Classic apps** | iOS `ExploreObsFieldRealm`; Android `ProjectField` / `field_id`. |
-| **Confusion** | Not the user's answer — that is an **OFV**. |
-| **Tickets** | A1, A2; iOS audit §2.2–2.4, Android §2 `project_fields`. |
-
-### Observation field value (OFV)
-
-| | |
-|---|---|
-| **Definition** | The user's **answer** for one observation field on one observation. |
-| **API** | `observation_field_values` / `ofvs`; nested body on POST: `{ observation_field_value: { observation_id, observation_field_id, value, uuid } }`. One OFV per `(observation_id, observation_field_id)` — no `project_id`. |
-| **RN / Realm** | Embedded `ObservationFieldValue` on `Observation`: `uuid`, `id` (server OFV id), `obsFieldId`, `value` — **global per observation, not project-scoped**; lookup `ObservationFieldValue.findForObsField(obs, obsFieldId)`. Schema **v70** (no `projectId` on OFV). |
-| **Classic apps** | iOS `ExploreObsFieldValueRealm` (`valueForObsField:`); Android `project_field_values`. |
-| **Confusion** | Values are **always strings** (taxon id, dates, numbers as text). OFVs are on the observation, not on the PO record. Same global field on multiple projects shares **one** answer — do not duplicate OFV rows per project; `projectId` lives on **PO**, not OFV. |
-| **Tickets** | A1, A2, A4 (schema v70), B3–B7, C1, D1; iOS audit §2.4, §5.1, Android §2, §6. |
-
-### `prefers_curator_coordinate_access`
-
-| | |
-|---|---|
-| **Definition** | Whether project curators may view **hidden/obscured coordinates** for the member's observations in that project. |
-| **API** | `PUT /v1/project_users/{id}` with `prefers_curator_coordinate_access` (web; RN to confirm). |
-| **RN / Realm** | Not persisted locally in Phase 1 beyond leave-flow choice; may sync via join/leave API. |
-| **Classic apps** | **Not implemented** in iOS or Android native apps. |
-| **Confusion** | Leave sheet option 2 ("prevent curators from viewing hidden coordinates") maps here — distinct from removing obs from project. |
-| **Tickets** | E3 (leave), P2-1 (join). |
-
-### Project observation (PO)
-
-| | |
-|---|---|
-| **Definition** | Server record linking **one observation** to **one traditional project** (manual membership). |
-| **API** | `project_observations`; POST body flat: `{ observation_id, project_id, uuid }`. |
-| **RN / Realm** | Embedded `ProjectObservation` on `Observation` (client `uuid`, server `projectObsId`). |
-| **Classic apps** | iOS `ExploreProjectObservationRealm`; Android `project_observations` with `is_new` / `is_deleted`. |
-| **Confusion** | Not "an observation made for a project" in casual language — it is the **join row**. Requires server `observation_id`; uploads **after** OFVs. |
-| **Tickets** | A1, A2, C1, D1; iOS audit §2.4, §5.1, Android §2, §6. |
-
-### `project_observation_rules`
-
-| | |
-|---|---|
-| **Definition** | Membership **rule rows** on a project — operators like `in_taxon?`, `georeferenced?`, `verifiable?` that the server evaluates when creating a **PO**. |
-| **API** | `project_observation_rules[]` on project payload with `operator`, `operand_type`, `operand_id`; expanded operands when `rule_details: true`. |
-| **RN / Realm** | Cached on `Project` (A3) for offline B9 validation in chooser. |
-| **Classic apps** | Not validated client-side in native apps. |
-| **Confusion** | These **cause 422** on traditional `POST /v1/project_observations`. Distinct from `rule_preferences` (display/ES on traditional). |
-| **Tickets** | A3, B9, E7; see build plan [B9 spike appendix](traditional-projects-build-plan.md#b9-spike-appendix--project-rules-validation). |
-
-### Project observation field (POF)
-
-| | |
-|---|---|
-| **Definition** | Configuration attaching an observation field to a **specific project**: required flag, sort position. |
-| **API** | `project_observation_fields[]` on project payload: `{ id, required, position, observation_field }`. |
-| **RN / Realm** | Embedded `ProjectObservationField` on `Project` (or denormalized on join sync). |
-| **Classic apps** | iOS `ExploreProjectObsFieldRealm`; Android `project_fields` with `is_required`, `position`. |
-| **Confusion** | Same global field can appear on multiple projects with different `required` / `position`. |
-| **Tickets** | A2, A3, B3; iOS audit §2.3–2.4, Android §2. |
-
-### Rule combination (membership rules)
-
-| | |
-|---|---|
-| **Definition** | How multiple `project_observation_rules` combine when validating a PO. |
-| **API** | Rails `validates_rules_from :project` in `lib/ruler/ruler/has_rules_for.rb`. |
-| **RN / Realm** | B9 `validateProjectRules` must mirror: **OR within same `operator`**, **AND across different operators**. |
-| **Classic apps** | Server-only; native apps do not pre-validate. |
-| **Confusion** | Three `in_taxon?` rules = match **any** listed taxon tree; `in_taxon?` + `georeferenced?` = **both** required. |
-| **Tickets** | B9; `spec/models/project_observation_rule_spec.rb`. |
-
-### `rule_preferences`
-
-| | |
-|---|---|
-| **Definition** | Collection-project **search filter** preferences (`quality_grade`, `photos`, `d1`, `month`, `native`, etc.) stored on project and indexed for ES. |
-| **API** | `rule_preferences[]` — `{ field, value }` from `Project::RULE_PREFERENCES`. |
-| **RN / Realm** | Cached on `Project` (A3); displayed in `ProjectRequirements.tsx` and E7. |
-| **Classic apps** | Shown on web requirements UI; **not enforced** on traditional PO create. |
-| **Confusion** | RN must **show** prefs for UI parity but **not SAVE-gate** on prefs alone — traditional enforces via `project_observation_rules` operators instead. |
-| **Tickets** | A3, B9 (display), E7; build plan B9 appendix. |
-
-### Select field
-
-| | |
-|---|---|
-| **Definition** | UI pattern for choosing one of several fixed answers — **not** an API `datatype`. |
-| **API** | Inferred when `datatype` is `text` or `dna` and `allowed_values` has **more than one** entry (iOS/Android). |
-| **RN** | Product decision: RN select UI applies to **`text` only**; `dna` always uses free-text input (MOB-1504). |
-| **RN / Realm** | Render with `RadioButtonSheet` / list picker (B5). |
-| **Classic apps** | iOS `ProjectObsFieldViewController`; Android Spinner. |
-| **Confusion** | Exactly one allowed value ⇒ render as free text, not select. Zero allowed values ⇒ free text. |
-| **Tickets** | B5; iOS audit §2.2, §3.4, Android §5. |
-
-### Traditional project
-
-| | |
-|---|---|
-| **Definition** | Original iNaturalist project type: users **manually** add observations and fill custom fields. |
-| **API** | `project_type` is `""` (empty string) or absent/null — **not** `collection` or `umbrella`. |
-| **RN / Realm** | `isTraditionalProject(project)` ⇒ `project_type !== "collection" && project_type !== "umbrella"`. |
-| **Classic apps** | iOS `ExploreProjectTypeOldStyle` / `!isNewStyleProject`; Android anything not collection/umbrella. |
-| **Confusion** | POD scope is **only** traditional manual add — not changing collection/umbrella behavior. |
-| **Tickets** | All B-track, E-track; iOS audit §2.1. |
-
-### Umbrella project
-
-| | |
-|---|---|
-| **Definition** | Container project grouping other projects; observations included by rules, not manual add. |
-| **API** | `project_type: "umbrella"`. |
-| **RN / Realm** | Same read-only treatment as collection in chooser footer. |
-| **Classic apps** | iOS `ExploreProjectTypeUmbrella`; Android `PROJECT_TYPE_UMBRELLA`. |
-| **Confusion** | Listed in chooser explainer only — no toggle. |
-| **Tickets** | B2; iOS audit §2.1. |
-
-### Upload order
-
-| | |
-|---|---|
-| **Definition** | Sequence of API calls when syncing an observation with project data. |
-| **API** | 1) Observation POST/PUT 2) photos/sounds 3) **OFVs** 4) **POs**. Deletes: **PO** before **OFV** before other children. |
-| **RN / Realm** | Extend `src/uploaders/observationUploader.ts` Step 3b. |
-| **Classic apps** | iOS `childrenNeedingUpload` order; Android `syncObservationFields` then `postProjectObservations`. |
-| **Confusion** | PO POST often fails with 422 if required OFVs missing — hence OFVs first. |
-| **Tickets** | D1, D2; iOS audit §5.2, Android §6. |
diff --git a/docs/traditional-projects-porting-analysis_Android.md b/docs/traditional-projects-porting-analysis_Android.md
deleted file mode 100644
index 5f9d2d416..000000000
--- a/docs/traditional-projects-porting-analysis_Android.md
+++ /dev/null
@@ -1,839 +0,0 @@
-# Traditional Projects in iNaturalistAndroid — Porting Analysis
-
-This document maps where every part of the Traditional Project feature lives in the iNaturalistAndroid repo, with direct code references. It covers the four POD work streams from the "Traditional Project Support POD Scope": add-to-project in the obs editor, the per-project obs field form (all field types + validation), join/leave flows, and offline sync. It ends with gaps where the Android app does NOT implement something the POD scope requires.
-
-All file paths are relative to the iNaturalistAndroid repository root. Line numbers refer to the state of the repo as of June 2026 (`master`).
-
-## 1. Architecture overview
-
-```mermaid
-flowchart TD
- subgraph ui [UI Layer]
- ObsEditor[ObservationEditor]
- Selector[ProjectSelectorActivity]
- FieldViewer[ProjectFieldViewer per field]
- ProjDetails[ProjectDetails join/leave]
- ObsViewer[ObservationViewerFragment]
- end
- subgraph db [SQLite via ObservationProvider]
- Projects[(projects)]
- ProjObs[(project_observations)]
- ProjFields[(project_fields)]
- ProjFieldVals[(project_field_values)]
- end
- subgraph api [API api.inaturalist.org/v1]
- JoinAPI["POST/DELETE /projects/:id/join|leave"]
- POAPI["POST/DELETE /project_observations"]
- OFVAPI["POST /observation_field_values"]
- UserProjAPI["GET /users/:login/projects"]
- end
- ObsEditor -->|"requestCode 102"| Selector
- Selector --> FieldViewer
- ObsEditor -->|saveProjects + saveProjectFields| ProjObs
- ObsEditor --> ProjFieldVals
- ProjDetails -->|service actions| JoinAPI
- JoinAPI --> Projects
- JoinAPI --> ProjFields
- Sync[INaturalistServiceImplementation sync] --> POAPI
- Sync --> OFVAPI
- Sync --> UserProjAPI
- ProjObs -->|"is_new / is_deleted queue"| Sync
- ProjFieldVals -->|"_updated_at > _synced_at queue"| Sync
- ObsViewer -->|read-only| ProjObs
-```
-
-Key design choice: all project selection AND project-field editing happens inside `ProjectSelectorActivity` (launched from the obs editor); the editor itself only stores results and persists them on save. Sync is a flag-based offline queue processed by a background service.
-
-## 2. Local data model (offline persistence)
-
-All four tables live in `inaturalist.db` (version 23), created in `iNaturalist/src/main/java/org/inaturalist/android/ObservationProvider.java` `onCreate`. No SQL foreign keys — relationships are logical. This is the model the RN app's Realm schema must reproduce.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ObservationProvider.java L65-L72
-public void onCreate(SQLiteDatabase db) {
- db.execSQL(Observation.sqlCreate());
- db.execSQL(ObservationPhoto.sqlCreate());
- db.execSQL(ObservationSound.sqlCreate());
- db.execSQL(Project.sqlCreate());
- db.execSQL(ProjectObservation.sqlCreate());
- db.execSQL(ProjectField.sqlCreate());
- db.execSQL(ProjectFieldValue.sqlCreate());
-}
-```
-
-### `projects` — joined projects (`Project.java`)
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/Project.java L143-L152
-public static String sqlCreate() {
- return "CREATE TABLE " + TABLE_NAME + " ("
- + Project._ID + " INTEGER PRIMARY KEY,"
- + "title TEXT,"
- + "description TEXT,"
- + "icon_url TEXT,"
- + "project_type TEXT,"
- + "id INTEGER,"
- + "check_list_id INTEGER"
- + ");";
-}
-```
-
-- Traditional-by-negation: only collection/umbrella constants exist; anything else (incl. null) is treated as traditional/selectable.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/Project.java L28-L29
-public static final String PROJECT_TYPE_COLLECTION = "collection";
-public static final String PROJECT_TYPE_UMBRELLA = "umbrella";
-```
-
-- No sync flags; the table is wiped and re-inserted from the server on every sync (`saveJoinedProjects()`, see section 6).
-
-### `project_observations` — obs-to-project join + offline queue (`ProjectObservation.java`)
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ProjectObservation.java L110-L119
-public static String sqlCreate() {
- return "CREATE TABLE " + TABLE_NAME + " ("
- + ProjectObservation._ID + " INTEGER PRIMARY KEY,"
- + "project_id INTEGER,"
- + "observation_id INTEGER,"
- + "is_deleted INTEGER,"
- + "is_new INTEGER, "
- + "id INTEGER, "
- + "UNIQUE(project_id, observation_id) ON CONFLICT REPLACE"
- + ");";
-}
-```
-
-- `is_new = 1` means "pending POST", `is_deleted = 1` means "pending DELETE". This is the entire offline add/remove queue. `id` is the server-side project_observation id once synced.
-- `observation_id` duality: holds local `Observation._id` before the obs is uploaded; once the obs gets a server ID, the provider rewrites `observation_id` in both `project_observations` and `project_field_values`:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ObservationProvider.java L586-L596
-if ((count > 0) && (values.containsKey(Observation.ID)) && (values.get(Observation.ID) != null)) {
- ContentValues cv = new ContentValues();
- cv.put(ProjectObservation.OBSERVATION_ID, values.getAsInteger(Observation.ID));
- Logger.tag(TAG).debug("Update project observation from " + id + " to " + values.getAsInteger(Observation.ID));
- db.update(ProjectObservation.TABLE_NAME, cv, ProjectObservation.OBSERVATION_ID + "=" + id, null);
-
- cv = new ContentValues();
- cv.put(ProjectFieldValue.OBSERVATION_ID, values.getAsInteger(Observation.ID));
- db.update(ProjectFieldValue.TABLE_NAME, cv, ProjectFieldValue.OBSERVATION_ID + "=" + id, null);
-}
-```
-
-### `project_fields` — field definitions per project (`ProjectField.java`)
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ProjectField.java L148-L160
-public static String sqlCreate() {
- return "CREATE TABLE " + TABLE_NAME + " ("
- + ProjectField._ID + " INTEGER PRIMARY KEY,"
- + "field_id INTEGER,"
- + "project_id INTEGER,"
- + "name TEXT, "
- + "description TEXT, "
- + "data_type TEXT, "
- + "allowed_values TEXT, "
- + "is_required INTEGER, "
- + "position INTEGER, "
- + "UNIQUE(field_id, project_id) ON CONFLICT REPLACE"
- + ");";
-}
-```
-
-- `allowed_values` is a pipe-separated string (e.g. `"a|b|c"`). Sourced from API `project_observation_fields` (nested `observation_field` object + `required` + `position`). Replaced wholesale per project on download; no sync flags.
-
-### `project_field_values` — user-entered values + offline queue (`ProjectFieldValue.java`)
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ProjectFieldValue.java L141-L154
-public static String sqlCreate() {
- return "CREATE TABLE " + TABLE_NAME + " ("
- + ProjectFieldValue._ID + " INTEGER PRIMARY KEY,"
- + "_created_at INTEGER,"
- + "_synced_at INTEGER,"
- + "_updated_at INTEGER,"
- + "created_at INTEGER,"
- + "id INTEGER,"
- + "observation_id INTEGER,"
- + "updated_at INTEGER,"
- + "value TEXT,"
- + "field_id INTEGER,"
- + "UNIQUE(field_id, observation_id) ON CONFLICT REPLACE"
- + ");";
-}
-```
-
-- `value` is always TEXT — taxon IDs, dates, numbers are all stored as strings.
-- Dirty state = `(_synced_at IS NULL) OR (_updated_at > _synced_at)` — same timestamp pattern as observations:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L1931-L1934
-c = mContext.getContentResolver().query(ProjectFieldValue.CONTENT_URI,
- ProjectFieldValue.PROJECTION,
- "(_synced_at IS NULL) OR (_updated_at > _synced_at AND _synced_at IS NOT NULL)",
-```
-
-## 3. Add-to-project flow in the observation editor
-
-### Entry point (`ObservationEditor.java`)
-
-- UI row `R.id.select_projects` + count badge; label logic in `refreshProjectList()` (lines 274-285): "Add to projects" when 0, "Projects" + count otherwise.
-- State held across rotation, keyed by `field_id`:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L2334-L2336
-@State public ArrayList mProjectIds;
-private ArrayList mProjectFields;
-@State public HashMap mProjectFieldValues = null;
-```
-
-- Initial load for an existing observation: query `project_observations` filtering soft-deleted rows. Also supports preselecting a project via intent extra `OBSERVATION_PROJECT`.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L1202-L1214
-// Get IDs of project-observations
-if ((mObservation.id == null) && (mObservation._id == null)) {
- mProjectIds = new ArrayList();
-} else {
- int obsId = (mObservation.id == null ? mObservation._id : mObservation.id);
- Cursor c = getActivity().getContentResolver().query(ProjectObservation.CONTENT_URI, ProjectObservation.PROJECTION,
- "(observation_id = " + obsId + ") AND ((is_deleted = 0) OR (is_deleted is NULL))",
- null, ProjectObservation.DEFAULT_SORT_ORDER);
-```
-
-- Launch picker (request code `PROJECT_SELECTOR_REQUEST_CODE = 102`) passing: observation ID, `IS_CONFIRMATION=true`, current field-value map, selected project IDs, and the IDs of collection/umbrella projects the obs is auto-included in (from obs JSON `non_traditional_projects`) as `UMBRELLA_PROJECT_IDs`.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L948-L957
-mProjectSelector.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- Intent intent = new Intent(getActivity(), ProjectSelectorActivity.class);
- intent.putExtra(INaturalistService.OBSERVATION_ID, (mObservation.id == null ? mObservation._id : mObservation.id));
- intent.putExtra(ProjectSelectorActivity.IS_CONFIRMATION, true);
- intent.putExtra(ProjectSelectorActivity.PROJECT_FIELDS, mProjectFieldValues);
-
- // Show both "regular" projects and umbrella/collection projects the observation belongs to
- intent.putIntegerArrayListExtra(INaturalistService.PROJECT_ID, mProjectIds);
-```
-
-- Result handling: replaces `mProjectIds` and `mProjectFieldValues` wholesale from the picker result.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L3006-L3017
-} else if (requestCode == PROJECT_SELECTOR_REQUEST_CODE) {
- if (resultCode == Activity.RESULT_OK) {
- ArrayList projectIds = data.getIntegerArrayListExtra(ProjectSelectorActivity.PROJECT_IDS);
- HashMap values = (HashMap) data.getSerializableExtra(ProjectSelectorActivity.PROJECT_FIELDS);
-
- if (!mProjectIds.equals(projectIds)) {
- AnalyticsClient.getInstance().logEvent(AnalyticsClient.EVENT_NAME_OBS_PROJECTS_CHANGED);
- }
-
- mProjectIds = projectIds;
- mProjectFieldValues = values;
-```
-
-### Persisting on observation save
-
-- `saveProjects()`, three-phase soft delete against `project_observations`:
- 1. rows whose project is no longer selected get `is_deleted = true` (lines 2737-2751)
- 2. re-selected rows get `is_deleted = false` (lines 2754-2773)
- 3. newly selected projects get a new row with `is_new = true, is_deleted = false`:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L2775-L2788
-// Finally, add new project-observation records
-ArrayList newIds = (ArrayList) CollectionUtils.subtract(mProjectIds, existingIds);
-
-for (int i = 0; i < newIds.size(); i++) {
- updatedProjects = true;
- int projectId = newIds.get(i);
- ProjectObservation projectObservation = new ProjectObservation();
- projectObservation.project_id = projectId;
- projectObservation.observation_id = obsId;
- projectObservation.is_new = true;
- projectObservation.is_deleted = false;
-
- getActivity().getContentResolver().insert(ProjectObservation.CONTENT_URI, projectObservation.getContentValues());
-}
-```
-
-- `saveProjectFields()`: upserts each non-null value into `project_field_values`; new rows are written with `_synced_at = now - 100` so `_updated_at > _synced_at` marks them dirty for upload.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L2707-L2726
-private void saveProjectFields() {
- if (mProjectFieldValues == null) return;
-
- for (ProjectFieldValue fieldValue : mProjectFieldValues.values()) {
- if (fieldValue.value == null) {
- continue;
- }
-
- if (fieldValue._id == null) {
- // New field value
- ContentValues cv = fieldValue.getContentValues();
- cv.put(ProjectFieldValue._SYNCED_AT, System.currentTimeMillis() - 100);
- Uri newRow = getActivity().getContentResolver().insert(ProjectFieldValue.CONTENT_URI, cv);
- getActivity().getContentResolver().update(newRow, fieldValue.getContentValues(), null, null);
- } else {
- // Update field value
- getActivity().getContentResolver().update(fieldValue.getUri(), fieldValue.getContentValues(), null, null);
- }
- }
-}
-```
-
-- Any project change bumps the observation's `_updated_at`, which enqueues the parent observation for sync.
-
-### Loading field definitions/values
-
-- `refreshProjectFields()` delegates to static helper `ProjectFieldViewer.getProjectFields()` (`ProjectFieldViewer.java` lines 670-706): queries `project_fields` per selected project + `project_field_values` for the observation, returns a `field_id → value` map. Fields filtered/sorted by `position` (`sortProjectFields`, lines 709-739).
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ObservationEditor.java L4630-L4642
-ProjectFieldViewer.getProjectFields(getActivity(), mProjectIds, (mObservation.id == null ? mObservation._id : mObservation.id), new ProjectFieldViewer.ProjectFieldsResults() {
- @Override
- public void onProjectFieldsResults(ArrayList projectFields, HashMap projectValues) {
- mProjectFields = projectFields;
-
- if (mProjectFieldValues == null) {
- mProjectFieldValues = projectValues;
- }
-
- addProjectFieldViewers();
- }
-});
-```
-
-## 4. Project picker + per-project field form (`ProjectSelectorActivity.java`)
-
-- Loads joined projects offline-first via service action `ACTION_GET_JOINED_PROJECTS` (reads local `projects` table). Receiver at lines 88-197 sorts alphabetically and splits the list: traditional projects on top, then a header ("Collection and Umbrella Projects") and the non-selectable collection/umbrella projects.
-- Collection/umbrella rows cannot be toggled — `onItemClick` returns early; they only show a read-only "included" indicator if the obs is in them.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ProjectSelectorActivity.java L656-L675
-String projectType = project.getString("project_type");
-boolean isUmbrellaProject = ((projectType != null) && ((projectType.equals(Project.PROJECT_TYPE_COLLECTION)) || (projectType.equals(Project.PROJECT_TYPE_UMBRELLA))));
-
-if (isUmbrellaProject) {
- // Umbrella/collection projects cannot be selected / expanded
- return;
-}
-
-Integer projectId = Integer.valueOf(project.getInt("id"));
-
-if (mObservationProjects.contains(projectId)) {
- mObservationProjects.remove(projectId);
-} else {
- mObservationProjects.add(projectId);
-}
-
-mAdapter.notifyDataSetChanged();
-```
-
-- Text search over project titles (lines 299-312, 397-427).
-- When a traditional project is checked (confirmation mode), the row expands inline with its field form: one `ProjectFieldViewer` per field (adapter `getView`, lines 496-628; layout `project_selector_confirmation_item.xml`), plus a "required" indicator if any field is required.
-- Field values are harvested from the viewers on every list rebind and on save (`saveProjectFieldValues()`, lines 362-381) into a `field_id → ProjectFieldValue` map.
-- Confirm (action-bar save): runs `validateProjectFields()`, then returns `PROJECT_IDS` + `PROJECT_FIELDS` to the editor.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ProjectSelectorActivity.java L210-L225
-case R.id.save_projects:
- saveProjectFieldValues();
-
- if (!validateProjectFields()) {
- return false;
- }
-
- Intent intent = new Intent();
- Bundle bundle = new Bundle();
- bundle.putIntegerArrayList(PROJECT_IDS, mObservationProjects);
- bundle.putSerializable(PROJECT_FIELDS, mProjectFieldValues);
- intent.putExtras(bundle);
-
- setResult(RESULT_OK, intent);
- finish();
-```
-
-## 5. Observation field types and validation (`ProjectFieldViewer.java`)
-
-Datatype rendering (one widget shown per `data_type`, lines ~409-541). This is the canonical list of field types the RN form must support:
-
-- `text` without `allowed_values` → free-text EditText
-- `text` with `allowed_values` → Spinner/dropdown; values parsed by pipe-splitting
-- `numeric` → numeric-keyboard EditText; value must parse as float
-- `date` → date picker dialog; stored as `yyyy-MM-dd`
-- `time` → time picker; stored as 24h `HH:mm`
-- `datetime` → datetime picker dialog (`showDateTimeDialog`, lines 548-607); displayed `yyyy-MM-dd HH:mm`, stored as ISO8601
-- `taxon` → launches `TaxonSearchActivity` with extra `FIELD_ID` (request code 301); value stored as the taxon ID string; existing values resolved back to a taxon via service `ACTION_GET_TAXON`
-- any other datatype → rendered as nothing, `getValue()` returns null
-
-Allowed-values parsing (select fields):
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ProjectFieldViewer.java L409-L413
-if ((mField.data_type.equals("text")) && (mField.allowed_values != null) && (!mField.allowed_values.equals(""))) {
- mSpinner.setVisibility(View.VISIBLE);
- String[] allowedValues = mField.allowed_values.split("\\|");
- mSpinnerAdapter = new ArrayAdapter(mContext, android.R.layout.simple_spinner_item, android.R.id.text1, allowedValues);
-```
-
-Taxon field launch:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ProjectFieldViewer.java L529-L534
-mTaxonContainer.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- Intent intent = new Intent(mContext, TaxonSearchActivity.class);
- intent.putExtra(TaxonSearchActivity.FIELD_ID, mField.field_id);
- mContext.startActivityForResult(intent, PROJECT_FIELD_TAXON_SEARCH_REQUEST_CODE);
-```
-
-Validation:
-
-- `isValid()`: required fields must be non-empty; numeric values must parse as float.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ProjectFieldViewer.java L272-L292
-public Boolean isValid() {
- if (mField.is_required) {
- String value = getValue();
- if (value == null || value.equals("")) {
- // Mandatory field
- return false;
- }
- }
-
- if ((mField.data_type.equals("numeric")) && (!mEditText.getText().toString().equals(""))) {
- try {
- float value = Float.valueOf(mEditText.getText().toString());
- } catch (Exception exc) {
- // Invalid number
- return false;
- }
- }
-
-
- return true;
-}
-```
-
-- `ProjectSelectorActivity.validateProjectFields()`: on confirm, validates all viewers of all checked projects; on failure shows toast `R.string.invalid_project_field` ("Please enter a valid value for field '%1s'") and blocks the save.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ProjectSelectorActivity.java L693-L715
-private boolean validateProjectFields() {
- if (mIsConfirmation) {
- HashMap> finalProjectFields = new HashMap>();
- for (int projectId : mObservationProjects) {
- finalProjectFields.put(projectId, mProjectFieldViewers.get(projectId));
- }
- for (int projectId : finalProjectFields.keySet()) {
- List fields = finalProjectFields.get(projectId);
- if (fields == null) break;
- for (ProjectFieldViewer fieldViewer : fields) {
- if (!fieldViewer.isValid()) {
- Toast.makeText(this, String.format(getString(R.string.invalid_project_field), fieldViewer.getField().name), Toast.LENGTH_LONG).show();
- return false;
- }
- }
- }
- mProjectFieldViewers = finalProjectFields;
- }
- return true;
-}
-```
-
-- Important behavior to know when porting: validation runs ONLY on the picker's confirm action. It does NOT run on checkbox toggle, on observation save, or before upload. Server-side rejections are handled post-hoc (section 6). The POD requires blocking upload client-side — stronger than Android's behavior. (Also note the `if (fields == null) break;` quirk above: it aborts validation of all remaining projects instead of skipping one.)
-
-## 6. Sync and API layer (`INaturalistServiceImplementation.java`)
-
-Hosts:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistService.java L428-L429
-public static String HOST = "https://www.inaturalist.org";
-public static String API_HOST = "https://api.inaturalist.org/v1";
-```
-
-### Endpoints
-
-- Join: `POST {API_HOST}/projects/{id}/join` (empty body), then `GET {API_HOST}/projects/{id}` to fetch `project_observation_fields`:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L4894-L4914
-public void joinProject(int projectId) throws AuthenticationException {
- post(String.format(Locale.ENGLISH, "%s/projects/%d/join", API_HOST, projectId), (JSONObject) null);
-
- try {
- JSONArray result = get(String.format(Locale.ENGLISH, "%s/projects/%d", API_HOST, projectId));
- if (result == null) return;
- JSONArray results = result.getJSONObject(0).getJSONArray("results");
- BetterJSONObject jsonProject = new BetterJSONObject(results.getJSONObject(0));
- Project project = new Project(jsonProject);
-
- Cursor c = mContext.getContentResolver().query(Project.CONTENT_URI, Project.PROJECTION, "id = ?", new String[]{String.valueOf(project.id)}, null);
-
- if (c.getCount() == 0) {
- // Add joined project locally
- ContentValues cv = project.getContentValues();
- mContext.getContentResolver().insert(Project.CONTENT_URI, cv);
- }
- c.close();
-
- // Save project fields
- addProjectFields(jsonProject.getJSONArray("project_observation_fields").getJSONArray(), jsonProject.getInt("id"));
-```
-
-- Leave: `DELETE {API_HOST}/projects/{id}/leave`:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L4921-L4926
-public void leaveProject(int projectId) throws AuthenticationException {
- delete(String.format(Locale.ENGLISH, "%s/projects/%d/leave", API_HOST, projectId), null);
-
- // Remove locally saved project (because we left it)
- mContext.getContentResolver().delete(Project.CONTENT_URI, "(id IS NOT NULL) and (id = " + projectId + ")", null);
-}
-```
-
-- Add obs to project: `POST {API_HOST}/project_observations`:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L4955-L4968
-String url = API_HOST + "/project_observations";
-
-JSONObject params = new JSONObject();
-JSONObject projectObs = new JSONObject();
-try {
- projectObs.put("observation_id", observationId);
- projectObs.put("project_id", projectId);
- params.put("project_observation", projectObs);
-} catch (JSONException e) {
- e.printStackTrace();
- return null;
-}
-
-JSONArray json = post(url, params);
-```
-
-- Remove obs from project: `DELETE {API_HOST}/project_observations/{id}` when the server id is known; legacy fallback on the Rails host otherwise:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L4934-L4937
-String url = projectObservationId != null ?
- String.format(Locale.ENGLISH, "%s/project_observations/%d", API_HOST, projectObservationId) :
- String.format(Locale.ENGLISH, "%s/projects/%d/remove.json?observation_id=%d", HOST, projectId, observationId);
-JSONArray json = request(url, "delete", null, null, true, true, false);
-```
-
-- Field values: `POST {API_HOST}/observation_field_values`. There is NO PUT/DELETE for field values anywhere; clearing a value never syncs.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L5435-L5446
-JSONObject params = new JSONObject();
-JSONObject obsFieldValue = new JSONObject();
-try {
- obsFieldValue.put("observation_id", localField.observation_id);
- obsFieldValue.put("observation_field_id", localField.field_id);
- obsFieldValue.put("value", localField.value);
-
- params.put("observation_field_value", obsFieldValue);
-} catch (JSONException e) {
- e.printStackTrace();
-}
-JSONArray result = post(API_HOST + "/observation_field_values", params);
-```
-
-- Joined projects list: `GET {API_HOST}/users/{login}/projects?per_page=100&page=N`, paginated; each result's `project_observation_fields` is stored locally via `addProjectFields()` (delete-all-then-reinsert per project, lines 4832-4853):
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L5133-L5153 (abridged)
-do {
- String url = API_HOST + "/users/" + Uri.encode(mLogin) + "/projects?per_page=100&page=" + page;
- JSONArray json = get(url, true);
- // ...
- for (int i = 0; i < results.length(); i++) {
- JSONObject project = results.getJSONObject(i);
- project.put("joined", true);
- finalJson.put(project);
- addProjectFields(project.getJSONArray("project_observation_fields"), project.optInt("id"));
- }
-} while (projectsDownloaded < totalResults);
-```
-
-- Standalone field metadata (for values referencing fields not in any joined project): `GET {HOST}/observation_fields/{id}.json` — `addProjectField()` lines 5694-5710.
-- User obs download includes project data via `extra=observation_photos,projects,fields` (`getUserObservations()` lines 5317-5347).
-
-### Offline queue processing (per-observation upload order)
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L2035-L2036
-syncObservationFields(observation);
-postProjectObservations(observation);
-```
-
-1. POST observation body
-2. photos/sounds
-3. `syncObservationFields(observation)` — uploads dirty field values, with last-writer-wins conflict resolution against remote OFVs (lines 5356-5514); sets `_synced_at` on success
-4. `postProjectObservations(observation)` — DELETEs rows with `is_deleted = 1` (then hard-deletes locally), POSTs rows with `is_new = 1` (then clears flag and stores server `id`). Skips entirely if the observation has no server id yet:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L2716-L2720
-private boolean postProjectObservations(Observation observation) throws AuthenticationException, CancelSyncException, SyncFailedException {
- if (observation.id == null) {
- // Observation not synced yet - cannot sync its project associations yet
- return true;
- }
-```
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L2759-L2791
-// Next, add new project observations
-c = mContext.getContentResolver().query(ProjectObservation.CONTENT_URI,
- ProjectObservation.PROJECTION,
- "is_new = 1 AND observation_id = ?",
- new String[]{String.valueOf(observation.id)},
- ProjectObservation.DEFAULT_SORT_ORDER);
-
-c.moveToFirst();
-while (c.isAfterLast() == false) {
- checkForCancelSync();
- ProjectObservation projectObservation = new ProjectObservation(c);
- BetterJSONObject result = addObservationToProject(projectObservation.observation_id, projectObservation.project_id);
-
- if ((result == null) && (mResponseErrors == null)) {
- c.close();
- throw new SyncFailedException();
- }
-
- increaseProgressForObservation(observation);
-
- if (mResponseErrors != null) {
- handleProjectFieldErrors(projectObservation.observation_id, projectObservation.project_id);
- } else {
- // Unmark as new
- projectObservation.is_new = false;
- // Save external ID
- projectObservation.id = result.getInt("id");
- ContentValues cv = projectObservation.getContentValues();
- mContext.getContentResolver().update(projectObservation.getUri(), cv, null, null);
-
- // Clean the errors for the observation
- mApp.setErrorsForObservation(projectObservation.observation_id, projectObservation.project_id, new JSONArray());
- }
-```
-
-Field values upload BEFORE project membership so required-field validation passes server-side. End of full sync: `saveJoinedProjects()` (wipe + re-insert `projects` table, lines 3010-3038) and `storeProjectObservations()` (insert-only reconciliation of downloaded memberships, lines 2968-2993).
-
-The queue-discovery query that decides which observations have pending project changes (handles both local and server observation IDs):
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L2188-L2191
-c = mContext.getContentResolver().query(ProjectObservation.CONTENT_URI,
- ProjectObservation.PROJECTION,
- "((is_deleted = 1) OR (is_new = 1)) AND " +
- "((observation_id = ?) OR (observation_id = ?))",
-```
-
-Join/leave are NOT queued offline — they fire immediately from `ProjectDetails` and silently fail without network (no retry queue, no rollback of the optimistic UI).
-
-### Error handling (server rejects add-to-project)
-
-- A failed `POST /project_observations` or `/observation_field_values` with API `errors` is a soft failure: row stays `is_new = 1` (retried next sync), `handleProjectFieldErrors()` (lines 2901-2965) formats the error (strings `failed_to_add_to_project` / `failed_to_add_obs_to_project`), stores it per observation+project in SharedPreferences via `INaturalistApp.setErrorsForObservation()` (`INaturalistApp.java` lines 671-689), and shows a toast. See the `mResponseErrors != null` branch in the `postProjectObservations` citation above.
-- Stored errors surface in the editor (`ObservationEditor` ~line 1934), obs detail (`ObservationViewerFragment` ~2006), and obs list rows (`ObservationCursorAdapter` ~517).
-
-## 7. Join / leave flows (`ProjectDetails.java`)
-
-- Join: if project has `terms`, shows confirm dialog "Do you agree to the following?" with the raw terms text; on agree (or no terms) optimistically flips the button, fires `ACTION_JOIN_PROJECT`. Local effect on success: insert `projects` row + replace `project_fields` for that project. Requires login (redirects to onboarding otherwise).
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ProjectDetails.java L253-L269 (abridged)
-} else {
- String terms = mProject.getString("terms");
- if ((terms != null) && (terms.length() > 0)) {
- mHelper.confirm(getString(R.string.do_you_agree_to_the_following), mProject.getString("terms"), new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialogInterface, int i) {
- joinProject();
- }
- // ... cancel listener ...
- }, R.string.yes, R.string.no);
- } else {
- joinProject();
- }
-}
-```
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ProjectDetails.java L277-L289
-private void joinProject() {
- if (!isLoggedIn()) {
- // User not logged-in - redirect to onboarding screen
- startActivity(new Intent(ProjectDetails.this, OnboardingActivity.class).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP));
- return;
- }
-
- mJoinLeaveProject.setText(R.string.leave);
- mProject.put("joined", true);
-
- Intent serviceIntent = new Intent(INaturalistService.ACTION_JOIN_PROJECT, null, ProjectDetails.this, INaturalistService.class);
- serviceIntent.putExtra(INaturalistService.PROJECT_ID, mProject.getInt("id"));
- INaturalistService.callService(this, serviceIntent);
-```
-
-- Leave: single confirm dialog — title `leave_project` ("Leave Project"), message `leave_project_confirmation` ("Are you sure you want to leave this project?"), Yes/No. Fires `ACTION_LEAVE_PROJECT`. Local effect: deletes the `projects` row only — `project_fields`, `project_observations`, `project_field_values` are left in place.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/ProjectDetails.java L234-L244
-if ((isJoined != null) && (isJoined == true)) {
- mHelper.confirm(getString(R.string.leave_project), getString(R.string.leave_project_confirmation),
- new DialogInterface.OnClickListener() {
- public void onClick(DialogInterface dialog, int buttonId) {
- // Leave the project
- mJoinLeaveProject.setText(R.string.join);
- mProject.put("joined", false);
-
- Intent serviceIntent = new Intent(INaturalistService.ACTION_LEAVE_PROJECT, null, ProjectDetails.this, INaturalistService.class);
- serviceIntent.putExtra(INaturalistService.PROJECT_ID, mProject.getInt("id"));
- INaturalistService.callService(ProjectDetails.this, serviceIntent);
-```
-
-- Project browsing: `ProjectsActivity.java` hosts Joined/Nearby/Featured tabs (`BaseTab.java` does the loading); "joined" state for nearby/featured lists is computed by checking whether the project id exists in the local `projects` table. No project-type badge or joined indicator is shown in browse lists.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L4770-L4780
-// Determine which projects are already joined
-for (int i = 0; i < json.length(); i++) {
- Cursor c;
- try {
- c = mContext.getContentResolver().query(Project.CONTENT_URI, Project.PROJECTION, "id = '" + json.getJSONObject(i).getInt("id") + "'", null, Project.DEFAULT_SORT_ORDER);
- c.moveToFirst();
- int count = c.getCount();
- c.close();
- if (count > 0) {
- json.getJSONObject(i).put("joined", true);
- }
-```
-
-- Read-only display of an observation's projects: `ObservationViewerFragment` "Included in N projects" row → `ObservationProjectsViewer.java` (list only; field values are never displayed on the obs detail screen).
-
-## 8. Gaps: what the Android app does NOT have (vs POD scope)
-
-These items are in the POD scope but have no Android reference implementation — they will need design/API research from web behavior instead:
-
-- Hidden-coordinate access permission at join time: `preferred_curator_coordinate_access` appears nowhere in this codebase. Join is a bare POST. (Web-only today, as the POD notes.)
-- Leave flow with "keep or remove my observations": Android shows only a generic yes/no confirmation; no observation-retention option and no related API param.
-- Client-side blocking of upload on unfilled required fields: Android only validates inside the picker's confirm action; the upload path itself never re-validates (server rejection is handled as a retryable soft error). The POD requires a hard pre-upload gate.
-- Deleting a field value remotely: no DELETE for `observation_field_values`; clearing a value locally never propagates.
-- Field-value map keyed by `field_id` only — the same observation field shared by two selected projects collides (a known Android quirk to avoid reproducing).
-
-## 9. Porting checklist (behavioral spec for RN, no engineering yet)
-
-- Persist locally: joined projects (with `project_type`), per-project field definitions (`field_id`, `data_type`, `allowed_values`, `is_required`, `position`), obs-project links with pending add/remove state, and field values with dirty tracking — all must survive offline.
-- Only allow manual add for traditional projects (project_type not collection/umbrella); show collection/umbrella membership read-only.
-- Field form supports: free text, select (pipe-separated `allowed_values`), numeric, date, time, datetime, taxon (taxon picker, value = taxon id as string).
-- Validate required + numeric fields before letting the user confirm project selection AND before upload (stricter than Android).
-- Upload ordering: observation first, then field values, then project_observations; handle local-id → server-id remapping for queued records.
-- Server validation errors on add-to-project: keep the pending record, surface a per-observation/per-project error, retry on next sync.
-- Join: POST join → fetch project → cache `project_observation_fields` locally (fields must be available offline for the form). Leave: DELETE leave → remove local project (+ decide cleanup policy for orphaned local data, which Android gets wrong).
-- Refresh joined-projects + field definitions on every full sync via `GET /users/{login}/projects` (paginated).
-
-## 10. Offline behavior in detail
-
-The core "add observation to project" flow works offline; join/leave does not.
-
-### Works offline
-
-- Selecting projects and filling fields: the picker reads joined projects from the local `projects` table, not the network — `ACTION_GET_JOINED_PROJECTS` resolves to `getJoinedProjectsOffline()`. Field definitions (datatype, required flag, allowed values) are cached in `project_fields` at join/sync time, so the field form renders offline.
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L5097-L5102
-private SerializableJSONArray getJoinedProjectsOffline() {
- JSONArray projects = new JSONArray();
- Cursor c = mContext.getContentResolver().query(Project.CONTENT_URI, Project.PROJECTION, null, null, Project.DEFAULT_SORT_ORDER);
-
- c.moveToFirst();
- int count = c.getCount();
-```
-
-- Queuing changes (no network call on save):
- - Project memberships → `project_observations` rows with `is_new = 1` (add) or `is_deleted = 1` (remove), written in `ObservationEditor.saveProjects()` (lines 2729-2790).
- - Field values → `project_field_values` rows marked dirty via `_updated_at > _synced_at`, written in `saveProjectFields()` (lines 2707-2726).
-- Sync later: on the next sync the queue is flushed per observation — `syncObservationFields(observation)` then `postProjectObservations(observation)` (lines 2035-2036). If the observation itself hasn't been uploaded yet, the queued rows reference its local `_id`; once the obs gets a server ID, `ObservationProvider` rewrites `observation_id` in both tables (lines 586-596).
-
-### Does NOT work offline
-
-- Joining/leaving a project: `ProjectDetails` fires `ACTION_JOIN_PROJECT` / `ACTION_LEAVE_PROJECT` immediately; on failure there is no retry queue, and the optimistically flipped button is never rolled back. You cannot join a new project offline — which also means its field definitions never get cached.
-- Taxon-type fields: the taxon picker (`TaxonSearchActivity`) and resolving an existing taxon-ID value back to a name (`ACTION_GET_TAXON`) both require network.
-- Clearing a field value: never syncs at all (online or offline) — there is no DELETE for observation field values in the codebase.
-
-The POD requirement "observations can be added to projects while offline" matches Android's behavior only for projects joined while online — the local caching of projects + field definitions is what makes it possible and is the pattern the RN port must replicate.
-
-## 11. Upload-time reconciliation
-
-Two different things can change server-side between the user filling the form and the upload: field values and field definitions. They are handled very differently.
-
-### Field values: last-writer-wins reconciliation
-
-`syncObservationFields(observation)` (`INaturalistServiceImplementation.java` lines 5356-5514) does real conflict resolution before pushing. For each dirty local value it fetches the observation's remote `observation_field_values` via `GET /observations/{id}` (lines 5382-5409), then decides direction:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L5422-L5431
-if (!fields.containsKey(Integer.valueOf(localField.field_id))) {
- // No remote field - add it
- shouldOverwriteRemote = true;
-} else {
- remoteField = fields.get(Integer.valueOf(localField.field_id));
-
- if ((remoteField.updated_at != null) && (remoteField.updated_at.before(localField._updated_at))) {
- shouldOverwriteRemote = true;
- }
-}
-```
-
-- Remote field missing, or remote `updated_at` older than local `_updated_at` → POST the local value (lines 5433-5446, see the OFV POST citation in section 6).
-- Remote newer → overwrite local with the remote value, no API call:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L5467-L5475
-} else {
- // Overwrite local value
- localField.created_at = remoteField.created_at;
- localField.id = remoteField.id;
- localField.observation_id = remoteField.observation_id;
- localField.field_id = remoteField.field_id;
- localField.value = remoteField.value;
- localField.updated_at = remoteField.updated_at;
-}
-```
-
-- Remote values the device has never seen are inserted locally afterwards (lines 5488-5512); unknown field metadata is fetched via `addProjectField()`.
-
-Caveat: the comparison is server timestamp vs. device clock (`remoteField.updated_at.before(localField._updated_at)`), so it is clock-skew sensitive.
-
-### Field definitions: no reconciliation at upload time
-
-If `project_observation_fields` changed server-side (new required field, changed `allowed_values`, removed field), the upload proceeds against the stale cached `project_fields` definitions. There is no schema re-fetch and no client-side re-validation before upload — validation only ever ran in the picker UI. Definitions are refreshed only after all uploads, at the tail of the sync:
-
-```java
-// iNaturalist/src/main/java/org/inaturalist/android/INaturalistServiceImplementation.java L2056-L2060
-if (mApp.loggedIn() && mIsSyncing) {
- // Update observation comments/IDs for the observations
- storeProjectObservations();
- saveJoinedProjects();
-}
-```
-
-Resulting failure modes:
-
-- New required field added since the form was filled → `POST /project_observations` rejected server-side; soft failure: row keeps `is_new = 1` (retried every sync), `handleProjectFieldErrors()` (lines 2901-2965) stores the error per observation+project and shows a toast. The user must re-open the editor (definitions cache is fresh by then), fill the new field, and sync again.
-- `allowed_values` changed → stale value POSTed as-is; server accepts or rejects (same soft-failure path).
-- Field removed from project → the orphaned local value still POSTs to `/observation_field_values` and generally succeeds, since observation field values are not project-scoped server-side.
-
-Server is the validator of record for schema drift; the client's only "reconciliation" is retry-with-error-surface. For the RN port, the POD's pre-upload validation requirement means validating against a potentially stale schema — decide whether to re-fetch `project_observation_fields` at upload time or accept Android's eventual-consistency behavior.
diff --git a/docs/traditional-projects-porting-reference_iOS.md b/docs/traditional-projects-porting-reference_iOS.md
deleted file mode 100644
index 5f173bcec..000000000
--- a/docs/traditional-projects-porting-reference_iOS.md
+++ /dev/null
@@ -1,2163 +0,0 @@
-# Traditional Projects — iOS Feature Analysis (Porting Reference)
-
-Audit deliverable for the Traditional Project Support POD (Phase 1/2): where the "add observation to a Traditional Project" feature lives in the classic Objective-C iOS app (`INaturalistIOS`), with verbatim code citations so this document is readable without the iOS repo checked out.
-
-All file paths are relative to the `INaturalistIOS` repository root. Citation blocks are formatted as `startLine:endLine:path` and were verified against the source at the time of writing.
-
-Scope note: this document covers the **iOS** classic app only. The POD's Phase 1 audit also calls for classic Android and web flows (including the web-only hidden-coordinates join permission), which must be audited in those codebases separately. Release mechanics like feature flags have no precedent in this app and are likewise out of scope here.
-
----
-
-## 1. Architecture at a glance
-
-The feature spans four layers. All live data is **Realm** (Core Data models are legacy migration sources only); API JSON is parsed via transient **Mantle** models; all network traffic goes through the **Node API** (`https://api.inaturalist.org/v1`).
-
-```mermaid
-flowchart TD
- ObsEdit[ObsEditV2ViewController - Projects row] --> Chooser[ProjectObservationsViewController - Choose Projects]
- Chooser -->|toggle ON| PO[ExploreProjectObservationRealm plus default OFVs]
- Chooser -->|field tap| Inputs[Per-type input UIs]
- Inputs --> OFV[ExploreObsFieldValueRealm]
- ObsEdit -->|validatedSave| RealmDB[(Realm)]
- RealmDB --> UploadMgr[UploadManager / UploadObservationOperation]
- UploadMgr -->|"POST /v1/project_observations"| API[Node API]
- UploadMgr -->|"POST /v1/observation_field_values"| API
- ProjectsTab[ProjectsViewController] --> Detail[ProjectDetailV2ViewController join and leave]
- Detail -->|"POST join / DELETE leave"| API
- API --> User[ExploreUserRealm.joinedProjects]
- User --> Chooser
-```
-
-Entity relationships:
-
-```mermaid
-flowchart TD
- UserRealm[ExploreUserRealm PK userId] -->|joinedProjects| ProjectRealm[ExploreProjectRealm PK projectId]
- ProjectRealm -->|projectObsFields| POF[ExploreProjectObsFieldRealm PK projectObsFieldId]
- POF -->|obsField| ObsField[ExploreObsFieldRealm PK obsFieldId]
- ObsRealm[ExploreObservationRealm PK uuid] -->|projectObservations| PORealm[ExploreProjectObservationRealm PK uuid]
- PORealm -->|project| ProjectRealm
- ObsRealm -->|observationFieldValues| OFVRealm[ExploreObsFieldValueRealm PK uuid]
- OFVRealm -->|obsField| ObsField
-```
-
----
-
-## 2. Data models
-
-### 2.1 Project type detection (traditional vs collection/umbrella)
-
-The project type enum:
-
-```12:16:INaturalistIOS/Models/ViewProtocols/ProjectVisualization.h
-typedef NS_ENUM(NSInteger, ExploreProjectType) {
- ExploreProjectTypeCollection,
- ExploreProjectTypeUmbrella,
- ExploreProjectTypeOldStyle
-};
-```
-
-`OldStyle` = Traditional. The API's `project_type` string maps to the enum; an empty string maps to OldStyle:
-
-```44:52:INaturalistIOS/Models/Mantle/ExploreProject.m
-+ (NSValueTransformer *)typeJSONTransformer {
- NSDictionary *typeMappings = @{
- @"collection": @(ExploreProjectTypeCollection),
- @"umbrella": @(ExploreProjectTypeUmbrella),
- @"": @(ExploreProjectTypeOldStyle),
- };
-
- return [NSValueTransformer mtl_valueMappingTransformerWithDictionary:typeMappings];
-}
-```
-
-A missing/nil `project_type` also defaults to OldStyle:
-
-```55:67:INaturalistIOS/Models/Mantle/ExploreProject.m
-- (void)setNilValueForKey:(NSString *)key {
- if ([key isEqualToString:@"locationId"]) {
- self.locationId = 0;
- } else if ([key isEqualToString:@"latitude"]) {
- self.latitude = kCLLocationCoordinate2DInvalid.latitude;
- } else if ([key isEqualToString:@"longitude"]) {
- self.longitude = kCLLocationCoordinate2DInvalid.longitude;
- } else if ([key isEqualToString:@"type"]) {
- self.type = ExploreProjectTypeOldStyle;
- } else {
- [super setNilValueForKey:key];
- }
-}
-```
-
-The single check that gates all traditional-only UI, plus the user-facing type labels:
-
-```153:165:INaturalistIOS/Models/Realm/ExploreProjectRealm.m
-- (BOOL)isNewStyleProject {
- return self.type == ExploreProjectTypeUmbrella || self.type == ExploreProjectTypeCollection;
-}
-
-- (NSString *)titleForTypeOfProject {
- if (self.type == ExploreProjectTypeCollection) {
- return NSLocalizedString(@"Collection Project", @"Collection type of project, which automatically collects observations into it.");
- } else if (self.type == ExploreProjectTypeUmbrella) {
- return NSLocalizedString(@"Umbrella Project", @"Umbrella type of project, which contains other projects within it.");
- } else {
- return NSLocalizedString(@"Traditional Project", @"Traditional inat type of project, where users have to manually add observations to the project.");
- }
-}
-```
-
-Traditional = `!isNewStyleProject`.
-
-### 2.2 Observation field datatypes (the "7 types")
-
-```11:19:INaturalistIOS/Models/Mantle/ExploreObsField.h
-typedef NS_ENUM(NSInteger, ExploreObsFieldDataType) {
- ExploreObsFieldDataTypeText,
- ExploreObsFieldDataTypeNumeric,
- ExploreObsFieldDataTypeDate,
- ExploreObsFieldDataTypeTime,
- ExploreObsFieldDataTypeDateTime,
- ExploreObsFieldDataTypeTaxon,
- ExploreObsFieldDataTypeDna
-};
-```
-
-API `datatype` string mapping:
-
-```29:41:INaturalistIOS/Models/Mantle/ExploreObsField.m
-+ (NSValueTransformer *)dataTypeJSONTransformer {
- NSDictionary *typeMappings = @{
- @"text": @(ExploreObsFieldDataTypeText),
- @"numeric": @(ExploreObsFieldDataTypeNumeric),
- @"date": @(ExploreObsFieldDataTypeDate),
- @"time": @(ExploreObsFieldDataTypeTime),
- @"datetime": @(ExploreObsFieldDataTypeDateTime),
- @"taxon": @(ExploreObsFieldDataTypeTaxon),
- @"dna": @(ExploreObsFieldDataTypeDna),
- };
-
- return [NSValueTransformer mtl_valueMappingTransformerWithDictionary:typeMappings];
-}
-```
-
-**There is no "select" datatype.** Select fields are inferred at runtime: text/dna fields with more than one allowed value (see section 3.4). The API sends `allowed_values` as a pipe-delimited string, split on `|`:
-
-```23:27:INaturalistIOS/Models/Mantle/ExploreObsField.m
-+ (NSValueTransformer *)allowedValuesJSONTransformer {
- return [MTLValueTransformer transformerWithBlock:^id(NSString *allowedValues) {
- return [allowedValues componentsSeparatedByString:@"|"];
- }];
-}
-```
-
-The text-or-dna helper used everywhere:
-
-```80:82:INaturalistIOS/Models/Realm/ExploreObsFieldRealm.m
-- (BOOL)canBeTreatedAsText {
- return self.dataType == ExploreObsFieldDataTypeText || self.dataType == ExploreObsFieldDataTypeDna;
-}
-```
-
-### 2.3 Mantle models (API JSON parsing)
-
-`ExploreProject` JSON key mappings — note `project_observation_fields` is included in project payloads, which is what enables offline field definitions:
-
-```16:30:INaturalistIOS/Models/Mantle/ExploreProject.m
-+ (NSDictionary *)JSONKeyPathsByPropertyKey{
- return @{
- @"title": @"title",
- @"projectId": @"id",
- @"locationId": @"place_id",
- @"latitude": @"latitude",
- @"longitude": @"longitude",
- @"iconUrl": @"icon",
- @"type": @"project_type",
- @"bannerColorString": @"banner_color",
- @"bannerImageUrl": @"header_image_url",
- @"inatDescription": @"description",
- @"projectObsFields": @"project_observation_fields",
- };
-}
-```
-
-Note: Mantle instances always report `joined == NO`; joined state is Realm-only:
-
-```69:71:INaturalistIOS/Models/Mantle/ExploreProject.m
-- (BOOL)joined {
- return NO;
-}
-```
-
-`ExploreProjectObsField` (the per-project field config carrying `required` and `position`):
-
-```13:22:INaturalistIOS/Models/Mantle/ExploreProjectObsField.h
-@interface ExploreProjectObsField : MTLModel
-
-@property (nonatomic, assign) BOOL required;
-@property (nonatomic, assign) NSInteger position;
-@property (nonatomic, assign) NSInteger projectObsFieldId;
-@property (nonatomic) ExploreObsField *obsField;
-
-@end
-```
-
-```15:30:INaturalistIOS/Models/Mantle/ExploreProjectObsField.m
-+ (NSDictionary *)JSONKeyPathsByPropertyKey{
- return @{
- @"required": @"required",
- @"position": @"position",
- @"projectObsFieldId": @"id",
- @"obsField": @"observation_field",
- };
-}
-
-- (void)setNilValueForKey:(NSString *)key {
- if ([key isEqualToString:@"required"]) {
- self.required = FALSE;
- } else if ([key isEqualToString:@"position"]) {
- self.position = 0;
- }
-}
-```
-
-`ExploreObsField`:
-
-```13:21:INaturalistIOS/Models/Mantle/ExploreObsField.m
-+ (NSDictionary *)JSONKeyPathsByPropertyKey{
- return @{
- @"allowedValues": @"allowed_values",
- @"name": @"name",
- @"inatDescription": @"description",
- @"obsFieldId": @"id",
- @"dataType": @"datatype",
- };
-}
-```
-
-`ExploreProjectObservation` (the observation-to-project join record as fetched from the server):
-
-```13:19:INaturalistIOS/Models/Mantle/ExploreProjectObservation.m
-+ (NSDictionary *)JSONKeyPathsByPropertyKey{
- return @{
- @"projectObsId": @"id",
- @"uuid": @"uuid",
- @"project": @"project",
- };
-}
-```
-
-`ExploreObsFieldValue`:
-
-```14:21:INaturalistIOS/Models/Mantle/ExploreObsFieldValue.m
-+ (NSDictionary *)JSONKeyPathsByPropertyKey{
- return @{
- @"obsFieldValueId": @"id",
- @"value": @"value",
- @"obsField": @"observation_field",
- @"uuid": @"uuid",
- };
-}
-```
-
-On fetched observations, the relevant nested JSON keys are `project_observations` and `ofvs` (excerpt of the larger mapping):
-
-```52:66:INaturalistIOS/Models/Mantle/ExploreObservation.m
- @"user": @"user",
- @"observationPhotos": @"observation_photos",
- @"observationSounds": @"observation_sounds",
- @"comments": @"comments",
- @"identifications": @"identifications",
- @"faves": @"faves",
- @"projectObservations": @"project_observations",
- @"taxon": @"taxon",
- @"dataQuality": @"quality_grade",
- @"uuid": @"uuid",
- @"captive": @"captive",
- @"geoprivacy": @"geoprivacy",
- @"ownersIdentificationFromVision": @"owners_identification_from_vision",
- @"observationFieldValues": @"ofvs",
- };
-```
-
-```136:142:INaturalistIOS/Models/Mantle/ExploreObservation.m
-+ (NSValueTransformer *)projectObservationsJSONTransformer {
- return [NSValueTransformer mtl_JSONArrayTransformerWithModelClass:ExploreProjectObservation.class];
-}
-
-+ (NSValueTransformer *)observationFieldValuesJSONTransformer {
- return [NSValueTransformer mtl_JSONArrayTransformerWithModelClass:ExploreObsFieldValue.class];
-}
-```
-
-### 2.4 Realm models (source of truth)
-
-`ExploreProjectRealm` — PK `projectId`:
-
-```15:41:INaturalistIOS/Models/Realm/ExploreProjectRealm.h
-@interface ExploreProjectRealm : RLMObject
-
-@property NSString *title;
-@property NSInteger projectId;
-@property NSInteger locationId;
-@property CLLocationDegrees latitude;
-@property CLLocationDegrees longitude;
-@property NSString *iconUrlString;
-@property NSString *bannerImageUrlString;
-@property NSString *bannerColorString;
-@property ExploreProjectType type;
-@property NSString *inatDescription;
-
-- (BOOL)isNewStyleProject;
-
-- (instancetype)initWithMantleModel:(ExploreProject *)model;
-
-// to-many relationships
-@property RLMArray *projectObsFields;
-
-+ (NSDictionary *)valueForMantleModel:(ExploreProject *)model;
-+ (NSDictionary *)valueForCoreDataModel:(id)model;
-
-- (NSString *)titleForTypeOfProject;
-
-
-@end
-```
-
-```120:122:INaturalistIOS/Models/Realm/ExploreProjectRealm.m
-+ (NSString *)primaryKey {
- return @"projectId";
-}
-```
-
-Fields are displayed sorted by `position`:
-
-```146:151:INaturalistIOS/Models/Realm/ExploreProjectRealm.m
-- (NSArray *)sortedProjectObservationFields {
- RLMSortDescriptor *positionSort = [RLMSortDescriptor sortDescriptorWithKeyPath:@"position" ascending:YES];
- RLMResults *sortedResults = [self.projectObsFields sortedResultsUsingDescriptors:@[ positionSort ]];
- // convert to NSArray
- return [sortedResults valueForKey:@"self"];
-}
-```
-
-`ExploreProjectObsFieldRealm` — PK `projectObsFieldId`, carries the per-project `required` flag, with an inverse link back to its project:
-
-```16:29:INaturalistIOS/Models/Realm/ExploreProjectObsFieldRealm.h
-@interface ExploreProjectObsFieldRealm : RLMObject
-
-@property BOOL required;
-@property NSInteger position;
-@property NSInteger projectObsFieldId;
-@property ExploreObsFieldRealm *obsField;
-
-@property (readonly) ExploreProjectRealm *project;
-
-- (instancetype)initWithMantleModel:(ExploreProjectObsField *)model;
-+ (NSDictionary *)valueForMantleModel:(ExploreProjectObsField *)model;
-+ (NSDictionary *)valueForCoreDataModel:(id)model;
-
-@end
-```
-
-```74:88:INaturalistIOS/Models/Realm/ExploreProjectObsFieldRealm.m
-+ (NSString *)primaryKey {
- return @"projectObsFieldId";
-}
-
-+ (NSDictionary *)linkingObjectsProperties {
- return @{
- @"projects": [RLMPropertyDescriptor descriptorWithClass:ExploreProjectRealm.class
- propertyName:@"projectObsFields"],
- };
-}
-
-- (ExploreProjectRealm *)project {
- // should only be one project attached to this linking object property
- return [self.projects firstObject];
-}
-```
-
-`ExploreObsFieldRealm` — PK `obsFieldId`:
-
-```12:26:INaturalistIOS/Models/Realm/ExploreObsFieldRealm.h
-@interface ExploreObsFieldRealm : RLMObject
-
-@property RLMArray *allowedValues;
-@property NSString *name;
-@property NSString *inatDescription;
-@property NSInteger obsFieldId;
-@property ExploreObsFieldDataType dataType;
-
-- (instancetype)initWithMantleModel:(ExploreObsField *)model;
-+ (NSDictionary *)valueForMantleModel:(ExploreObsField *)model;
-+ (NSDictionary *)valueForCoreDataModel:(id)model;
-
-- (BOOL)canBeTreatedAsText;
-
-@end
-```
-
-`ExploreProjectObservationRealm` — PK is a **client-generated `uuid`**; `projectObsId` is the server id (0 until uploaded):
-
-```17:31:INaturalistIOS/Models/Realm/ExploreProjectObservationRealm.h
-@interface ExploreProjectObservationRealm : RLMObject
-
-@property NSInteger projectObsId;
-@property NSString *uuid;
-@property ExploreProjectRealm *project;
-
-@property NSDate *timeSynced;
-@property NSDate *timeUpdatedLocally;
-
-@property (readonly) ExploreObservationRealm *observation;
-
-+ (NSDictionary *)valueForMantleModel:(ExploreProjectObservation *)model;
-+ (NSDictionary *)valueForCoreDataModel:(id)model;
-
-@end
-```
-
-```88:102:INaturalistIOS/Models/Realm/ExploreProjectObservationRealm.m
-+ (NSString *)primaryKey {
- return @"uuid";
-}
-
-+ (NSDictionary *)linkingObjectsProperties {
- return @{
- @"observations": [RLMPropertyDescriptor descriptorWithClass:ExploreObservationRealm.class
- propertyName:@"projectObservations"],
- };
-}
-
-- (ExploreObservationRealm *)observation {
- // should only be one observation attached to this linking object property
- return [self.observations firstObject];
-}
-```
-
-`ExploreObsFieldValueRealm` — PK client `uuid`; `value` is **always a string** (taxon ids stored as strings):
-
-```17:32:INaturalistIOS/Models/Realm/ExploreObsFieldValueRealm.h
-@interface ExploreObsFieldValueRealm : RLMObject
-
-@property NSInteger obsFieldValueId;
-@property NSString *value;
-@property NSString *uuid;
-@property ExploreObsFieldRealm *obsField;
-
-@property NSDate *timeSynced;
-@property NSDate *timeUpdatedLocally;
-
-@property (readonly) ExploreObservationRealm *observation;
-
-+ (NSDictionary *)valueForMantleModel:(ExploreObsFieldValue *)model;
-+ (NSDictionary *)valueForCoreDataModel:(id)model;
-
-@end
-```
-
-```99:113:INaturalistIOS/Models/Realm/ExploreObsFieldValueRealm.m
-+ (NSString *)primaryKey {
- return @"uuid";
-}
-
-+ (NSDictionary *)linkingObjectsProperties {
- return @{
- @"observations": [RLMPropertyDescriptor descriptorWithClass:ExploreObservationRealm.class
- propertyName:@"observationFieldValues"],
- };
-}
-
-- (ExploreObservationRealm *)observation {
- // should only be one observation attached to this linking object property
- return [self.observations firstObject];
-}
-```
-
-`ExploreObservationRealm` holds the to-many arrays plus `validationErrorMsg` (the upload-failure flag):
-
-```58:69:INaturalistIOS/Models/Realm/ExploreObservationRealm.h
-// to-many relationships
-@property RLMArray *observationPhotos;
-@property RLMArray *observationSounds;
-@property RLMArray *comments;
-@property RLMArray *identifications;
-@property RLMArray *faves;
-@property RLMArray *observationFieldValues;
-@property RLMArray *projectObservations;
-
-@property (readonly) NSArray *observationMedia;
-
-@property NSString *validationErrorMsg;
-```
-
-OFV lookup by field, used by all the field UI:
-
-```402:410:INaturalistIOS/Models/Realm/ExploreObservationRealm.m
-- (ExploreObsFieldValueRealm *)valueForObsField:(ExploreObsFieldRealm *)field {
- for (ExploreObsFieldValueRealm *ofv in self.observationFieldValues) {
- if (ofv.obsField.obsFieldId == field.obsFieldId) {
- return ofv;
- }
- }
-
- return nil;
-}
-```
-
-Cascade delete of project links and field values when an observation is deleted (excerpt):
-
-```729:744:INaturalistIOS/Models/Realm/ExploreObservationRealm.m
- // the server will cascade delete these for us
- // so just cascade the local stuff
- [realm deleteObjects:observation.observationPhotos];
- [realm deleteObjects:observation.observationSounds];
- [realm deleteObjects:observation.projectObservations];
- [realm deleteObjects:observation.observationFieldValues];
- [realm deleteObjects:observation.comments];
- [realm deleteObjects:observation.identifications];
-
- // create a deleted record for the observation
- ExploreDeletedRecord *dr = [observation deletedRecordForModel];
- [realm addOrUpdateObject:dr];
-
- // delete the observation
- [realm deleteObject:observation];
- [realm commitWriteTransaction];
-```
-
-### 2.5 Joined-projects persistence
-
-Membership is a user-to-projects list — there is **no** `joined` flag on the project model:
-
-```28:29:INaturalistIOS/Models/Realm/ExploreUserRealm.h
-@property RLMArray *joinedProjects;
-- (BOOL)hasJoinedProjectWithId:(NSInteger)projectId;
-```
-
-```111:116:INaturalistIOS/Models/Realm/ExploreUserRealm.m
-- (BOOL)hasJoinedProjectWithId:(NSInteger)projectId {
- for (ExploreProjectRealm *project in self.joinedProjects) {
- if (project.projectId == projectId) { return YES; }
- }
- return NO;
-}
-```
-
-### 2.6 Legacy Core Data
-
-`Project`, `ProjectObservation`, `ProjectObservationField`, `ObservationField`, `ObservationFieldValue`, `ProjectUser` under `INaturalistIOS/Models/CoreData/` are migration sources only. Each Realm class has a `valueForCoreDataModel:` (e.g. `ExploreProjectRealm.m` lines 41–103) converting string project types and pipe-delimited allowed values. Not relevant to the RN port beyond confirming the active store is Realm.
-
----
-
-## 3. Add-to-project flow in observation edit
-
-### 3.1 Entry point: the Projects row in obs edit
-
-The obs edit table sections:
-
-```50:55:INaturalistIOS/Controllers/Observations/Observation Details/ObsEditV2ViewController.m
-typedef NS_ENUM(NSInteger, ConfirmObsSection) {
- ConfirmObsSectionPhotos = 0,
- ConfirmObsSectionIdentify,
- ConfirmObsSectionNotes,
- ConfirmObsSectionDelete,
-};
-```
-
-The Projects row is Notes section, item 5. Cell builder (shows count of attached projects):
-
-```1701:1716:INaturalistIOS/Controllers/Observations/Observation Details/ObsEditV2ViewController.m
-- (UITableViewCell *)projectsCellInTableView:(UITableView *)tableView {
- DisclosureCell *cell = [tableView dequeueReusableCellWithIdentifier:@"disclosure"];
-
- cell.titleLabel.text = [self projectsTitle];
- FAKIcon *project = [FAKIonIcons iosBriefcaseOutlineIconWithSize:44];
- [project addAttribute:NSForegroundColorAttributeName value:[UIColor colorWithHexString:@"#777777"]];
- cell.cellImageView.image = [project imageWithSize:CGSizeMake(44, 44)];
-
- if (self.standaloneObservation.projectObservations.count > 0) {
- cell.secondaryLabel.text = [NSString stringWithFormat:@"%ld", (unsigned long)self.standaloneObservation.projectObservations.count];
- }
-
- cell.selectionStyle = UITableViewCellSelectionStyleNone;
- cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
- return cell;
-}
-```
-
-```1744:1746:INaturalistIOS/Controllers/Observations/Observation Details/ObsEditV2ViewController.m
-- (NSString *)projectsTitle {
- return NSLocalizedString(@"Projects", @"choose projects button title.");
-}
-```
-
-Tap handler — requires login, then pushes the chooser with the in-flight observation and itself as delegate:
-
-```1419:1435:INaturalistIOS/Controllers/Observations/Observation Details/ObsEditV2ViewController.m
- } else if (indexPath.item == 5) {
- INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
- if (appDelegate.loginController.isLoggedIn) {
- UIStoryboard *sb = [UIStoryboard storyboardWithName:@"MainStoryboard" bundle:[NSBundle mainBundle]];
- ProjectObservationsViewController *vc = [sb instantiateViewControllerWithIdentifier:@"projectObservationsVC"];
- vc.observation = self.standaloneObservation;
- vc.delegate = self;
- [self.navigationController pushViewController:vc animated:YES];
- } else {
- UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"You must be logged in!", nil)
- message:NSLocalizedString(@"You must be logged in to access projects.", nil)
- preferredStyle:UIAlertControllerStyleAlert];
- [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK",nil)
- style:UIAlertActionStyleCancel
- handler:nil]];
- [self presentViewController:alert animated:YES completion:nil];
- }
-```
-
-### 3.2 The chooser: `ProjectObservationsViewController`
-
-Public interface and the delegate protocol used to stage removals back in obs edit:
-
-```14:26:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.h
-@protocol ProjectObservationsViewControllerDelegate
-@optional
-- (void)projectObsDelegateDeletedProjectObservation:(ExploreProjectObservationRealm *)po;
-- (void)projectObsDelegateDeletedObsFieldValue:(ExploreObsFieldValueRealm *)ofv;
-@end
-
-
-@interface ProjectObservationsViewController : UITableViewController
-
-@property ExploreObservationRealm *observation;
-@property (weak, nonatomic) id delegate;
-
-@end
-```
-
-`viewDidLoad` reads joined projects from Realm and only re-syncs when the network is reachable (this is the offline-aware path):
-
-```86:141:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (void)viewDidLoad {
- [super viewDidLoad];
-
- NSArray *sorts = @[
- [RLMSortDescriptor sortDescriptorWithKeyPath:@"type" ascending:NO],
- [RLMSortDescriptor sortDescriptorWithKeyPath:@"title" ascending:YES],
- ];
-
- INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
- if (appDelegate.loginController.isLoggedIn) {
- ExploreUserRealm *meUser = appDelegate.loginController.meUserLocal;
- if (meUser) {
- self.joinedProjects = [[meUser joinedProjects] sortedResultsUsingDescriptors:sorts];
- __weak typeof(self)weakSelf = self;
- self.joinedToken = [self.joinedProjects addNotificationBlock:^(RLMResults * _Nullable results, RLMCollectionChange * _Nullable change, NSError * _Nullable error) {
- [weakSelf.tableView reloadData];
- }];
- }
- }
-
- self.title = NSLocalizedString(@"Choose Projects", @"title for project observations chooser");
-
- self.tableView.tableHeaderView = ({
- InsetLabel *label = [InsetLabel new];
- label.insets = UIEdgeInsetsMake(10, 10, 10, 10);
- label.text = NSLocalizedString(@"Please note: Observations will be automatically included in a collection project if they meet its requirements.",
- @"helpful note about observations and collection projects on the screen where you can add observations to projects.");
- label.numberOfLines = 0;
- label.backgroundColor = [[UIColor lightGrayColor] colorWithAlphaComponent:0.2];
- label;
- });
- [self.tableView.tableHeaderView sizeToFit];
-
- self.tableView.backgroundColor = [UIColor whiteColor];
- self.tableView.estimatedRowHeight = 44.0f;
- self.tableView.rowHeight = UITableViewAutomaticDimension;
- [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"cell"];
- [self.tableView registerClass:[ObsFieldSimpleValueCell class] forCellReuseIdentifier:SimpleFieldIdentifier];
- [self.tableView registerClass:[ObsFieldLongTextValueCell class] forCellReuseIdentifier:LongTextFieldIdentifier];
-
- if ([[INatReachability sharedClient] isNetworkReachable]) {
- INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
- if ([appDelegate.loginController isLoggedIn]) {
- ExploreUserRealm *me = [appDelegate.loginController meUserLocal];
- // start by clearing all joined projects
- RLMRealm *realm = [RLMRealm defaultRealm];
- [realm beginWriteTransaction];
- [me.joinedProjects removeAllObjects];
- [realm commitWriteTransaction];
-
- // sync first page, that will trigger page 2 if
- // necessary and so on
- [self syncUserProjectsUserId:me.userId page:1];
- }
- }
-}
-```
-
-The paginated joined-projects sync (each result is upserted by PK, which refreshes the project's `projectObsFields` to latest server state):
-
-```59:84:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (void)syncUserProjectsUserId:(NSInteger)userId page:(NSInteger)page {
-
- __weak typeof(self)weakSelf = self;
- [[self projectsApi] projectsForUser:userId page:page handler:^(NSArray *results, NSInteger totalCount, NSError *error) {
- ExploreUserRealm *meUser = [ExploreUserRealm objectForPrimaryKey:@(userId)];
- if (!meUser) { return; } // can't join projects if we don't have a me user
-
- RLMRealm *realm = [RLMRealm defaultRealm];
- [realm beginWriteTransaction];
- for (ExploreProject *eg in results) {
- NSDictionary *value = [ExploreProjectRealm valueForMantleModel:eg];
- ExploreProjectRealm *project = [ExploreProjectRealm createOrUpdateInDefaultRealmWithValue:value];
- [meUser.joinedProjects addObject:project];
- }
- [realm commitWriteTransaction];
-
- // update tableview
- [weakSelf.tableView reloadData];
-
- NSInteger totalReceived = results.count + ((page-1) * [[weakSelf projectsApi] projectsPerPage]);
- if (totalReceived < totalCount) {
- // recursively fetch another page of joined projects
- [weakSelf syncUserProjectsUserId:userId page:page+1];
- }
- }];
-}
-```
-
-Table structure: one section per joined project; field rows only for traditional projects whose switch is ON:
-
-```413:429:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
- ExploreProjectRealm *project = [self projectForSection:section];
- if ([project isNewStyleProject]) {
- // don't show fields for new style projects
- return 0;
- }
-
- if ([self projectIsSelected:project]) {
- return project.projectObsFields.count;
- } else {
- return 0;
- }
-}
-
-- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
- return [self.joinedProjects count];
-}
-```
-
-Section header: project icon/title/type plus the toggle switch — hidden for collection/umbrella:
-
-```355:393:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section {
-
- ExploreProjectRealm *project = [self projectForSection:section];
- BOOL projectIsSelected = [self projectIsSelected:project];
-
- CGFloat height = [self tableView:tableView heightForHeaderInSection:section];
-
- UINib *nib = [UINib nibWithNibName:@"ProjectObservationHeaderView" bundle:[NSBundle mainBundle]];
- ProjectObservationHeaderView *header = [[nib instantiateWithOwner:nil options:nil] firstObject];
- header.frame = CGRectMake(0, 0, tableView.bounds.size.width, height);
-
- header.projectTitleLabel.text = project.title;
- header.projectTypeLabel.text = [project titleForTypeOfProject];
-
- if ([project isNewStyleProject]) {
- header.selectedSwitch.hidden = YES;
- header.backgroundColor = [[UIColor lightGrayColor] colorWithAlphaComponent:0.2];
- } else {
- header.selectedSwitch.hidden = NO;
- [header.selectedSwitch setOn:projectIsSelected animated:NO];
- header.selectedSwitch.tag = section;
- [header.selectedSwitch addTarget:self action:@selector(selectedChanged:) forControlEvents:UIControlEventValueChanged];
- header.backgroundColor = [UIColor whiteColor];
- }
-
-
- if ([project iconUrl]) {
- header.projectThumbnailImageView.backgroundColor = [UIColor clearColor];
- header.projectThumbnailImageView.contentMode = UIViewContentModeScaleAspectFill;
- [header.projectThumbnailImageView setImageWithURL:[project iconUrl]];
- } else {
- // use standard projects icon
- header.projectThumbnailImageView.backgroundColor = [UIColor colorWithHexString:@"#cccccc"];
- header.projectThumbnailImageView.image = [UIImage inat_defaultProjectImage];
- header.projectThumbnailImageView.contentMode = UIViewContentModeCenter;
- }
-
- return header;
-}
-```
-
-Header view outlets:
-
-```11:18:INaturalistIOS/Views/ProjectObservationHeaderView.h
-@interface ProjectObservationHeaderView : UIView
-
-@property IBOutlet UIImageView *projectThumbnailImageView;
-@property IBOutlet UILabel *projectTitleLabel;
-@property IBOutlet UILabel *projectTypeLabel;
-@property IBOutlet UISwitch *selectedSwitch;
-
-@end
-```
-
-"Is this observation in this project?" is answered by scanning the observation's project links:
-
-```657:665:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (BOOL)projectIsSelected:(ExploreProjectRealm *)project {
- for (ExploreProjectObservationRealm *po in self.observation.projectObservations) {
- if (po.project.projectId == project.projectId) {
- return YES;
- }
- }
-
- return NO;
-}
-```
-
-### 3.3 Toggling a project ON/OFF
-
-Toggle ON creates a `ExploreProjectObservationRealm` plus one default OFV per project field, **written to the default Realm immediately** (even for unsaved observations). Toggle OFF stages deletions via the delegate:
-
-```675:732:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (void)selectedChanged:(UISwitch *)switcher {
- NSInteger section = switcher.tag;
- ExploreProjectRealm *project = [self projectForSection:section];
- if (!project) return;
-
- NSIndexPath *sectionIp = [NSIndexPath indexPathForRow:NSNotFound inSection:section];
-
- if (switcher.isOn) {
- // have to create a ProjectObs and some OFVs for this observation
-
- // create and add project observation
- ExploreProjectObservationRealm *po = [ExploreProjectObservationRealm new];
- po.project = project;
- po.uuid = [[[NSUUID UUID] UUIDString] lowercaseString];
-
- RLMRealm *realm = [RLMRealm defaultRealm];
- [realm beginWriteTransaction];
- [realm addOrUpdateObject:po];
- [self.observation.projectObservations addObject:po];
- [realm commitWriteTransaction];
-
- for (ExploreProjectObsFieldRealm *pof in project.sortedProjectObservationFields) {
- ExploreObsFieldValueRealm *ofv = [ExploreObsFieldValueRealm new];
- ofv.uuid = [[[NSUUID UUID] UUIDString] lowercaseString];
- ofv.obsField = pof.obsField;
- ofv.value = pof.obsField.allowedValues.firstObject;
-
- [realm beginWriteTransaction];
- [realm addOrUpdateObject:ofv];
- [self.observation.observationFieldValues addObject:ofv];
- [realm commitWriteTransaction];
- }
- } else {
- ExploreProjectObservationRealm *poToDelete = nil;
- for (ExploreProjectObservationRealm *po in self.observation.projectObservations) {
- if (po.project.projectId == project.projectId) {
- poToDelete = po;
- }
- }
-
- if (poToDelete) {
- [self.delegate projectObsDelegateDeletedProjectObservation:poToDelete];
- }
-
- // do the ofvs for this project's pofs
- for (ExploreProjectObsFieldRealm *pof in project.projectObsFields) {
- ExploreObsFieldValueRealm *ofvToDelete = [self.observation valueForObsField:pof.obsField];
- if (ofvToDelete) {
- [self.delegate projectObsDelegateDeletedObsFieldValue:ofvToDelete];
- }
- }
- }
-
- [self.tableView reloadData];
- [self.tableView scrollToRowAtIndexPath:sectionIp
- atScrollPosition:UITableViewScrollPositionTop
- animated:YES];
-}
-```
-
-The delegate methods in obs edit stage the removals into `recordsToDelete` (committed only at save):
-
-```1099:1115:INaturalistIOS/Controllers/Observations/Observation Details/ObsEditV2ViewController.m
--(void)projectObsDelegateDeletedProjectObservation:(ExploreProjectObservationRealm *)po {
- NSInteger indexOfProjectObs = [self.standaloneObservation.projectObservations indexOfObject:po];
-
- if (indexOfProjectObs != NSNotFound) {
- [self.recordsToDelete addObject:po];
- [self.standaloneObservation.projectObservations removeObjectAtIndex:indexOfProjectObs];
- }
-}
-
-- (void)projectObsDelegateDeletedObsFieldValue:(ExploreObsFieldValueRealm *)ofv {
- NSInteger indexOfObsFieldValue = [self.standaloneObservation.observationFieldValues indexOfObject:ofv];
-
- if (indexOfObsFieldValue != NSNotFound) {
- [self.recordsToDelete addObject:ofv];
- [self.standaloneObservation.observationFieldValues removeObjectAtIndex:indexOfObsFieldValue];
- }
-}
-```
-
-### 3.4 Field rows: cell selection decision tree
-
-```324:353:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
- ExploreProjectRealm *project = [self projectForSection:indexPath.section];
- ExploreProjectObsFieldRealm *pof = [[project sortedProjectObservationFields] objectAtIndex:indexPath.item];
-
- if ([pof.obsField canBeTreatedAsText]) {
- if (pof.obsField.allowedValues.count > 1) {
- // simple value cell
- ObsFieldSimpleValueCell *cell = [tableView dequeueReusableCellWithIdentifier:SimpleFieldIdentifier];
- [self configureSimpleCell:cell forProjectObsField:pof];
- return cell;
- } else {
- ObsFieldLongTextValueCell *cell = [tableView dequeueReusableCellWithIdentifier:LongTextFieldIdentifier];
- [self configureLongTextCell:cell forProjectObsField:pof];
- return cell;
- }
- } else if (pof.obsField.dataType == ExploreObsFieldDataTypeNumeric ||
- pof.obsField.dataType == ExploreObsFieldDataTypeDate ||
- pof.obsField.dataType == ExploreObsFieldDataTypeTime ||
- pof.obsField.dataType == ExploreObsFieldDataTypeDateTime ||
- pof.obsField.dataType == ExploreObsFieldDataTypeTaxon) {
-
- ObsFieldSimpleValueCell *cell = [tableView dequeueReusableCellWithIdentifier:SimpleFieldIdentifier];
- [self configureSimpleCell:cell forProjectObsField:pof];
- return cell;
- } else {
- UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath];
- [self configureCell:cell forIndexPath:indexPath];
- return cell;
- }
-}
-```
-
-Summary of the mapping:
-
-- text/dna with more than 1 allowed value: select list (push `ProjectObsFieldViewController`)
-- text/dna with 0 or 1 allowed values: inline free-text (`ObsFieldLongTextValueCell`)
-- numeric: inline overlay text field with decimal pad
-- taxon: push `TaxaSearchViewController`
-- date and datetime: `ActionSheetDatePicker` in DateAndTime mode
-- time: `ActionSheetDatePicker` in Time mode
-
-### 3.5 Per-type input dispatch on row tap
-
-```431:560:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
- [tableView deselectRowAtIndexPath:indexPath animated:YES];
-
- ExploreProjectRealm *project = [self projectForSection:indexPath.section];
-
- if ([project isNewStyleProject]) {
- // there shouldn't be any rows for new style projects
- // bail just in case
- return;
- }
-
- ExploreProjectObsFieldRealm *pof = [[project sortedProjectObservationFields] objectAtIndex:indexPath.item];
- ExploreObsFieldValueRealm *ofv = [self.observation valueForObsField:pof.obsField];
-
- NSInteger initialSelection = 0;
-
- if (ofv) {
- // will be set to NSNotFound if it's not in the allowed values
- initialSelection = [pof.obsField.allowedValues indexOfObject:ofv.value];
- }
-
- if ([pof.obsField canBeTreatedAsText]) {
- if (pof.obsField.allowedValues.count > 1) {
- // text field, multiselect
- ProjectObsFieldViewController *pofVC = [[ProjectObsFieldViewController alloc] initWithNibName:nil bundle:nil];
- pofVC.pof = pof;
- pofVC.ofv = ofv;
-
- [self.navigationController pushViewController:pofVC animated:YES];
- } else {
- // text field, raw entry
-
- // activate the textfield
- ObsFieldLongTextValueCell *cell = (ObsFieldLongTextValueCell *)[tableView cellForRowAtIndexPath:indexPath];
- [cell.textField becomeFirstResponder];
-
- self.tapAwayGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapAway:)];
- [self.tableView addGestureRecognizer:self.tapAwayGesture];
- }
- } else if (pof.obsField.dataType == ExploreObsFieldDataTypeNumeric) {
- // numeric text entry
-
- // setup a textfield above the label
- ObsFieldSimpleValueCell *cell = [tableView cellForRowAtIndexPath:indexPath];
- cell.valueLabel.hidden = YES;
-
- UITextField *tf = [[UITextField alloc] initWithFrame:cell.valueLabel.frame];
- tf.keyboardType = UIKeyboardTypeDecimalPad;
- tf.textAlignment = NSTextAlignmentRight;
- tf.returnKeyType = UIReturnKeyDone;
- tf.text = cell.valueLabel.text;
- tf.delegate = self;
- [cell.contentView addSubview:tf];
-
- [tf becomeFirstResponder];
-
- self.tapAwayGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapAway:)];
- [self.tableView addGestureRecognizer:self.tapAwayGesture];
- } else if (pof.obsField.dataType == ExploreObsFieldDataTypeTaxon) {
- // taxon picker
- UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"MainStoryboard" bundle:nil];
-
- TaxaSearchViewController *search = [storyboard instantiateViewControllerWithIdentifier:@"TaxaSearchViewController"];
- search.hidesDoneButton = YES;
- search.delegate = self;
- // only prime the query if there's a placeholder, not a taxon)
- if (self.observation.speciesGuess && ! self.observation.taxon) {
- search.query = self.observation.speciesGuess;
- }
- [self.navigationController pushViewController:search animated:YES];
-
- // stash the selected index path so we know what ofv to update
- self.taxaSearchIndexPath = indexPath;
- } else if (pof.obsField.dataType == ExploreObsFieldDataTypeDate
- || pof.obsField.dataType == ExploreObsFieldDataTypeDateTime) {
-
- ObsFieldSimpleValueCell *cell = [tableView cellForRowAtIndexPath:indexPath];
- static NSDateFormatter *dateFormatter;
- if (!dateFormatter) {
- dateFormatter = [[NSDateFormatter alloc] init];
- dateFormatter.dateFormat = @"dd MMM yyyy HH:mm:ss ZZZ";
- }
- NSDate *date;
- if (cell.valueLabel.text && cell.valueLabel.text.length > 0) {
- date = [dateFormatter dateFromString:cell.valueLabel.text];
- }
- if (!date) {
- date = [NSDate date];
- }
-
- __weak typeof(self) weakSelf = self;
- [[[ActionSheetDatePicker alloc] initWithTitle:pof.obsField.name
- datePickerMode:UIDatePickerModeDateAndTime
- selectedDate:date
- doneBlock:^(ActionSheetDatePicker *picker, id selectedDate, id origin) {
- NSDate *date = (NSDate *)selectedDate;
- cell.valueLabel.text = [dateFormatter stringFromDate:date];
- [weakSelf saveVisibleObservationFieldValues];
- } cancelBlock:nil
- origin:self.view] showActionSheetPicker];
-
- } else if (pof.obsField.dataType == ExploreObsFieldDataTypeTime) {
-
- ObsFieldSimpleValueCell *cell = [tableView cellForRowAtIndexPath:indexPath];
- static NSDateFormatter *dateFormatter;
- if (!dateFormatter) {
- dateFormatter = [[NSDateFormatter alloc] init];
- dateFormatter.dateFormat = @"HH:mm:ss";
- }
- NSDate *date;
- if (cell.valueLabel.text && cell.valueLabel.text.length > 0) {
- date = [dateFormatter dateFromString:cell.valueLabel.text];
- }
- if (!date) {
- date = [NSDate date];
- }
-
- __weak typeof(self) weakSelf = self;
- [[[ActionSheetDatePicker alloc] initWithTitle:pof.obsField.name
- datePickerMode:UIDatePickerModeTime
- selectedDate:date
- doneBlock:^(ActionSheetDatePicker *picker, id selectedDate, id origin) {
- NSDate *date = (NSDate *)selectedDate;
- cell.valueLabel.text = [dateFormatter stringFromDate:date];
- [weakSelf saveVisibleObservationFieldValues];
- } cancelBlock:nil
- origin:self.view] showActionSheetPicker];
-
- }
-}
-```
-
-Notes:
-
-- `date` fields use the **date+time** picker mode (`UIDatePickerModeDateAndTime`), same as `datetime` — likely a bug worth fixing in RN with a date-only picker. Date format: `dd MMM yyyy HH:mm:ss ZZZ`; time format: `HH:mm:ss`.
-- Taxon field values come back via the taxon search delegate, stored as the taxon id string:
-
-```289:316:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (void)taxaSearchViewControllerChoseTaxon:(id )taxon chosenViaVision:(BOOL)visionFlag {
- [self.navigationController popToViewController:self animated:YES];
-
- if (!self.taxaSearchIndexPath) { return; }
-
-
- ExploreProjectRealm *project = [self projectForSection:self.taxaSearchIndexPath.section];
- if (!project) return;
-
- ExploreProjectObsFieldRealm *pof = [project.sortedProjectObservationFields objectAtIndex:self.taxaSearchIndexPath.item];
-
- ExploreObsFieldValueRealm *ofv = [self.observation valueForObsField:pof.obsField];
- if (ofv) {
- RLMRealm *realm = [RLMRealm defaultRealm];
- [realm beginWriteTransaction];
- ofv.value = [NSString stringWithFormat:@"%ld", (long)taxon.taxonId];
- ofv.timeUpdatedLocally = [NSDate date];
- [realm commitWriteTransaction];
- }
-
- [self.tableView beginUpdates];
- [self.tableView reloadRowsAtIndexPaths:@[ self.taxaSearchIndexPath ]
- withRowAnimation:UITableViewRowAnimationNone];
- [self.tableView endUpdates];
-
- self.taxaSearchIndexPath = nil;
- [self saveVisibleObservationFieldValues];
-}
-```
-
-### 3.6 The select picker: `ProjectObsFieldViewController`
-
-A simple checkmark list over the allowed values. Selecting writes straight to the OFV in Realm and pops:
-
-```79:98:INaturalistIOS/Controllers/Projects/ProjectObsFieldViewController.m
-- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)selectedIndexPath {
- // update selection
- for (NSIndexPath *indexPath in tableView.indexPathsForVisibleRows) {
- UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
- if ([selectedIndexPath isEqual:indexPath]) {
- cell.accessoryType = UITableViewCellAccessoryCheckmark;
- } else {
- cell.accessoryType = UITableViewCellAccessoryNone;
- }
- }
-
- // update model
- RLMRealm *realm = [RLMRealm defaultRealm];
- [realm beginWriteTransaction];
- self.ofv.value = [self.pof.obsField.allowedValues objectAtIndex:selectedIndexPath.item];
- [realm commitWriteTransaction];
-
- // pop
- [self.navigationController popViewControllerAnimated:YES];
-}
-```
-
-```106:117:INaturalistIOS/Controllers/Projects/ProjectObsFieldViewController.m
-- (void)configureCell:(UITableViewCell *)cell forIndexPath:(NSIndexPath *)indexPath {
- NSString *valueForRow = [self.pof.obsField.allowedValues objectAtIndex:indexPath.item];
-
- cell.textLabel.text = valueForRow;
- cell.textLabel.numberOfLines = 0;
-
- if ([valueForRow isEqualToString:self.ofv.value]) {
- cell.accessoryType = UITableViewCellAccessoryCheckmark;
- } else {
- cell.accessoryType = UITableViewCellAccessoryNone;
- }
-}
-```
-
-Note: this controller assumes `ofv` is non-nil (guaranteed because toggling a project ON creates default OFVs).
-
-### 3.7 Persisting field values
-
-Values are persisted by reading the **visible** cells back into OFVs — a fragile pattern (scrolled-off edits depend on `endEditing`/save being called) that should not be replicated in RN:
-
-```207:238:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (void)saveVisibleObservationFieldValues {
- RLMRealm *realm = [RLMRealm defaultRealm];
-
- for (NSIndexPath *indexPath in self.tableView.indexPathsForVisibleRows) {
- ExploreProjectRealm *project = [self projectForSection:indexPath.section];
- if (!project) return;
-
- ExploreProjectObsFieldRealm *pof = [[project sortedProjectObservationFields] objectAtIndex:indexPath.item];
-
-
- ExploreObsFieldValueRealm *ofv = [self.observation valueForObsField:pof.obsField];
- if (ofv) {
- if (![ofv.value isEqualToString:[self currentValueForIndexPath:indexPath]]) {
- [realm beginWriteTransaction];
- ofv.value = [self currentValueForIndexPath:indexPath];
- ofv.timeUpdatedLocally = [NSDate date];
- [realm commitWriteTransaction];
- }
- } else {
- ofv = [ExploreObsFieldValueRealm new];
- ofv.uuid = [[[NSUUID UUID] UUIDString] lowercaseString];
- ofv.obsField = pof.obsField;
- ofv.value = [self currentValueForIndexPath:indexPath];
- ofv.timeUpdatedLocally = [NSDate date];
-
- [realm beginWriteTransaction];
- [realm addObject:ofv];
- [self.observation.observationFieldValues addObject:ofv];
- [realm commitWriteTransaction];
- }
- }
-}
-```
-
-It is triggered from text field end-editing, the date/time picker done blocks, the taxon delegate, and `backPressed:`:
-
-```242:252:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (void)textFieldDidEndEditing:(UITextField *)textField {
- if ([textField.superview.superview isKindOfClass:[ObsFieldSimpleValueCell class]]) {
- // this textfield needs to be cleared and the value set
- ObsFieldSimpleValueCell *cell = (ObsFieldSimpleValueCell *)textField.superview.superview;
- cell.valueLabel.text = textField.text;
- [textField removeFromSuperview];
- cell.valueLabel.hidden = NO;
- }
-
- [self saveVisibleObservationFieldValues];
-}
-```
-
-### 3.8 Required-field indication and the visually distinct field form
-
-Required fields are shown **bold**:
-
-```607:634:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (void)configureSimpleCell:(ObsFieldSimpleValueCell *)cell forProjectObsField:(ExploreProjectObsFieldRealm *)pof {
-
- cell.fieldLabel.text = pof.obsField.name;
- if (pof.required) {
- cell.fieldLabel.font = [UIFont boldSystemFontOfSize:cell.fieldLabel.font.pointSize];
- } else {
- cell.fieldLabel.font = [UIFont systemFontOfSize:cell.fieldLabel.font.pointSize];
- }
-
- ExploreObsFieldValueRealm *ofv = [self.observation valueForObsField:pof.obsField];
- if (ofv) {
- if (pof.obsField.dataType == ExploreObsFieldDataTypeTaxon) {
- ExploreTaxonRealm *taxon = [ExploreTaxonRealm objectForPrimaryKey:@(ofv.value.integerValue)];
- if (taxon) {
- cell.valueLabel.text = taxon.commonName ?: taxon.scientificName;
- } else {
- cell.valueLabel.text = (ofv.value.length == 0) ? @"unknown" : ofv.value;
- }
- } else {
- cell.valueLabel.text = ofv.value ?: pof.obsField.allowedValues.firstObject;
- }
- } else {
- // show default
- cell.valueLabel.text = pof.obsField.allowedValues.firstObject;
- }
-
- cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
-}
-```
-
-```636:655:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (void)configureLongTextCell:(ObsFieldLongTextValueCell *)cell forProjectObsField:(ExploreProjectObsFieldRealm *)pof {
- cell.fieldLabel.text = pof.obsField.name;
-
- if (pof.required) {
- cell.fieldLabel.font = [UIFont boldSystemFontOfSize:cell.fieldLabel.font.pointSize];
- } else {
- cell.fieldLabel.font = [UIFont systemFontOfSize:cell.fieldLabel.font.pointSize];
- }
-
- cell.textField.delegate = self;
-
- ExploreObsFieldValueRealm *ofv = [self.observation valueForObsField:pof.obsField];
- if (ofv) {
- cell.textField.text = ofv.value ?: ofv.obsField.allowedValues.firstObject;
- } else {
- cell.textField.text = nil;
- }
-
- cell.accessoryType = UITableViewCellAccessoryNone;
-}
-```
-
-The field form is visually distinguished from the rest of the obs edit flow by a green-tinted cell background (POD design requirement "visually distinct"):
-
-```16:21:INaturalistIOS/Views/ObsFieldSimpleValueCell.m
-- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
-
- if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
-
- self.backgroundColor = [UIColor colorWithHexString:@"#f1f7e5"];
- self.indentationLevel = 3;
-```
-
-```15:19:INaturalistIOS/Views/ObsFieldLongTextValueCell.m
-- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
- if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
-
- self.backgroundColor = [UIColor colorWithHexString:@"#f1f7e5"];
-```
-
-The free-text placeholder:
-
-```30:38:INaturalistIOS/Views/ObsFieldLongTextValueCell.m
- self.textField = ({
- UITextField *tf = [[UITextField alloc] initWithFrame:CGRectZero];
- tf.translatesAutoresizingMaskIntoConstraints = NO;
-
- tf.font = [UIFont systemFontOfSize:14.0f];
- tf.placeholder = NSLocalizedString(@"Your response here", @"Placeholder for free text observation field value");
-
- tf;
- });
-```
-
-Row heights also use bold vs regular font for measurement:
-
-```400:411:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
- ExploreProjectRealm *project = [self projectForSection:indexPath.section];
- ExploreProjectObsFieldRealm *pof = [[project sortedProjectObservationFields] objectAtIndex:indexPath.item];
-
- UIFont *fieldFont = pof.required ? [UIFont boldSystemFontOfSize:17] : [UIFont systemFontOfSize:17];
-
- if ([[pof obsField] canBeTreatedAsText] && [[[pof obsField] allowedValues] count] > 1) {
- return [self heightForSimpleProjectField:pof inTableView:tableView font:fieldFont];
- } else {
- return [self heightForLongTextProjectField:pof inTableView:tableView font:fieldFont];
- }
-}
-```
-
-Arbitrary field-list length is handled structurally (one table section per project, one row per field), but persistence relies on the visible-rows-only save above — the fragile part for long field lists.
-
-### 3.9 Required-field validation (critical gotcha: it is unwired)
-
-A client-side validator exists:
-
-```187:205:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (BOOL)validateProjectObservationsForObservation:(ExploreObservationRealm *)observation
- failedProject:(out NSString **)failedProjectName
- failedField:(out NSString **)failedFieldName {
-
- for (ExploreProjectObservationRealm *po in self.observation.projectObservations) {
- for (ExploreProjectObsFieldRealm *pof in po.project.projectObsFields) {
- if (pof.required) {
- ExploreObsFieldValueRealm *ofv = [self.observation valueForObsField:pof.obsField];
- if (!ofv || ofv.value == nil || ofv.value.length == 0) {
- *failedProjectName = po.project.title;
- *failedFieldName = pof.obsField.name;
- return NO;
- }
- }
- }
- }
-
- return YES;
-}
-```
-
-And a back handler that runs it with a "Missing Required Field" alert:
-
-```155:183:INaturalistIOS/Controllers/Projects/ProjectObservationsViewController.m
-- (void)backPressed:(UIBarButtonItem *)button {
- // end editing on any rows
- [self.tableView endEditing:YES];
-
- // save the ofvs
- [self saveVisibleObservationFieldValues];
-
- NSString *projectNameFailingValidation = [NSString string];
- NSString *projectFieldFailingValidation = [NSString string];
-
- BOOL validated = [self validateProjectObservationsForObservation:self.observation
- failedProject:&projectNameFailingValidation
- failedField:&projectFieldFailingValidation];
-
- if (validated) {
- [self.navigationController popViewControllerAnimated:YES];
- } else {
- NSString *msg = [NSString stringWithFormat:NSLocalizedString(@"'%@' requires that you fill out the '%@' field.",nil),
- projectNameFailingValidation,
- projectFieldFailingValidation];
- UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Missing Required Field",nil)
- message:msg
- preferredStyle:UIAlertControllerStyleAlert];
- [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK",nil)
- style:UIAlertActionStyleCancel
- handler:nil]];
- [self presentViewController:alert animated:YES completion:nil];
- }
-}
-```
-
-**However, `backPressed:` is never wired up anywhere** — a repo-wide search finds only its definition at `ProjectObservationsViewController.m:155`, no `addTarget:`/selector references. The standard navigation back button and swipe-back skip validation entirely. This is dead/incomplete code. In practice required-field enforcement is server-side (section 5.4). Also note that toggling a project on seeds each OFV with `allowedValues.firstObject` (section 3.3) — a non-empty default silently satisfies "required" without user interaction. The RN port must implement real pre-upload client validation per the POD requirement; port the validator logic, not the broken wiring.
-
-### 3.10 Saving the observation: `validatedSave`
-
-Obs edit's save does **not** run project required-field validation. It clears `validationErrorMsg`, persists the observation, then materializes the staged removals as delete tombstones:
-
-```969:1016:INaturalistIOS/Controllers/Observations/Observation Details/ObsEditV2ViewController.m
-- (void)validatedSave {
- [self.view endEditing:YES];
-
- self.shouldContinueUpdatingLocation = NO;
- [self stopUpdatingLocation];
-
- // clear upload validation error message
- RLMRealm *realm = [RLMRealm defaultRealm];
- [realm beginWriteTransaction];
- self.standaloneObservation.validationErrorMsg = nil;
- [realm commitWriteTransaction];
-
- if (self.isMakingNewObservation) {
- // insert new standalone observation into realm
- RLMRealm *realm = [RLMRealm defaultRealm];
- [realm beginWriteTransaction];
- [realm addObject:self.standaloneObservation];
- [realm commitWriteTransaction];
- } else {
- // merge observation with standalone editing copy
- RLMRealm *realm = [RLMRealm defaultRealm];
- [realm beginWriteTransaction];
- // use addOrUpdateObject: instead of createOrUpdateInRealm: because
- // we want to allow users to delete photos and clear records
- [realm addOrUpdateObject:self.standaloneObservation];
- [realm commitWriteTransaction];
-
-
- // time to make deleted records for our stuff
- // would be nice to make this an inherited or protocol method
- [realm beginWriteTransaction];
- for (RLMObject *recordToDelete in self.recordsToDelete) {
- [realm addOrUpdateObject:[recordToDelete deletedRecordForModel]];
- }
- [realm commitWriteTransaction];
-
- // purge from realm
- // have to do this carefully since the handle we have on realm might
- // not be the realm handle where the deleted record was made
- for (RLMObject *recordToDelete in self.recordsToDelete) {
- if ([recordToDelete realm]) {
- RLMRealm *realm = [recordToDelete realm];
- [realm beginWriteTransaction];
- [realm deleteObject:recordToDelete];
- [realm commitWriteTransaction];
- }
- }
- }
-
- [self.view.window.rootViewController dismissViewControllerAnimated:YES completion:^{
-```
-
----
-
-## 4. API endpoints
-
-All project traffic goes through the Node API:
-
-```36:38:INaturalistIOS/API Endpoints/Node API/INatAPI.m
-- (NSString *)apiBaseUrl {
- return @"https://api.inaturalist.org/v1";
-}
-```
-
-`INatAPI` appends a `locale` query parameter to every request and attaches a JWT `Authorization` header for logged-in requests.
-
-The complete `ProjectsAPI` surface relevant to this feature:
-
-```21:35:INaturalistIOS/API Endpoints/Node API/ProjectsAPI.m
-- (NSInteger)projectsPerPage {
- return 100;
-}
-
-- (NSInteger)observationsProjectPerPage {
- return 200;
-}
-
-- (void)projectsForUser:(NSInteger)userId page:(NSInteger)page handler:(INatAPIFetchCompletionCountHandler)done {
- [[Analytics sharedClient] debugLog:@"Network - fetch a page of user projects from node"];
- NSString *path = [NSString stringWithFormat:@"/v1/users/%ld/projects", (long)userId];
- NSString *query = [NSString stringWithFormat:@"per_page=%ld&page=%ld",
- (long)self.projectsPerPage, (long)page];
- [self fetch:path query:query classMapping:ExploreProject.class handler:done];
-}
-```
-
-```61:73:INaturalistIOS/API Endpoints/Node API/ProjectsAPI.m
-- (void)joinProject:(NSInteger)projectId handler:(INatAPIFetchCompletionCountHandler)done {
- [[Analytics sharedClient] debugLog:@"Network - join project via node"];
- NSString *path =[NSString stringWithFormat:@"/v1/projects/%ld/join",
- (long)projectId];
- [self post:path query:nil params:nil classMapping:ExploreProject.class handler:done];
-}
-
-- (void)leaveProject:(NSInteger)projectId handler:(INatAPIFetchCompletionCountHandler)done {
- [[Analytics sharedClient] debugLog:@"Network - join project via node"];
- NSString *path = [NSString stringWithFormat:@"/v1/projects/%ld/leave",
- (long)projectId];
- [self delete:path query:nil handler:done];
-}
-```
-
-Endpoint summary:
-
-- `GET /v1/users/{userId}/projects?per_page=100&page=N` — joined projects (paginated; includes `project_observation_fields`)
-- `POST /v1/projects/{id}/join` — join (no body)
-- `DELETE /v1/projects/{id}/leave` — leave
-- `POST/PUT /v1/project_observations[/{id}]` — attach observation to project (see 5.1)
-- `POST/PUT /v1/observation_field_values[/{id}]` — field values (see 5.1)
-- `DELETE /v1/project_observations/{id}` and `DELETE /v1/observation_field_values/{id}` — removals (see 5.3)
-
-There is **no** `GET /v1/projects/{id}` single-project fetch anywhere in `ProjectsAPI.m` — project metadata comes from list/join/search responses. Project detail tab counts use `GET /v1/observations`, `/v1/observations/species_counts`, `/v1/observations/observers`, `/v1/observations/identifiers` filtered by `project_id` (`ProjectsAPI.m` lines 77–104).
-
----
-
-## 5. Upload / sync pipeline
-
-### 5.1 Upload payloads
-
-Both record types implement the `Uploadable` protocol:
-
-```13:31:INaturalistIOS/Helpers/Uploader/Uploadable.h
-@protocol Uploadable
-
-- (NSArray *)childrenNeedingUpload;
-- (BOOL)needsUpload;
-+ (NSArray *)needingUpload;
-- (NSDictionary *)uploadableRepresentation;
-- (NSString *)uuid;
-+ (NSString *)endpointName;
-- (NSDate *)timeSynced;
-- (void)setTimeSynced:(NSDate *)date;
-- (void)setRecordId:(NSInteger)newRecordId;
-- (NSInteger)recordId;
-
-// uploadable stuff needs to be deletable, too
-- (ExploreDeletedRecord *)deletedRecordForModel;
-// would be nice to be generic here
-+ (void)syncedDelete:(id )model;
-+ (void)deleteWithoutSync:(id )model;
-```
-
-Project observation: **flat** body, endpoint `project_observations`. Requires the parent observation's server id, so it can only upload after the observation exists server-side:
-
-```122:136:INaturalistIOS/Models/Realm/ExploreProjectObservationRealm.m
-- (NSDictionary *)uploadableRepresentation {
- if (self.observation && self.project) {
- return @{
- @"observation_id": @(self.observation.observationId),
- @"project_id": @(self.project.projectId),
- @"uuid": self.uuid,
- };
- } else {
- return nil;
- }
-}
-
-+ (NSString *)endpointName {
- return @"project_observations";
-}
-```
-
-Observation field value: **nested** body under `observation_field_value`, endpoint `observation_field_values`. The asymmetry with the PO payload is intentional in this codebase:
-
-```132:149:INaturalistIOS/Models/Realm/ExploreObsFieldValueRealm.m
-- (NSDictionary *)uploadableRepresentation {
- if (self.obsField && self.observation && self.uuid && self.value) {
- return @{
- @"observation_field_value": @{
- @"uuid": self.uuid,
- @"value": self.value,
- @"observation_id": @(self.observation.observationId),
- @"observation_field_id": @(self.obsField.obsFieldId),
- },
- };
- } else {
- return nil;
- }
-}
-
-+ (NSString *)endpointName {
- return @"observation_field_values";
-}
-```
-
-Dirty-tracking on both children (`timeSynced` vs `timeUpdatedLocally`):
-
-```110:115:INaturalistIOS/Models/Realm/ExploreProjectObservationRealm.m
-- (BOOL)needsUpload {
- if (self.uploadableRepresentation == nil) { return NO; } // nothing to upload
- if (!self.timeSynced) { return YES; } // never uploaded, needs upload
- if ([self.timeSynced timeIntervalSinceDate:self.timeUpdatedLocally] < 0) { return YES; } // updated since last sync, needs upload
- return NO; // doesn't need upload
-}
-```
-
-### 5.2 Upload ordering
-
-Child upload order is fixed: photos, then sounds, then **OFVs**, then **project observations**:
-
-```541:573:INaturalistIOS/Models/Realm/ExploreObservationRealm.m
-- (NSArray *)childrenNeedingUpload {
- NSMutableArray *recordsToUpload = [NSMutableArray array];
-
- for (ExploreObservationPhotoRealm *op in self.observationPhotos) {
- if ([op needsUpload]) {
- [recordsToUpload addObject:op];
- }
- }
-
- for (ExploreObservationSoundRealm *os in self.observationSounds) {
- if ([os needsUpload]) {
- [recordsToUpload addObject:os];
- }
- }
-
- for (ExploreObsFieldValueRealm *ofv in self.observationFieldValues) {
- if ([ofv needsUpload]) {
- [recordsToUpload addObject:ofv];
- }
- }
-
- for (ExploreProjectObservationRealm *po in self.projectObservations) {
- if ([po needsUpload]) {
- [recordsToUpload addObject:po];
- }
- }
-
- return [NSArray arrayWithArray:recordsToUpload];
-}
-
-- (BOOL)needsUpload {
- return self.timeSynced == nil || [self.timeSynced timeIntervalSinceDate:self.timeUpdatedLocally] < 0;
-}
-```
-
-Per-observation upload starts with the observation itself (POST for new, PUT for updates), then children serially:
-
-```125:133:INaturalistIOS/Helpers/Uploader/UploadObservationOperation.m
- if (o.needsUpload) {
- NSString *httpMethod = o.timeSynced ? @"PUT" : @"POST";
- [self syncObservation:o method:httpMethod];
- } else if (o.childrenNeedingUpload.count > 0) {
- [self syncChildRecord:o.childrenNeedingUpload.firstObject
- ofObservation:o];
- } else {
- [self syncObservationFinishedSuccess:YES syncError:nil];
- }
-}
-```
-
-Child dispatch: POST for never-synced records, PUT to `/v1/{endpointName}/{recordId}` for updates:
-
-```233:236:INaturalistIOS/Helpers/Uploader/UploadObservationOperation.m
-- (void)syncChildRecord:(id )child ofObservation:(ExploreObservationRealm *)observation {
- NSString *HTTPMethod = child.timeSynced ? @"PUT" : @"POST";
-
- NSString *childUUID = [child uuid];
-```
-
-```358:371:INaturalistIOS/Helpers/Uploader/UploadObservationOperation.m
- NSString *path = nil;
- if ([HTTPMethod isEqualToString:@"PUT"]) {
- path = [NSString stringWithFormat:@"/v1/%@/%ld",
- [[child class] endpointName],
- (long)[child recordId]];
- path = [path stringByAppendingFormat:@"?%@", localeQuery];
-
- [self.nodeSessionManager PUT:path
- parameters:[child uploadableRepresentation]
- success:successBlock
- failure:failureBlock];
- } else {
- path = [NSString stringWithFormat:@"/v1/%@", [[child class] endpointName]];
- path = [path stringByAppendingFormat:@"?%@", localeQuery];
-```
-
-On success the server id is written back to the child (`recordId` maps to `projectObsId` / `obsFieldValueId`):
-
-```250:261:INaturalistIOS/Helpers/Uploader/UploadObservationOperation.m
- RLMRealm *realm = [RLMRealm defaultRealm];
- // this observation has been synced
- [realm beginWriteTransaction];
- localChild.timeSynced = [NSDate date];
- [realm commitWriteTransaction];
-
- // record ids come from the server
- if ([responseObject valueForKey:@"id"]) {
- [realm beginWriteTransaction];
- [localChild setRecordId:[[responseObject valueForKey:@"id"] integerValue]];
- [realm commitWriteTransaction];
- }
-```
-
-### 5.3 Deletions: tombstones and ordering
-
-Removals are tracked as `ExploreDeletedRecord` tombstones:
-
-```11:19:INaturalistIOS/Models/Realm/ExploreDeletedRecord.h
-@interface ExploreDeletedRecord : RLMObject
-
-@property NSInteger recordId;
-@property NSString *modelName;
-@property NSString *endpointName;
-@property BOOL synced;
-// synthetic primary key
-@property NSString *modelAndRecordId;
-```
-
-Created from the child records (e.g. project observation):
-
-```181:187:INaturalistIOS/Models/Realm/ExploreProjectObservationRealm.m
-- (ExploreDeletedRecord *)deletedRecordForModel {
- ExploreDeletedRecord *dr = [[ExploreDeletedRecord alloc] initWithRecordId:self.recordId
- modelName:@"ProjectObservation"];
- dr.endpointName = [self.class endpointName];
- dr.synced = NO;
- return dr;
-}
-```
-
-Deletes run in a **specific model order** — project observations before field values — to avoid server 422s:
-
-```187:206:INaturalistIOS/Helpers/Uploader/UploadManager.m
-/*
- Arrange deleted records. We need to delete in a specific order in order to avoid
- invalidation errors on the server. For example, a project may require certain fields
- or photos to be a member - deleting the fields or the photos before deleting the
- project observation will result in a 422 validation error from the server.
-
- This is a public method so that the UI can know if there are records to delete
- or not.
- */
-
-- (NSArray *)deletedRecordsNeedingSync {
- // delete in a specific order
- NSMutableArray *recordsToDelete = [NSMutableArray array];
- for (NSString *modelName in @[ @"Observation", @"ProjectObservation", @"ObservationPhoto", @"ObservationFieldValue", ]) {
- RLMResults *needingDelete = [ExploreDeletedRecord needingSyncForModelName:modelName];
- // convert to array and add to our list of all things to delete
- [recordsToDelete addObjectsFromArray:[needingDelete valueForKey:@"self"]];
- }
- return [NSArray arrayWithArray:recordsToDelete];
-}
-```
-
-Deletes run before uploads in a session:
-
-```70:75:INaturalistIOS/Helpers/Uploader/UploadManager.m
- if (self.deletedRecordsNeedingSync.count > 0) {
- [self syncDeletes];
- } else {
- [self syncUploads];
- }
-}
-```
-
-The delete operation hits `DELETE /v1/{endpointName}/{recordId}` and treats 404/403 as success:
-
-```55:56:INaturalistIOS/Helpers/Uploader/DeleteRecordOperation.m
- NSString *deletePath = [NSString stringWithFormat:@"/v1/%@/%ld", self.endpointName, (long)self.recordId];
-
-```
-
-```68:85:INaturalistIOS/Helpers/Uploader/DeleteRecordOperation.m
- } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
- BOOL actualSuccess = NO;
- NSHTTPURLResponse *r = [error.userInfo valueForKey:AFNetworkingOperationFailingURLResponseErrorKey];
- if (r) {
- if (r.statusCode == 404 || r.statusCode == 403) {
- // treat 404s and 403s as successful deletions
- // 404 means it was already deleted
- // 403 means you don't own the resource and can't delete it
- // in either case don't block the user from doing other stuff
- ExploreDeletedRecord *dr = [ExploreDeletedRecord deletedRecordId:self.recordId withModelName:self.modelName];
- RLMRealm *realm = [RLMRealm defaultRealm];
- [realm beginWriteTransaction];
- dr.synced = YES;
- [realm commitWriteTransaction];
-
- actualSuccess = YES;
- }
- }
-```
-
-### 5.4 Server-side validation (422) and `validationErrorMsg`
-
-When a child upload fails with HTTP 422, the error is extracted and stored on the **observation**:
-
-```293:333:INaturalistIOS/Helpers/Uploader/UploadObservationOperation.m
- if ([[error userInfo] valueForKey:AFNetworkingOperationFailingURLResponseErrorKey]) {
- NSHTTPURLResponse *response = [[error userInfo] valueForKey:AFNetworkingOperationFailingURLResponseErrorKey];
- if (response.statusCode == 422) {
-
- // try to extract a validation error from the json response
- NSData *data = [[error userInfo] valueForKey:AFNetworkingOperationFailingURLResponseDataErrorKey];
- NSError *jsonDecodeError = nil;
- id json = [NSJSONSerialization JSONObjectWithData:data
- options:NSJSONReadingAllowFragments
- error:&jsonDecodeError];
-
- NSString *validationError = error.localizedDescription;
- NSArray *validationErrors = [json valueForKey:@"errors"];
- if (validationErrors && validationErrors.count > 0) {
- validationError = validationErrors.firstObject;
- }
-
- RLMRealm *realm = [RLMRealm defaultRealm];
- if ([localChild isKindOfClass:ExploreProjectObservationRealm.class]) {
- // add project validation error notice
- ExploreObservationRealm *eor = [ExploreObservationRealm objectForPrimaryKey:self.rootObjectUUID];
- ExploreProjectObservationRealm *po = [ExploreProjectObservationRealm objectForPrimaryKey:childUUID];
- NSString *baseErrMsg = NSLocalizedString(@"Couldn't be added to project %@. %@",
- @"Project validation error. first string is project title, second is the specific error");
- [realm beginWriteTransaction];
- eor.validationErrorMsg = [NSString stringWithFormat:baseErrMsg,
- po.project.title, validationError];
- [realm commitWriteTransaction];
-
- // fall through to failing and reporting the error
- } else if ([localChild isKindOfClass:ExploreObsFieldValueRealm.class]) {
- // add observation field validation error notice
- ExploreObservationRealm *eor = [ExploreObservationRealm objectForPrimaryKey:self.rootObjectUUID];
- NSString *baseErrMsg = NSLocalizedString(@"Observation Field Validation error: %@",
- @"Project validation error, with the specific error");
- [realm beginWriteTransaction];
- eor.validationErrorMsg = [NSString stringWithFormat:baseErrMsg, validationError];
- [realm commitWriteTransaction];
-
- // fall through to failing and reporting the error
- }
- }
- }
- [self syncObservationFinishedSuccess:NO syncError:error];
-```
-
-Observations carrying a `validationErrorMsg` are excluded from autoupload:
-
-```219:239:INaturalistIOS/Helpers/Uploader/UploadManager.m
-/*
- Upload all pending content. The exclude flag allows us to exclude any pending
- content that failed to upload last time due to server-side data validation issues.
- */
-- (void)autouploadPendingContentExcludeInvalids:(BOOL)excludeInvalids {
- if (!self.shouldAutoupload) { return; }
-
- // invalid observations failed validation their last upload
- NSPredicate *noInvalids = [NSPredicate predicateWithBlock:^BOOL(ExploreObservationRealm *observation, NSDictionary *bindings) {
- return !(observation.validationErrorMsg && observation.validationErrorMsg.length > 0);
- }];
-
- NSArray *observationsToUpload = [ExploreObservationRealm needingUpload];
- if (excludeInvalids) {
- observationsToUpload = [observationsToUpload filteredArrayUsingPredicate:noInvalids];
- }
-
- if (self.deletedRecordsNeedingSync.count > 0 || self.observationsNeedingUpload.count > 0) {
- [self syncDeletedRecordsThenUploadObservations];
- }
-}
-```
-
-`validationErrorMsg` is cleared in two places only: `validatedSave` (section 3.10) when the user re-saves, and at the start of the next upload attempt:
-
-```98:102:INaturalistIOS/Helpers/Uploader/UploadObservationOperation.m
- // clear any validation errors
- RLMRealm *realm = [RLMRealm defaultRealm];
- [realm beginWriteTransaction];
- o.validationErrorMsg = nil;
- [realm commitWriteTransaction];
-```
-
-### 5.5 Upload-time reconciliation: there is none
-
-The uploader is a dumb replay of whatever was persisted in Realm at edit time. `syncChildRecord:` simply POSTs/PUTs the stored `uploadableRepresentation` (section 5.2) — there is no re-fetch of the project or its `project_observation_fields`, no diffing of locally stored field definitions against the server, and no re-validation of stored OFVs before sending. Payloads carry only ids and raw string values, so any staleness travels straight to the server.
-
-If the project definition changed between edit and upload (admin added a required field, deleted a field, changed allowed values), the server rejects with 422 and the flow in section 5.4 takes over: the observation sync fails, `validationErrorMsg` is set, autoupload excludes the observation, and recovery is fully manual (reopen, fix, re-save). Note the upload order (OFVs before POs) means a "missing required field" 422 typically lands on the `project_observations` create.
-
-The only "refresh" that exists is UI-time, not upload-time: opening the chooser while online wipes and re-fetches joined projects (section 3.2), and `createOrUpdateInDefaultRealmWithValue:` upserts each `ExploreProjectRealm` by `projectId`, updating its `projectObsFields` to the latest server state. Nothing reconciles records already queued for upload — e.g. an OFV pointing at a since-deleted field stays queued and will 422.
-
----
-
-## 6. Join / leave flows
-
-### 6.1 Join/leave entry point and alert texts
-
-```195:228:INaturalistIOS/Controllers/Projects/Project Details/ProjectDetailV2ViewController.m
-- (void)joinTapped:(UIButton *)button {
- if (![[INatReachability sharedClient] isNetworkReachable]) {
- UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Internet required", nil)
- message:NSLocalizedString(@"You must be connected to the Internet to do this.", nil)
- preferredStyle:UIAlertControllerStyleAlert];
- [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK",nil)
- style:UIAlertActionStyleCancel
- handler:nil]];
- [self presentViewController:alert animated:YES completion:nil];
- return;
- }
-
- INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
- if ([appDelegate.loginController.meUserLocal hasJoinedProjectWithId:self.project.projectId]) {
- UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Are you sure you want to leave this project?", nil)
- message:NSLocalizedString(@"This will also remove your observations from this project.",nil)
- preferredStyle:UIAlertControllerStyleAlert];
- [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", nil)
- style:UIAlertActionStyleCancel
- handler:nil]];
- [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Leave", nil)
- style:UIAlertActionStyleDestructive
- handler:^(UIAlertAction * _Nonnull action) {
- [self leave];
- }]];
- [self presentViewController:alert animated:YES completion:nil];
- } else {
- if ([(INaturalistAppDelegate *)UIApplication.sharedApplication.delegate loggedIn]) {
- [self join];
- } else {
- [self presentSignupPrompt:NSLocalizedString(@"You must be signed in to join a project.", @"Reason text for signup prompt while trying to join a project.")];
- }
- }
-}
-```
-
-Observations:
-
-- Join/leave is **hard-blocked offline** ("Internet required") — there is no offline join queue.
-- The leave warning ("This will also remove your observations from this project.") describes **server** behavior; the app does no local cleanup of `ExploreProjectObservationRealm` records on leave.
-- **There is no hidden-coordinates / curator-trust prompt at join time anywhere in this codebase.** The full join path is the code above plus `-join` below; searches for coordinate/curator/trust/hidden prompts in the project controllers find nothing. The POD's "hidden location access permission option at join" is net-new for RN (web-only today).
-- The POD's "keep or remove observations on leave" option also does not exist here; the classic app only warns. Net-new for RN.
-
-Join button label reflects membership:
-
-```238:247:INaturalistIOS/Controllers/Projects/Project Details/ProjectDetailV2ViewController.m
-- (void)configureJoinButton {
- INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
- if ([appDelegate.loginController.meUserLocal hasJoinedProjectWithId:self.project.projectId]) {
- [self.joinButton setTitle:[NSLocalizedString(@"Leave", @"Leave project button") uppercaseString]
- forState:UIControlStateNormal];
- } else {
- [self.joinButton setTitle:[NSLocalizedString(@"Join", @"Join project button") uppercaseString]
- forState:UIControlStateNormal];
- }
-}
-```
-
-### 6.2 Join: network + local effects
-
-`POST /v1/projects/{id}/join`, then upsert the project into Realm and append to `joinedProjects`. The join API response body is otherwise ignored:
-
-```258:310:INaturalistIOS/Controllers/Projects/Project Details/ProjectDetailV2ViewController.m
-- (void)join {
-
- MBProgressHUD *hud = [MBProgressHUD showHUDAddedTo:self.view animated:YES];
- hud.labelText = NSLocalizedString(@"Joining...",nil);
- hud.removeFromSuperViewOnHide = YES;
- hud.dimBackground = YES;
-
- __weak typeof(self) weakSelf = self;
- [[self projectsApi] joinProject:self.project.projectId
- handler:^(NSArray *results, NSInteger count, NSError *error) {
-
- [hud hide:YES];
-
- if (error) {
- UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error", nil)
- message:error.localizedDescription
- preferredStyle:UIAlertControllerStyleAlert];
- [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", nil)
- style:UIAlertActionStyleDefault
- handler:nil]];
- [weakSelf presentViewController:alert animated:YES completion:nil];
- } else {
- RLMRealm *realm = [RLMRealm defaultRealm];
-
- if ([weakSelf.project isKindOfClass:[ExploreProject class]]) {
- ExploreProject *ep = (ExploreProject *)weakSelf.project;
- // make this project in realm, set joined to true
- NSDictionary *value = [ExploreProjectRealm valueForMantleModel:ep];
- [realm beginWriteTransaction];
- ExploreProjectRealm *epr = [ExploreProjectRealm createOrUpdateInDefaultRealmWithValue:value];
- [realm commitWriteTransaction];
-
- // set self.project pointer to the new realm project
- weakSelf.project = epr;
-
- INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
- [realm beginWriteTransaction];
- [appDelegate.loginController.meUserLocal.joinedProjects addObject:epr];
- [realm commitWriteTransaction];
- } else if ([weakSelf.project isKindOfClass:[ExploreProjectRealm class]]) {
- // update this project in realm
- RLMRealm *realm = [RLMRealm defaultRealm];
- INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
- [realm beginWriteTransaction];
- [appDelegate.loginController.meUserLocal.joinedProjects addObject:(ExploreProjectRealm *)weakSelf.project];
- [realm commitWriteTransaction];
- }
-
- [self configureJoinButton];
- }
-
- }];
-}
-```
-
-### 6.3 Leave: network + local effects
-
-`DELETE /v1/projects/{id}/leave`, then remove from `joinedProjects`. The project row itself is not deleted, and existing local project observations are untouched:
-
-```312:346:INaturalistIOS/Controllers/Projects/Project Details/ProjectDetailV2ViewController.m
-- (void)leave {
-
- MBProgressHUD *hud = [MBProgressHUD showHUDAddedTo:self.view animated:YES];
- hud.labelText = NSLocalizedString(@"Leaving...",nil);
- hud.removeFromSuperViewOnHide = YES;
- hud.dimBackground = YES;
-
- __weak typeof(self) weakSelf = self;
- [[self projectsApi] leaveProject:self.project.projectId
- handler:^(NSArray *results, NSInteger count, NSError *error) {
-
- [hud hide:YES];
-
- if (error) {
- UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error", nil)
- message:error.localizedDescription
- preferredStyle:UIAlertControllerStyleAlert];
- [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", nil)
- style:UIAlertActionStyleDefault
- handler:nil]];
- [weakSelf presentViewController:alert animated:YES completion:nil];
- } else {
- ExploreProjectRealm *projectToLeave = (ExploreProjectRealm *)self.project;
- RLMRealm *realm = [RLMRealm defaultRealm];
- // update this project in realm
- INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
- NSInteger indexOfProjectToLeave = [appDelegate.loginController.meUserLocal.joinedProjects indexOfObject:projectToLeave];
- [realm beginWriteTransaction];
- [appDelegate.loginController.meUserLocal.joinedProjects removeObjectAtIndex:indexOfProjectToLeave];
- [realm commitWriteTransaction];
-
- [self configureJoinButton];
- }
- }];
-}
-```
-
-### 6.4 Projects tab
-
-The Joined segment reads `joinedProjects` from Realm:
-
-```59:90:INaturalistIOS/Controllers/Projects/ProjectsViewController.m
-- (NSArray *)projects {
- if (self.searchController.isActive) {
- // show searched projects
- return self.matchingProjects;
- } else {
- // show projects for context
- switch (self.listControl.selectedSegmentIndex) {
- case ListControlIndexFeatured:
- return [self featuredProjects];
- break;
- case ListControlIndexNearby:
- return [self nearbyProjects];
- break;
- case ListControlIndexUser: {
- INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
- ExploreUserRealm *me = appDelegate.loginController.meUserLocal;
- if (me) {
- return [[me.joinedProjects sortedResultsUsingKeyPath:@"title" ascending:YES] valueForKey:@"self"];
- } else {
- return nil;
- }
-
- break;
- }
- default:
- return @[];
- break;
- }
- }
-
- return @[];
-}
-```
-
-The Projects tab refresh **wipes all** `ExploreProjectRealm` rows before re-fetching — aggressive; port carefully if you cache project metadata referenced by observations:
-
-```128:148:INaturalistIOS/Controllers/Projects/ProjectsViewController.m
-- (void)syncUserProjects {
- // start by deleting all projects stored in realm
- self->activityCount += 1;
- RLMRealm *realm = [RLMRealm defaultRealm];
- [realm beginWriteTransaction];
- [realm deleteObjects:[ExploreProjectRealm allObjects]];
- [realm commitWriteTransaction];
-
- // empty the UI
- [self.tableView reloadData];
-
- // fetch first page of joined projects, if we can
- INaturalistAppDelegate *appDelegate = (INaturalistAppDelegate *)[[UIApplication sharedApplication] delegate];
- if ([appDelegate.loginController isLoggedIn]) {
- ExploreUserRealm *me = [appDelegate.loginController meUserLocal];
- [self syncUserProjectsUserId:me.userId page:1];
- } else {
- [self showSignupPrompt:NSLocalizedString(@"You must be logged in to sync user projects.", @"Signup prompt reason when user tries to sync user projects.")];
- }
- [self syncFinished];
-}
-```
-
-List cells show title + icon only — **no traditional/collection/umbrella indicator** in project lists (the type label appears only in the obs-edit chooser headers, section 3.2). The POD's traditional-project indicator in lists is net-new for RN.
-
----
-
-## 7. Offline behavior
-
-What works offline:
-
-- **Attaching an observation to a project and filling fields.** The chooser reads joined projects from Realm; the network re-sync in `viewDidLoad` only runs when reachable (section 3.2, lines 126–140). Offline it uses the cached `meUser.joinedProjects`.
-- **Field definitions are cached.** The joined-projects sync persists each project including its `project_observation_fields` (sections 2.3 and 3.2), so field names, datatypes, allowed values, and required flags are available locally.
-- **Persistence and deferred upload.** Toggling a project on writes the PO + default OFVs to Realm immediately with client-generated UUID PKs and `timeSynced == nil` (section 3.3). The upload queue picks them up later; autoupload triggers on reachability changes. Removals are queued as tombstones and replayed (section 5.3).
-
-What does not work offline:
-
-- **Joining or leaving a project** — hard-blocked with the "Internet required" alert (section 6.1); no offline join queue.
-- **First-time availability** — joined projects and their obs fields must have been synced at least once while online; a fresh install offline has nothing to show.
-- **Taxon-type fields** — the taxon picker (`TaxaSearchViewController`) searches via the API, so picking a taxon for a taxon field effectively needs connectivity.
-- **Required-field validation feedback** — since the client-side validator is unwired (section 3.9), validation errors only surface as server 422s after reconnecting, via `validationErrorMsg`.
-
-POD relevance: the POD's offline requirement matches what this app already does (Realm-cached joined projects with embedded obs fields, UUID-keyed offline records, sync queue). Gaps for RN: no offline join/leave, and weak offline "failure states" because required-field validation is server-driven.
-
----
-
-## 8. Things this app does NOT do (absence claims)
-
-- **No client-enforced required-field validation before upload.** The validator exists but `backPressed:` is never wired (section 3.9 — only its definition exists at `ProjectObservationsViewController.m:155`); `validatedSave` in obs edit performs no project-field checks (section 3.10). Enforcement is server 422 (section 5.4).
-- **No upload-time reconciliation** of stale project definitions (section 5.5).
-- **No hidden-coordinates / trust prompt at join time** — the full join path is `joinTapped:` → `join` (sections 6.1–6.2); nothing else happens.
-- **No keep/remove-observations choice on leave** — only the warning alert (section 6.1).
-- **No `GET /v1/projects/{id}`** — `ProjectsAPI.m` (section 4) is the complete project API surface; detail screens reuse list/join/search payloads.
-- **No project-type indicator in project lists or project detail** — type label shown only in the obs-edit chooser header (sections 3.2 and 6.4).
-- **No projects or field values on the observation detail screen** — `ObsDetailV2ViewController.m` contains zero references to projects or OFVs; they appear only in the edit flow. There is no read-only display parity target in this app.
-- **No offline join/leave queue** (section 6.1).
-
----
-
-## 9. Porting gotchas (summary)
-
-1. Traditional = any project whose `project_type` is not `collection`/`umbrella` (empty string or missing both map to OldStyle).
-2. Select is not a datatype: text/dna + more than one `allowed_values` entry; exactly one allowed value renders as free text. `allowed_values` arrives pipe-delimited.
-3. All OFV values are strings; taxon fields store the taxon id as a string; toggling a project on seeds each OFV with `allowedValues.firstObject` — a non-empty default silently satisfies "required".
-4. The `dna` datatype exists and is treated as text (`canBeTreatedAsText`).
-5. Client required-field validation exists but is unwired dead code — RN must implement it properly per the POD requirement.
-6. PO upload body is flat; OFV body is nested under `observation_field_value` — asymmetric on purpose.
-7. Strict orderings: upload photos → sounds → OFVs → POs; delete Observation → ProjectObservation → Photo → ObservationFieldValue (POs before OFVs to avoid 422s).
-8. `date` fields use a date+time picker (`UIDatePickerModeDateAndTime`) — likely a bug worth fixing in RN with a date-only picker. Formats: `dd MMM yyyy HH:mm:ss ZZZ` (date/datetime), `HH:mm:ss` (time).
-9. `saveVisibleObservationFieldValues` only persists visible rows — fragile for long field lists; do not replicate.
-10. Toggling a project ON writes to Realm immediately, even for unsaved observations; removals are staged in `recordsToDelete` and committed at save.
-11. Membership is `ExploreUserRealm.joinedProjects` (a user→projects list), not a flag on the project; checks are linear scans.
-12. `syncUserProjects` on the Projects tab deletes **all** `ExploreProjectRealm` rows before re-fetching; the chooser's variant only clears the `joinedProjects` list. Both rely on PK upsert to re-link.
-13. Client-generated lowercase UUIDs are the primary keys for PO/OFV records and are sent on create for server-side idempotency; server ids are written back to `projectObsId` / `obsFieldValueId` after upload.
-14. 404/403 on a queued DELETE is treated as success so unsynced or foreign records never block the queue.
From f45a7e3670ecb53301de790754b6b04710170e37 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 15:14:28 +0200
Subject: [PATCH 025/108] multiple projects should be valid when one OFV
satisfies two projects requiring the same field
---
...alidateProjectFieldsForObservation.test.js | 38 +++++++++++++++++++
1 file changed, 38 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 631f79074..8ee370d24 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -356,4 +356,42 @@ describe( "validateProjectFieldsForObservation", () => {
expect( result.errors[0].reason ).toBe( MISSING_REQUIRED );
} );
} );
+
+ describe( "multiple projects", () => {
+ it( "should be valid when one OFV satisfies two projects requiring the same field", () => {
+ const mockProjectA = {
+ id: 1,
+ title: "Project A",
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ name: "Habitat",
+ },
+ }],
+ };
+ const mockProjectB = {
+ id: 2,
+ title: "Project B",
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ name: "Habitat",
+ },
+ }],
+ };
+ const mockObservation = {
+ observationFieldValues: [{ obsFieldId: 10, value: "shrubland" }],
+ };
+ const result = validateProjectFieldsForObservation(
+ mockObservation,
+ [mockProjectA, mockProjectB],
+ );
+ expect( result.valid ).toBe( true );
+ expect( result.errors ).toEqual( [] );
+ } );
+ } );
} );
From 54cc794860b5c1b7ae5b22d504a8f4e0dbc4b54b Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 15:21:48 +0200
Subject: [PATCH 026/108] should report one error per project when a shared
required field is unfilled
---
...alidateProjectFieldsForObservation.test.js | 36 +++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 8ee370d24..434859b93 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -393,5 +393,41 @@ describe( "validateProjectFieldsForObservation", () => {
expect( result.valid ).toBe( true );
expect( result.errors ).toEqual( [] );
} );
+
+ it( "should report one error per project when a shared required field is unfilled", () => {
+ const mockProjectA = {
+ id: 1,
+ title: "Project A",
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ name: "Habitat",
+ },
+ }],
+ };
+ const mockProjectB = {
+ id: 2,
+ title: "Project B",
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ name: "Habitat",
+ },
+ }],
+ };
+ const mockObservation = { observationFieldValues: [] };
+ const result = validateProjectFieldsForObservation(
+ mockObservation,
+ [mockProjectA, mockProjectB],
+ );
+ expect( result.valid ).toBe( false );
+ expect( result.errors ).toHaveLength( 2 );
+ expect( result.errors.map( e => e.projectTitle ) ).toEqual( ["Project A", "Project B"] );
+ expect( result.errors.map( e => e.fieldName ) ).toEqual( ["Habitat", "Habitat"] );
+ } );
} );
} );
From 334955a342244a3bc56b0dfb3a68cb9f5570b184 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 15:22:19 +0200
Subject: [PATCH 027/108] should report only the failing project when the other
is satisfied
---
...alidateProjectFieldsForObservation.test.js | 37 +++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 434859b93..d376fec82 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -429,5 +429,42 @@ describe( "validateProjectFieldsForObservation", () => {
expect( result.errors.map( e => e.projectTitle ) ).toEqual( ["Project A", "Project B"] );
expect( result.errors.map( e => e.fieldName ) ).toEqual( ["Habitat", "Habitat"] );
} );
+
+ it( "should report only the failing project when the other is satisfied", () => {
+ const mockProjectA = {
+ id: 1,
+ title: "Project A",
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ name: "Habitat",
+ },
+ }],
+ };
+ const mockProjectB = {
+ id: 2,
+ title: "Project B",
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 20,
+ name: "Substrate",
+ },
+ }],
+ };
+ const mockObservation = {
+ observationFieldValues: [{ obsFieldId: 10, value: "shrubland" }],
+ };
+ const result = validateProjectFieldsForObservation(
+ mockObservation,
+ [mockProjectA, mockProjectB],
+ );
+ expect( result.errors ).toHaveLength( 1 );
+ expect( result.errors[0].projectTitle ).toBe( "Project B" );
+ expect( result.errors[0].fieldName ).toBe( "Substrate" );
+ } );
} );
} );
From 39b113b48f78519275862f226aa19086c0fd158b Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 15:23:36 +0200
Subject: [PATCH 028/108] should report errors in the order the projects were
passed in
Not really sure yet if that is important later.
---
...alidateProjectFieldsForObservation.test.js | 33 +++++++++++++++++++
1 file changed, 33 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index d376fec82..dce377b47 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -466,5 +466,38 @@ describe( "validateProjectFieldsForObservation", () => {
expect( result.errors[0].projectTitle ).toBe( "Project B" );
expect( result.errors[0].fieldName ).toBe( "Substrate" );
} );
+
+ it( "should report errors in the order the projects were passed in", () => {
+ const mockProjectA = {
+ id: 1,
+ title: "Project A",
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ name: "Habitat",
+ },
+ }],
+ };
+ const mockProjectB = {
+ id: 2,
+ title: "Project B",
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 20,
+ name: "Substrate",
+ },
+ }],
+ };
+ const mockObservation = { observationFieldValues: [] };
+ const resultBA = validateProjectFieldsForObservation(
+ mockObservation,
+ [mockProjectB, mockProjectA],
+ );
+ expect( resultBA.errors.map( e => e.projectTitle ) ).toEqual( ["Project B", "Project A"] );
+ } );
} );
} );
From 67b8ccb7efbe2ac4a034513f4e463d32bc4ceca2 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 15:25:23 +0200
Subject: [PATCH 029/108] Result should be valid when a project has no POFs
---
.../helpers/validateProjectFieldsForObservation.test.js | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index dce377b47..008ca2e85 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -500,4 +500,13 @@ describe( "validateProjectFieldsForObservation", () => {
expect( resultBA.errors.map( e => e.projectTitle ) ).toEqual( ["Project B", "Project A"] );
} );
} );
+
+ describe( "edge cases", () => {
+ it( "should be valid when no projects are passed", () => {
+ const mockObservation = { observationFieldValues: [] };
+ const result = validateProjectFieldsForObservation( mockObservation, [] );
+ expect( result.valid ).toBe( true );
+ expect( result.errors ).toEqual( [] );
+ } );
+ } );
} );
From ce10ff40faee278c6fec448c5cd7896a0e740fc1 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 15:25:41 +0200
Subject: [PATCH 030/108] should return MISSING_REQUIRED when
observationFieldValues is undefined
---
.../validateProjectFieldsForObservation.test.js | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 008ca2e85..691d01db6 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -508,5 +508,21 @@ describe( "validateProjectFieldsForObservation", () => {
expect( result.valid ).toBe( true );
expect( result.errors ).toEqual( [] );
} );
+ it( "should return MISSING_REQUIRED when observationFieldValues is undefined", () => {
+ const mockProject = {
+ projectObservationFields: [{
+ required: true,
+ obsField: {
+ allowedValues: [],
+ id: 10,
+ name: "Habitat",
+ },
+ }],
+ };
+ const mockObservation = {};
+ const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
+ expect( result.valid ).toBe( false );
+ expect( result.errors[0].reason ).toBe( MISSING_REQUIRED );
+ } );
} );
} );
From f2c8968edd1e3a73ffb7c7ca64e2db10eee3824b Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 15:27:29 +0200
Subject: [PATCH 031/108] should be valid when a project has no POFs
---
.../helpers/validateProjectFieldsForObservation.test.js | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 691d01db6..999d88a8c 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -508,6 +508,14 @@ describe( "validateProjectFieldsForObservation", () => {
expect( result.valid ).toBe( true );
expect( result.errors ).toEqual( [] );
} );
+
+ it( "should be valid when a project has no POFs", () => {
+ const mockProject = { projectObservationFields: [] };
+ const mockObservation = { observationFieldValues: [] };
+ expect(
+ validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
+ ).toBe( true );
+ } );
it( "should return MISSING_REQUIRED when observationFieldValues is undefined", () => {
const mockProject = {
projectObservationFields: [{
From 40f2e11afc74b47bc201fc763bb461efd52ce9e0 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Tue, 14 Jul 2026 15:29:19 +0200
Subject: [PATCH 032/108] should skip a POF without an obsField definition
---
.../validateProjectFieldsForObservation.test.js | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 999d88a8c..a249e1b08 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -516,6 +516,20 @@ describe( "validateProjectFieldsForObservation", () => {
validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
).toBe( true );
} );
+
+ it( "should skip a POF without an obsField definition", () => {
+ const mockProject = {
+ projectObservationFields: [{
+ required: true,
+ obsField: undefined,
+ }],
+ };
+ const mockObservation = { observationFieldValues: [] };
+ expect(
+ validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
+ ).toBe( true );
+ } );
+
it( "should return MISSING_REQUIRED when observationFieldValues is undefined", () => {
const mockProject = {
projectObservationFields: [{
From 5fdbc4b12f14a2836ab8bf94db1a59ce94f861f5 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Tue, 14 Jul 2026 12:46:08 -0700
Subject: [PATCH 033/108] handle pagination in useServerOrderedObs
---
.../hooks/useServerOrderedObservations.ts | 55 ++++++++++++-------
1 file changed, 35 insertions(+), 20 deletions(-)
diff --git a/src/components/MyObservations/hooks/useServerOrderedObservations.ts b/src/components/MyObservations/hooks/useServerOrderedObservations.ts
index 3ce6a5681..1b541a7e8 100644
--- a/src/components/MyObservations/hooks/useServerOrderedObservations.ts
+++ b/src/components/MyObservations/hooks/useServerOrderedObservations.ts
@@ -4,7 +4,7 @@ import Observation from "realmModels/Observation";
import { log } from "sharedHelpers/logger";
import type { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
import { observationSortToApiParams } from "sharedHelpers/observationsSort";
-import { useAuthenticatedQuery, useCurrentUser } from "sharedHooks";
+import { useAuthenticatedInfiniteQuery, useCurrentUser } from "sharedHooks";
const { useRealm } = RealmContext;
@@ -18,79 +18,94 @@ interface SearchObservationsResult {
}
interface SearchObservationsResponse {
+ page: number;
results: SearchObservationsResult[];
total_results: number;
}
-interface ServerOrderedObservationsData {
- observationIds: { uuid: string }[];
- totalResults: number;
-}
-
interface UseServerOrderedObservationsParams {
sortBy: OBSERVATIONS_SORT;
- page?: number;
enabled?: boolean;
}
interface UseServerOrderedObservationsResult {
observationIds: { uuid: string }[];
isLoading: boolean;
+ isFetchingNextPage: boolean;
error: Error | null;
totalResults?: number;
+ fetchNextPage: ( ) => void;
refetch: ( ) => void;
}
const useServerOrderedObservations = ( {
sortBy,
- page = 1,
enabled = true,
}: UseServerOrderedObservationsParams ): UseServerOrderedObservationsResult => {
const realm = useRealm( );
const currentUser = useCurrentUser( );
- const params = {
+ const baseParams = {
user_id: currentUser?.id,
...observationSortToApiParams( sortBy ),
- page,
per_page: PER_PAGE,
fields: Observation.ADVANCED_MODE_LIST_FIELDS,
// Bypass API response caching so newly created/updated observations show up
ttl: -1,
};
- const queryKey = ["useServerOrderedObservations", params];
+ const queryKey = ["useServerOrderedObservations", baseParams];
const {
data,
isLoading,
+ isFetchingNextPage,
error,
+ fetchNextPage,
refetch,
- } = useAuthenticatedQuery(
+ } = useAuthenticatedInfiniteQuery(
queryKey,
- async ( optsWithAuth ): Promise => {
+ async ( { pageParam = 1 }, optsWithAuth ): Promise => {
+ const params = {
+ ...baseParams,
+ page: pageParam,
+ };
const rawResponse = await searchObservations( params, optsWithAuth );
const response = rawResponse as SearchObservationsResponse;
const results = response.results || [];
+ // upsert results to Realm
try {
Observation.upsertRemoteObservations( results, realm );
} catch ( upsertError ) {
// A local Realm-write failure shouldn't be reported as a failed search
logger.error( "Failed to upsert server-ordered observations", upsertError );
}
- return {
- observationIds: results.map( ( { uuid } ) => ( { uuid } ) ),
- totalResults: response.total_results,
- };
+ return response;
+ },
+ {
+ getNextPageParam: ( lastPage: SearchObservationsResponse ) => {
+ if ( !lastPage ) return null;
+ const totalFetchedCount = lastPage.page * PER_PAGE;
+ return totalFetchedCount < lastPage.total_results
+ ? lastPage.page + 1
+ : null;
+ },
+ enabled: enabled && !!currentUser,
},
- { enabled: enabled && !!currentUser },
);
+ const pages: SearchObservationsResponse[] = data?.pages || [];
+ const observationIds = pages
+ .flatMap( page => page.results || [] )
+ .map( ( { uuid } ) => ( { uuid } ) );
+
return {
- observationIds: data?.observationIds || [],
+ observationIds,
isLoading,
+ isFetchingNextPage,
error,
- totalResults: data?.totalResults,
+ totalResults: pages[0]?.total_results,
+ fetchNextPage,
refetch,
};
};
From 25944f7ce883105ebf6265cc7f01b2334ec9dd11 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Tue, 14 Jul 2026 13:02:41 -0700
Subject: [PATCH 034/108] update tests
---
.../useServerOrderedObservations.test.js | 63 +++++++++++++------
1 file changed, 45 insertions(+), 18 deletions(-)
diff --git a/tests/unit/components/MyObservations/hooks/useServerOrderedObservations.test.js b/tests/unit/components/MyObservations/hooks/useServerOrderedObservations.test.js
index b193d3a88..f115d3f1c 100644
--- a/tests/unit/components/MyObservations/hooks/useServerOrderedObservations.test.js
+++ b/tests/unit/components/MyObservations/hooks/useServerOrderedObservations.test.js
@@ -3,7 +3,7 @@ import useServerOrderedObservations
from "components/MyObservations/hooks/useServerOrderedObservations";
import inatjs from "inaturalistjs";
import { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
-import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
+import useAuthenticatedInfiniteQuery from "sharedHooks/useAuthenticatedInfiniteQuery";
import useCurrentUser from "sharedHooks/useCurrentUser";
import factory, { makeResponse } from "tests/factory";
import setupUniqueRealm from "tests/helpers/uniqueRealm";
@@ -15,7 +15,7 @@ jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
default: jest.fn( ),
} ) );
-jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
+jest.mock( "sharedHooks/useAuthenticatedInfiniteQuery", ( ) => ( {
__esModule: true,
default: jest.fn( ),
} ) );
@@ -47,13 +47,15 @@ const getLocalObservation = uuid => getRealm( ).objectForPrimaryKey( "Observatio
const defaultQueryResult = {
data: undefined,
isLoading: false,
+ isFetchingNextPage: false,
error: null,
+ fetchNextPage: jest.fn( ),
refetch: jest.fn( ),
};
beforeEach( ( ) => {
useCurrentUser.mockReturnValue( mockUser );
- useAuthenticatedQuery.mockReturnValue( defaultQueryResult );
+ useAuthenticatedInfiniteQuery.mockReturnValue( defaultQueryResult );
} );
afterEach( ( ) => {
@@ -66,7 +68,7 @@ describe( "useServerOrderedObservations", ( ) => {
sortBy: OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST,
} ) );
- const [queryKey] = useAuthenticatedQuery.mock.calls[0];
+ const [queryKey] = useAuthenticatedInfiniteQuery.mock.calls[0];
const [, params] = queryKey;
expect( params ).toEqual( expect.objectContaining( {
user_id: mockUser.id,
@@ -79,19 +81,27 @@ describe( "useServerOrderedObservations", ( ) => {
props => useServerOrderedObservations( props ),
{ initialProps: { sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST, enabled: false } },
);
- expect( useAuthenticatedQuery.mock.calls[0][2].enabled ).toEqual( false );
+ expect( useAuthenticatedInfiniteQuery.mock.calls[0][2].enabled ).toEqual( false );
useCurrentUser.mockReturnValue( null );
rerender( { sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST, enabled: true } );
- expect( useAuthenticatedQuery.mock.calls[1][2].enabled ).toEqual( false );
+ expect( useAuthenticatedInfiniteQuery.mock.calls[1][2].enabled ).toEqual( false );
} );
- it( "passes through the query's uuid-only data and metadata as-is", ( ) => {
+ it( "flattens uuid-only data across pages and passes through pagination metadata", ( ) => {
const mockRefetch = jest.fn( );
- useAuthenticatedQuery.mockReturnValue( {
- data: { observationIds: [{ uuid: "a" }, { uuid: "b" }], totalResults: 2 },
+ const mockFetchNextPage = jest.fn( );
+ useAuthenticatedInfiniteQuery.mockReturnValue( {
+ data: {
+ pages: [
+ { results: [{ uuid: "a" }, { uuid: "b" }], total_results: 3, page: 1 },
+ { results: [{ uuid: "c" }], total_results: 3, page: 2 },
+ ],
+ },
isLoading: false,
+ isFetchingNextPage: true,
error: null,
+ fetchNextPage: mockFetchNextPage,
refetch: mockRefetch,
} );
@@ -99,12 +109,16 @@ describe( "useServerOrderedObservations", ( ) => {
sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
} ) );
- expect( result.current.observationIds ).toEqual( [{ uuid: "a" }, { uuid: "b" }] );
- expect( result.current.totalResults ).toEqual( 2 );
+ expect( result.current.observationIds ).toEqual( [
+ { uuid: "a" }, { uuid: "b" }, { uuid: "c" },
+ ] );
+ expect( result.current.totalResults ).toEqual( 3 );
+ expect( result.current.isFetchingNextPage ).toEqual( true );
+ expect( result.current.fetchNextPage ).toEqual( mockFetchNextPage );
expect( result.current.refetch ).toEqual( mockRefetch );
} );
- it( "queryFn upserts fetched results into Realm and returns a uuid-only list", async ( ) => {
+ it( "requests the next page via pageParam and upserts fetched results into Realm", async ( ) => {
const remoteObservation = factory( "RemoteObservation" );
inatjs.observations.search.mockResolvedValueOnce( makeResponse( [remoteObservation] ) );
@@ -112,15 +126,28 @@ describe( "useServerOrderedObservations", ( ) => {
sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
} ) );
- const [, queryFunction] = useAuthenticatedQuery.mock.calls[0];
- const data = await queryFunction( { api_token: "fake-token" } );
+ const [, queryFunction] = useAuthenticatedInfiniteQuery.mock.calls[0];
+ const data = await queryFunction( { pageParam: 2 }, { api_token: "fake-token" } );
- expect( data ).toEqual( {
- observationIds: [{ uuid: remoteObservation.uuid }],
- totalResults: 1,
- } );
+ expect( inatjs.observations.search ).toHaveBeenCalledWith(
+ expect.objectContaining( { page: 2 } ),
+ expect.anything( ),
+ );
+ expect( data.results ).toEqual( [remoteObservation] );
const localObs = getLocalObservation( remoteObservation.uuid );
expect( localObs ).toBeTruthy( );
expect( localObs.id ).toEqual( remoteObservation.id );
} );
+
+ describe( "getNextPageParam", ( ) => {
+ it( "requests the next page until all results have been fetched", ( ) => {
+ renderHook( ( ) => useServerOrderedObservations( {
+ sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
+ } ) );
+
+ const { getNextPageParam } = useAuthenticatedInfiniteQuery.mock.calls[0][2];
+ expect( getNextPageParam( { page: 1, total_results: 45 } ) ).toEqual( 2 );
+ expect( getNextPageParam( { page: 3, total_results: 45 } ) ).toBeNull( );
+ } );
+ } );
} );
From 1185de5da40a2d5dc5c6770d6ba55d08b26098a5 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Tue, 14 Jul 2026 13:12:56 -0700
Subject: [PATCH 035/108] pass pagination through useMyObservationsQuery
---
.../MyObservations/hooks/useMyObservationsQuery.ts | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/src/components/MyObservations/hooks/useMyObservationsQuery.ts b/src/components/MyObservations/hooks/useMyObservationsQuery.ts
index c24c3ba08..bbb8f9da4 100644
--- a/src/components/MyObservations/hooks/useMyObservationsQuery.ts
+++ b/src/components/MyObservations/hooks/useMyObservationsQuery.ts
@@ -9,13 +9,16 @@ import useServerOrderedObservations from "./useServerOrderedObservations";
const { useQuery } = RealmContext;
const NOOP_REFETCH = ( ) => undefined;
+const NOOP_FETCH_NEXT_PAGE = ( ) => undefined;
interface UseMyObservationsQueryResult {
observationIds: { uuid: string }[];
isServerAuthoritative: boolean;
isLoading: boolean;
+ isFetchingNextPage: boolean;
error: Error | null;
refetch: ( ) => void;
+ fetchNextPage: ( ) => void;
}
// We want to preserve offline behavior for the default sort (created at, desc) so a user can see
@@ -32,7 +35,9 @@ const useMyObservationsQuery = ( ): UseMyObservationsQueryResult => {
const {
observationIds: serverObservationIds,
isLoading,
+ isFetchingNextPage,
error,
+ fetchNextPage,
refetch,
} = useServerOrderedObservations( {
sortBy: state.observationsSort,
@@ -75,14 +80,20 @@ const useMyObservationsQuery = ( ): UseMyObservationsQueryResult => {
isLoading: isDefaultSort
? false
: isLoading,
+ isFetchingNextPage: isDefaultSort
+ ? false
+ : isFetchingNextPage,
error: isDefaultSort
? null
: error,
- // since we never fetched for default sort, we don't need to refetch.
+ // since we never fetched for default sort, we don't need to refetch or paginate.
// pagination is still handled by useInfiniteObservationsScroll
refetch: isDefaultSort
? NOOP_REFETCH
: refetch,
+ fetchNextPage: isDefaultSort
+ ? NOOP_FETCH_NEXT_PAGE
+ : fetchNextPage,
};
};
From 0ee1ebb62f9975da8840fcb14a074d7b6d66baab Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Tue, 14 Jul 2026 13:15:41 -0700
Subject: [PATCH 036/108] update tests
---
.../hooks/useMyObservationsQuery.test.js | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js b/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js
index 697972a5c..75f520b95 100644
--- a/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js
+++ b/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js
@@ -56,7 +56,9 @@ const createObservation = observation => {
const defaultServerResult = {
observationIds: [],
isLoading: false,
+ isFetchingNextPage: false,
error: null,
+ fetchNextPage: jest.fn( ),
refetch: jest.fn( ),
};
@@ -80,11 +82,14 @@ describe( "useMyObservationsQuery", ( ) => {
state: { observationsSort: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST },
} );
const serverRefetch = jest.fn( );
+ const serverFetchNextPage = jest.fn( );
useServerOrderedObservations.mockReturnValue( {
observationIds: [{ uuid: "should-be-ignored-in-default-sort" }],
isLoading: true,
+ isFetchingNextPage: true,
error: new Error( "should be suppressed for default sort" ),
refetch: serverRefetch,
+ fetchNextPage: serverFetchNextPage,
} );
const localObs = factory( "LocalObservation", { needs_sync: false } );
createObservation( localObs );
@@ -94,8 +99,10 @@ describe( "useMyObservationsQuery", ( ) => {
expect( result.current.observationIds ).toEqual( [{ uuid: localObs.uuid }] );
expect( result.current.isServerAuthoritative ).toEqual( false );
expect( result.current.isLoading ).toEqual( false );
+ expect( result.current.isFetchingNextPage ).toEqual( false );
expect( result.current.error ).toBeNull( );
expect( result.current.refetch ).not.toBe( serverRefetch );
+ expect( result.current.fetchNextPage ).not.toBe( serverFetchNextPage );
expect( useServerOrderedObservations ).toHaveBeenCalledWith(
expect.objectContaining( { enabled: false } ),
);
@@ -106,9 +113,12 @@ describe( "useMyObservationsQuery", ( ) => {
state: { observationsSort: OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST },
} );
const serverObs = { uuid: factory( "LocalObservation" ).uuid };
+ const serverFetchNextPage = jest.fn( );
useServerOrderedObservations.mockReturnValue( {
...defaultServerResult,
observationIds: [serverObs],
+ isFetchingNextPage: true,
+ fetchNextPage: serverFetchNextPage,
} );
const unsyncedObs = factory( "LocalObservation", { needs_sync: true } );
createObservation( unsyncedObs );
@@ -120,6 +130,8 @@ describe( "useMyObservationsQuery", ( ) => {
serverObs,
] );
expect( result.current.isServerAuthoritative ).toEqual( true );
+ expect( result.current.isFetchingNextPage ).toEqual( true );
+ expect( result.current.fetchNextPage ).toBe( serverFetchNextPage );
expect( useServerOrderedObservations ).toHaveBeenCalledWith(
expect.objectContaining( { enabled: true } ),
);
From e5d9353ddafb6a5a454ea6ccb748add70de45be1 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Tue, 14 Jul 2026 13:20:37 -0700
Subject: [PATCH 037/108] test in debug sheet
---
.../MyObservations/MyObsServerOrderedDebugSheet.tsx | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/components/MyObservations/MyObsServerOrderedDebugSheet.tsx b/src/components/MyObservations/MyObsServerOrderedDebugSheet.tsx
index da87f7286..ba80b3648 100644
--- a/src/components/MyObservations/MyObsServerOrderedDebugSheet.tsx
+++ b/src/components/MyObservations/MyObsServerOrderedDebugSheet.tsx
@@ -101,8 +101,10 @@ const DebugSheetContent = ( { onClose }: DebugSheetContentProps ) => {
observationIds,
isServerAuthoritative,
isLoading,
+ isFetchingNextPage,
error,
refetch,
+ fetchNextPage,
} = useMyObservationsQuery( );
return (
@@ -147,6 +149,14 @@ const DebugSheetContent = ( { onClose }: DebugSheetContentProps ) => {
{observationIds.map( ( { uuid }, index ) => (
) )}
+ {isServerAuthoritative && (
+ !isFetchingNextPage && fetchNextPage( )}
+ />
+ )}
);
From 99b2f2b6b7f4a874e13af130f5e1b04d4cdd08d7 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 10:02:18 +0200
Subject: [PATCH 038/108] Export ObservationFlowSlice
---
src/stores/createObservationFlowSlice.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/stores/createObservationFlowSlice.ts b/src/stores/createObservationFlowSlice.ts
index bc30d0b9d..06037527f 100644
--- a/src/stores/createObservationFlowSlice.ts
+++ b/src/stores/createObservationFlowSlice.ts
@@ -89,7 +89,7 @@ interface ObservationFlowActions {
setRollbackSnapshot: ( ) => void;
}
-type ObservationFlowSlice = ObservationFlowState & ObservationFlowActions;
+export type ObservationFlowSlice = ObservationFlowState & ObservationFlowActions;
const DEFAULT_STATE: ObservationFlowState = {
aICameraSuggestion: null,
From 9cbad08e939183773f4c91bf7bd7ac083163bbee Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 10:10:08 +0200
Subject: [PATCH 039/108] Validate OFVs on every change to currentObservation,
disable Save button
---
src/components/AddToProjects/AddToProjects.tsx | 18 ++++++++++++++----
1 file changed, 14 insertions(+), 4 deletions(-)
diff --git a/src/components/AddToProjects/AddToProjects.tsx b/src/components/AddToProjects/AddToProjects.tsx
index d71b30955..901ad237a 100644
--- a/src/components/AddToProjects/AddToProjects.tsx
+++ b/src/components/AddToProjects/AddToProjects.tsx
@@ -17,6 +17,8 @@ import { useTranslation } from "react-i18next";
import type { ListRenderItem } from "react-native";
import Project from "realmModels/Project";
import type { RealmProject, RealmProjectObservation } from "realmModels/types";
+import validateProjectFieldsForObservation from "sharedHelpers/validateProjectFieldsForObservation";
+import type { ObservationFlowSlice } from "stores/createObservationFlowSlice";
import useStore from "stores/useStore";
import { getShadow } from "styles/global";
import colors from "styles/tailwindColors";
@@ -44,9 +46,11 @@ const AddToProjects = ( ) => {
},
[],
);
- const projectObservations = useStore(
- state => state.currentObservation?.projectObservations,
+ const currentObservation = useStore(
+ ( state: ObservationFlowSlice ) => state.currentObservation,
);
+ const { projectObservations } = currentObservation;
+
const [selectedProjectIds, setSelectedProjectIds] = useState( () => new Set( ) );
const joinedProjects = useMemo(
@@ -54,6 +58,13 @@ const AddToProjects = ( ) => {
[joinedProjectsCollection],
);
+ const validationResult = useMemo(
+ () => validateProjectFieldsForObservation(
+ currentObservation,
+ joinedProjects,
+ ),
+ [currentObservation, joinedProjects],
+ );
const listHeaderComponent = useMemo(
( ) => (
@@ -114,7 +125,6 @@ const AddToProjects = ( ) => {
const onSave = ( ) => {
navigation.goBack( );
};
- const disabled = false;
const renderExpanded = useCallback(
( item: RealmProject ) => (
@@ -235,7 +245,7 @@ const AddToProjects = ( ) => {
text={t( "SAVE" )}
onPress={onSave}
level="neutral"
- disabled={disabled}
+ disabled={!validationResult.valid}
/>
From 2a9362216e1932ee72dbbf78694294e4828ee250 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 10:15:17 +0200
Subject: [PATCH 040/108] Only validate selected projects
---
src/components/AddToProjects/AddToProjects.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/components/AddToProjects/AddToProjects.tsx b/src/components/AddToProjects/AddToProjects.tsx
index 901ad237a..699f2f611 100644
--- a/src/components/AddToProjects/AddToProjects.tsx
+++ b/src/components/AddToProjects/AddToProjects.tsx
@@ -59,11 +59,11 @@ const AddToProjects = ( ) => {
);
const validationResult = useMemo(
- () => validateProjectFieldsForObservation(
- currentObservation,
- joinedProjects,
- ),
- [currentObservation, joinedProjects],
+ () => {
+ const selectedProjects = joinedProjects.filter( jp => selectedProjectIds.has( jp.id ) );
+ return validateProjectFieldsForObservation( currentObservation, selectedProjects );
+ },
+ [currentObservation, joinedProjects, selectedProjectIds],
);
const listHeaderComponent = useMemo(
( ) => (
From 29dc43588363128420d33961f3e436068dfc620b Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 12:12:21 +0200
Subject: [PATCH 041/108] Pass boolean to show if the project overall is valid
---
src/components/AddToProjects/AddToProjects.tsx | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/src/components/AddToProjects/AddToProjects.tsx b/src/components/AddToProjects/AddToProjects.tsx
index 699f2f611..675150334 100644
--- a/src/components/AddToProjects/AddToProjects.tsx
+++ b/src/components/AddToProjects/AddToProjects.tsx
@@ -127,10 +127,9 @@ const AddToProjects = ( ) => {
};
const renderExpanded = useCallback(
- ( item: RealmProject ) => (
+ ( item: RealmProject, projectValid: boolean ) => (
- {/* TODO: MOB-1499 this will be based on the result of a validation function */}
- {Math.random() > 0.5
+ {!projectValid
? (
{
( { item } ) => {
const isSelected = selectedProjectIds.has( item.id );
const canExpand = item.projectObservationFields.length > 0;
+ const projectValid = !validationResult.errors.some(
+ error => error.projectId === item?.id,
+ );
return (
@@ -215,11 +217,11 @@ const AddToProjects = ( ) => {
{renderRightIcon( item, isSelected )}
- {canExpand && isSelected && renderExpanded( item )}
+ {canExpand && isSelected && renderExpanded( item, projectValid )}
);
},
- [renderExpanded, renderRightIcon, selectedProjectIds, toggleProject],
+ [renderExpanded, renderRightIcon, selectedProjectIds, toggleProject, validationResult],
);
return (
From 810d4734355df0deb244519780eccea1dc6a3f78 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 12:14:55 +0200
Subject: [PATCH 042/108] Validate single OFV and display state
---
src/components/AddToProjects/AddToProjects.tsx | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/components/AddToProjects/AddToProjects.tsx b/src/components/AddToProjects/AddToProjects.tsx
index 675150334..8774a1249 100644
--- a/src/components/AddToProjects/AddToProjects.tsx
+++ b/src/components/AddToProjects/AddToProjects.tsx
@@ -158,13 +158,14 @@ const AddToProjects = ( ) => {
error.obsFieldId === pof.obsField?.id,
+ )}
/>
) )}
),
- [t],
+ [validationResult, t],
);
const renderRightIcon = useCallback(
From 4903113550070be0106e28057675d7f85c5e7dd4 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 12:19:45 +0200
Subject: [PATCH 043/108] Right icon state also depends on project validation
---
.../AddToProjects/AddToProjects.tsx | 24 +++++++------------
1 file changed, 9 insertions(+), 15 deletions(-)
diff --git a/src/components/AddToProjects/AddToProjects.tsx b/src/components/AddToProjects/AddToProjects.tsx
index 8774a1249..3e24be4aa 100644
--- a/src/components/AddToProjects/AddToProjects.tsx
+++ b/src/components/AddToProjects/AddToProjects.tsx
@@ -16,7 +16,7 @@ import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ListRenderItem } from "react-native";
import Project from "realmModels/Project";
-import type { RealmProject, RealmProjectObservation } from "realmModels/types";
+import type { RealmProject } from "realmModels/types";
import validateProjectFieldsForObservation from "sharedHelpers/validateProjectFieldsForObservation";
import type { ObservationFlowSlice } from "stores/createObservationFlowSlice";
import useStore from "stores/useStore";
@@ -49,7 +49,6 @@ const AddToProjects = ( ) => {
const currentObservation = useStore(
( state: ObservationFlowSlice ) => state.currentObservation,
);
- const { projectObservations } = currentObservation;
const [selectedProjectIds, setSelectedProjectIds] = useState( () => new Set( ) );
@@ -169,18 +168,13 @@ const AddToProjects = ( ) => {
);
const renderRightIcon = useCallback(
- ( item: RealmProject, isSelected: boolean ) => {
- // Logic if all required fields have been filled out will live in zustand
- if (
- projectObservations?.some(
- ( po: RealmProjectObservation ) => po.projectId === item.id,
- )
- ) {
- return (
-
- );
- }
+ ( isSelected: boolean, projectValid: boolean ) => {
if ( isSelected ) {
+ if ( projectValid ) {
+ return (
+
+ );
+ }
return (
{
}
return ;
},
- [projectObservations],
+ [],
);
const renderProject: ListRenderItem = useCallback(
@@ -216,7 +210,7 @@ const AddToProjects = ( ) => {
- {renderRightIcon( item, isSelected )}
+ {renderRightIcon( isSelected, projectValid )}
{canExpand && isSelected && renderExpanded( item, projectValid )}
From 3a0abf6ab15bd8cec0cb0d35f16a4a0b4a223034 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 14:30:04 +0200
Subject: [PATCH 044/108] currentObservation in zustand is typed as
RealmObservationPojo
---
.../validateProjectFieldsForObservation.ts | 13 +++----------
1 file changed, 3 insertions(+), 10 deletions(-)
diff --git a/src/sharedHelpers/validateProjectFieldsForObservation.ts b/src/sharedHelpers/validateProjectFieldsForObservation.ts
index 98129811a..a3703c741 100644
--- a/src/sharedHelpers/validateProjectFieldsForObservation.ts
+++ b/src/sharedHelpers/validateProjectFieldsForObservation.ts
@@ -1,4 +1,5 @@
import ObservationFieldValue from "realmModels/ObservationFieldValue";
+import type { RealmObservationPojo } from "realmModels/types";
// Machine-readable reason codes. UI layers map these to localized
// strings; membership-rule validation uses a separate module with its
@@ -64,15 +65,6 @@ export function validateProjectFieldValue(
return null;
}
-interface ObservationFieldValueToValidate {
- obsFieldId: number;
- value: string;
-}
-
-interface ObservationToValidate {
- observationFieldValues?: ObservationFieldValueToValidate[];
-}
-
interface ProjectToValidate {
id: number;
projectObservationFields: ProjectObservationFieldToValidate[];
@@ -88,7 +80,8 @@ interface ProjectToValidate {
* each project reports its own error.
*/
export default function validateProjectFieldsForObservation(
- observation: ObservationToValidate,
+ // currentObservation in zustand is typed as RealmObservationPojo
+ observation: RealmObservationPojo,
projects: ProjectToValidate[],
): ProjectFieldValidationResult {
const errors: ProjectFieldValidationError[] = [];
From 9939cb871a2979c7a0de1bdf500e22bff7d6c5be Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 14:55:17 +0200
Subject: [PATCH 045/108] Update validation objects' types as to what is
propped in from Project realm map to Pojo
---
src/sharedHelpers/validateProjectFieldsForObservation.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/sharedHelpers/validateProjectFieldsForObservation.ts b/src/sharedHelpers/validateProjectFieldsForObservation.ts
index a3703c741..5d6a8e034 100644
--- a/src/sharedHelpers/validateProjectFieldsForObservation.ts
+++ b/src/sharedHelpers/validateProjectFieldsForObservation.ts
@@ -25,13 +25,13 @@ export interface ProjectFieldValidationResult {
}
interface ObservationFieldToValidate {
- datatype: string;
+ datatype?: string;
id: number;
- name: string;
+ name?: string;
}
interface ProjectObservationFieldToValidate {
- obsField: ObservationFieldToValidate;
+ obsField: ObservationFieldToValidate | null;
required: boolean;
}
@@ -68,7 +68,7 @@ export function validateProjectFieldValue(
interface ProjectToValidate {
id: number;
projectObservationFields: ProjectObservationFieldToValidate[];
- title: string;
+ title?: string;
}
/**
From bd9410549af158fa9f9da9fca2b9847e00c7076e Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 15:35:54 +0200
Subject: [PATCH 046/108] Type param as what is passed in
---
src/realmModels/ObservationFieldValue.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/realmModels/ObservationFieldValue.ts b/src/realmModels/ObservationFieldValue.ts
index 880915359..55b8cfb23 100644
--- a/src/realmModels/ObservationFieldValue.ts
+++ b/src/realmModels/ObservationFieldValue.ts
@@ -1,6 +1,6 @@
import { Realm } from "@realm/react";
import type { ApiObservationFieldValue } from "api/types";
-import type { RealmObservation, RealmObservationFieldValue } from "realmModels/types";
+import type { RealmObservationPojo } from "realmModels/types";
import * as uuid from "uuid";
class ObservationFieldValue extends Realm.Object {
@@ -32,10 +32,10 @@ class ObservationFieldValue extends Realm.Object {
}
static findForObsField(
- observation: RealmObservation,
+ observation: RealmObservationPojo,
obsFieldId: number,
- ): RealmObservationFieldValue | undefined {
- return observation.observationFieldValues?.find(
+ ) {
+ return observation.observationFieldValues.find(
ofv => ofv.obsFieldId === obsFieldId,
);
}
From 7269d6c09ffb91d2eb2019a61dbbc6f9bd406e29 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 15:59:25 +0200
Subject: [PATCH 047/108] Given the type of currentObservation in zustand, this
should never be the case
---
.../validateProjectFieldsForObservation.test.js | 17 -----------------
1 file changed, 17 deletions(-)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index a249e1b08..229d6579a 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -529,22 +529,5 @@ describe( "validateProjectFieldsForObservation", () => {
validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
).toBe( true );
} );
-
- it( "should return MISSING_REQUIRED when observationFieldValues is undefined", () => {
- const mockProject = {
- projectObservationFields: [{
- required: true,
- obsField: {
- allowedValues: [],
- id: 10,
- name: "Habitat",
- },
- }],
- };
- const mockObservation = {};
- const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
- expect( result.valid ).toBe( false );
- expect( result.errors[0].reason ).toBe( MISSING_REQUIRED );
- } );
} );
} );
From 12d0e371869c5ab6483cbdcc6f5f7ab20fae6414 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 16:18:27 +0200
Subject: [PATCH 048/108] Turn one test back to a todo (this is not wired yet
correctly)
---
.../AddToProjects/AddToProjects.test.js | 15 +++------------
1 file changed, 3 insertions(+), 12 deletions(-)
diff --git a/tests/unit/components/AddToProjects/AddToProjects.test.js b/tests/unit/components/AddToProjects/AddToProjects.test.js
index 4e476d5fb..0c4cc0ef4 100644
--- a/tests/unit/components/AddToProjects/AddToProjects.test.js
+++ b/tests/unit/components/AddToProjects/AddToProjects.test.js
@@ -73,16 +73,7 @@ describe( "AddToProjects", ( ) => {
// TODO: MOB-1503 also check for expanded chooser being shown
} );
- it( "renders existing project observations as checked", ( ) => {
- renderAddToProjects( );
-
- expect(
- within( screen.getByTestId( `AddToProjects.project.${mockProjects[0].id}` ) )
- .getByText( iconGlyph( "checkmark-circle" ) ),
- ).toBeVisible( );
- expect(
- within( screen.getByTestId( `AddToProjects.project.${mockProjects[1].id}` ) )
- .queryByText( iconGlyph( "checkmark-circle" ) ),
- ).toBeNull( );
- } );
+ // TODO: In MOB-1499 we changed the UI state for checked to be entirely driven by OFV validation
+ // jump-starting the UI with existing POFs has not been implemented yet.
+ it.todo( "renders existing project observations as checked" );
} );
From d0a39004002af5e4117fe4b85692cc87c72a2462 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 16:25:55 +0200
Subject: [PATCH 049/108] Link ticket in TODO
---
tests/unit/components/AddToProjects/AddToProjects.test.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tests/unit/components/AddToProjects/AddToProjects.test.js b/tests/unit/components/AddToProjects/AddToProjects.test.js
index 0c4cc0ef4..de579c9cb 100644
--- a/tests/unit/components/AddToProjects/AddToProjects.test.js
+++ b/tests/unit/components/AddToProjects/AddToProjects.test.js
@@ -73,7 +73,8 @@ describe( "AddToProjects", ( ) => {
// TODO: MOB-1503 also check for expanded chooser being shown
} );
- // TODO: In MOB-1499 we changed the UI state for checked to be entirely driven by OFV validation
+ // TODO: MOB-1498
+ // In MOB-1499 we changed the UI state for checked to be entirely driven by OFV validation
// jump-starting the UI with existing POFs has not been implemented yet.
it.todo( "renders existing project observations as checked" );
} );
From 49405dabb81bffecdb01035f863e7deb12cea8e2 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Wed, 15 Jul 2026 16:51:48 +0200
Subject: [PATCH 050/108] Make POF in tests always required
---
tests/factories/LocalProjectObservationField.js | 2 +-
tests/factories/RemoteProjectObservationField.js | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/factories/LocalProjectObservationField.js b/tests/factories/LocalProjectObservationField.js
index fb9d1547e..b4e2b4004 100644
--- a/tests/factories/LocalProjectObservationField.js
+++ b/tests/factories/LocalProjectObservationField.js
@@ -6,5 +6,5 @@ export default define( "LocalProjectObservationField", faker => ( {
id: faker.number.int( ),
obsField: ofFactory( "LocalObservationField" ),
position: faker.number.int( ),
- required: faker.datatype.boolean( 0.5 ),
+ required: true,
} ) );
diff --git a/tests/factories/RemoteProjectObservationField.js b/tests/factories/RemoteProjectObservationField.js
index 168ce3fc4..c0be3402d 100644
--- a/tests/factories/RemoteProjectObservationField.js
+++ b/tests/factories/RemoteProjectObservationField.js
@@ -6,5 +6,5 @@ export default define( "RemoteProjectObservationField", faker => ( {
id: faker.number.int(),
observation_field: ofFactory( "RemoteObservationField" ),
position: faker.number.int(),
- required: faker.datatype.boolean( 0.5 ),
+ required: true,
} ) );
From a0c44fb3e4e690c5cc6071e37ac64973c12387d9 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:31:47 +0200
Subject: [PATCH 051/108] Rename const
---
src/components/Journal/ProjectPosts.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/components/Journal/ProjectPosts.tsx b/src/components/Journal/ProjectPosts.tsx
index d5709a487..8571f3b19 100644
--- a/src/components/Journal/ProjectPosts.tsx
+++ b/src/components/Journal/ProjectPosts.tsx
@@ -20,7 +20,7 @@ interface Props {
projectTitle?: string;
}
-const PostsForProjects = ( {
+const ProjectPosts = ( {
projectIcon,
projectId,
projectTitle,
@@ -81,4 +81,4 @@ const PostsForProjects = ( {
);
};
-export default PostsForProjects;
+export default ProjectPosts;
From 0c72151024b2a526e96a21a25e80c89de6b80d77 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 19:06:52 +0200
Subject: [PATCH 052/108] Duplicate ProjectPosts
---
src/components/Journal/UserPosts.tsx | 84 ++++++++++++++++++++++++++++
1 file changed, 84 insertions(+)
create mode 100644 src/components/Journal/UserPosts.tsx
diff --git a/src/components/Journal/UserPosts.tsx b/src/components/Journal/UserPosts.tsx
new file mode 100644
index 000000000..8571f3b19
--- /dev/null
+++ b/src/components/Journal/UserPosts.tsx
@@ -0,0 +1,84 @@
+import { useNavigation } from "@react-navigation/native";
+import { POST_FOR_PROJECT_FIELDS } from "api/fields";
+import { fetchProjectPosts } from "api/posts";
+import { ScreenShell } from "components/SharedComponents/ViewWrapper";
+import type { TabStackScreenProps } from "navigation/types";
+import React, {
+ useEffect,
+ useMemo,
+} from "react";
+import {
+ useInfiniteScroll,
+ useTranslation,
+} from "sharedHooks";
+
+import PostList from "./PostList";
+
+interface Props {
+ projectIcon?: string;
+ projectId?: number;
+ projectTitle?: string;
+}
+
+const ProjectPosts = ( {
+ projectIcon,
+ projectId,
+ projectTitle,
+}: Props ) => {
+ const navigation
+ = useNavigation["navigation"]>();
+ const { t } = useTranslation();
+
+ const queryKey = ["fetchProjectPosts", projectId];
+ const queryParams = {
+ id: projectId,
+ fields: POST_FOR_PROJECT_FIELDS,
+ };
+
+ const {
+ data: projectPosts,
+ fetchNextPage,
+ isFetchingNextPage,
+ totalResults: totalPosts,
+ } = useInfiniteScroll( queryKey, fetchProjectPosts, queryParams, {
+ enabled: !!projectId,
+ } );
+
+ const headerOptions = useMemo(
+ () => ( {
+ headerTitle: projectTitle,
+ headerSubtitle: t( "X-JOURNAL_POSTS", {
+ count: totalPosts || 0,
+ } ),
+ } ),
+ [totalPosts, t, projectTitle],
+ );
+
+ useEffect( () => {
+ navigation.setOptions( headerOptions );
+ }, [headerOptions, navigation] );
+
+ const enrichedPosts = useMemo( () => {
+ if ( !projectPosts ) return null;
+
+ return projectPosts?.map( p => ( {
+ ...p,
+ parent: {
+ id: projectId,
+ icon_url: projectIcon,
+ },
+ } ) );
+ }, [projectIcon, projectId, projectPosts] );
+
+ return (
+
+
+
+ );
+};
+
+export default ProjectPosts;
From a79f2c0c300f59dbb81242f97f7f9d2a5f6ee273 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:11:50 +0200
Subject: [PATCH 053/108] Add user nav params
---
src/components/Journal/Journal.tsx | 5 ++++-
src/components/UserProfile/UserProfile.tsx | 4 +++-
src/navigation/types.ts | 2 ++
3 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/src/components/Journal/Journal.tsx b/src/components/Journal/Journal.tsx
index 471b680e3..50ced2d2d 100644
--- a/src/components/Journal/Journal.tsx
+++ b/src/components/Journal/Journal.tsx
@@ -8,9 +8,12 @@ import ProjectPosts from "./ProjectPosts";
const Journal = ( ) => {
const { params } = useRoute["route"]>( );
const {
- journalPostsCount, projectIcon, projectId, projectTitle, userLogin,
+ journalPostsCount, projectIcon, projectId, projectTitle, userId, userIcon, userLogin,
} = params || {};
+ console.log( "userId", userId );
+ console.log( "userIcon", userIcon );
+
if ( projectId ) {
return (
{
const onJournalPostsPressed = ( ) => {
navigation.navigate( "Journal", {
- userLogin: user?.login,
journalPostsCount: user?.journal_posts_count,
+ userIcon: user?.icon_url,
+ userId: user?.id,
+ userLogin: user?.login,
} );
};
diff --git a/src/navigation/types.ts b/src/navigation/types.ts
index ca5b5f111..9488fd362 100644
--- a/src/navigation/types.ts
+++ b/src/navigation/types.ts
@@ -337,6 +337,8 @@ export type BaseTabStackParamList = {
// projectTitle: project?.title,
// }
Journal: {
+ userIcon?: string;
+ userId?: number;
userLogin?: string;
projectIcon?: string;
projectId?: number;
From 8a5efc3c7cf44664b60b7fdb497a44f4834b2d2e Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:19:33 +0200
Subject: [PATCH 054/108] Remove journalPostsCount nav param
---
src/components/Journal/Journal.tsx | 3 +--
src/components/UserProfile/UserProfile.tsx | 1 -
src/navigation/types.ts | 6 +++---
3 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/src/components/Journal/Journal.tsx b/src/components/Journal/Journal.tsx
index 50ced2d2d..3286999ac 100644
--- a/src/components/Journal/Journal.tsx
+++ b/src/components/Journal/Journal.tsx
@@ -8,7 +8,7 @@ import ProjectPosts from "./ProjectPosts";
const Journal = ( ) => {
const { params } = useRoute["route"]>( );
const {
- journalPostsCount, projectIcon, projectId, projectTitle, userId, userIcon, userLogin,
+ projectIcon, projectId, projectTitle, userId, userIcon, userLogin,
} = params || {};
console.log( "userId", userId );
@@ -26,7 +26,6 @@ const Journal = ( ) => {
// TODO: posts for one user
if ( userLogin ) {
- console.log( journalPostsCount );
return null;
}
diff --git a/src/components/UserProfile/UserProfile.tsx b/src/components/UserProfile/UserProfile.tsx
index 15cb9b6dc..837267bdf 100644
--- a/src/components/UserProfile/UserProfile.tsx
+++ b/src/components/UserProfile/UserProfile.tsx
@@ -146,7 +146,6 @@ const UserProfile = ( ) => {
const onJournalPostsPressed = ( ) => {
navigation.navigate( "Journal", {
- journalPostsCount: user?.journal_posts_count,
userIcon: user?.icon_url,
userId: user?.id,
userLogin: user?.login,
diff --git a/src/navigation/types.ts b/src/navigation/types.ts
index 9488fd362..9465e5f41 100644
--- a/src/navigation/types.ts
+++ b/src/navigation/types.ts
@@ -327,8 +327,9 @@ export type BaseTabStackParamList = {
};
// From UserProfile
// {
- // userLogin: user?.login,
- // journalPostsCount: user?.journal_posts_count,
+ // userId: user?.id,
+ // userIcon: user?.login,
+ // userLogin: user?.icon_url,
// }
// From ProjectDetails
// {
@@ -343,7 +344,6 @@ export type BaseTabStackParamList = {
projectIcon?: string;
projectId?: number;
projectTitle?: string;
- journalPostsCount?: number;
} | undefined;
Debug: undefined;
UILibrary: undefined;
From 5cd89b43963a078168f6d8cb7721abb4758f548e Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:20:09 +0200
Subject: [PATCH 055/108] Alphabetical order
---
src/navigation/types.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/navigation/types.ts b/src/navigation/types.ts
index 9465e5f41..f598bc050 100644
--- a/src/navigation/types.ts
+++ b/src/navigation/types.ts
@@ -338,12 +338,12 @@ export type BaseTabStackParamList = {
// projectTitle: project?.title,
// }
Journal: {
- userIcon?: string;
- userId?: number;
- userLogin?: string;
projectIcon?: string;
projectId?: number;
projectTitle?: string;
+ userIcon?: string;
+ userId?: number;
+ userLogin?: string;
} | undefined;
Debug: undefined;
UILibrary: undefined;
From 5243a00bbf54a4256853630a54cb122c2edcc3bf Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:22:19 +0200
Subject: [PATCH 056/108] Return UserPosts for user
---
src/components/Journal/Journal.tsx | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/src/components/Journal/Journal.tsx b/src/components/Journal/Journal.tsx
index 3286999ac..a239aa94d 100644
--- a/src/components/Journal/Journal.tsx
+++ b/src/components/Journal/Journal.tsx
@@ -4,16 +4,14 @@ import React from "react";
import Blog from "./Blog";
import ProjectPosts from "./ProjectPosts";
+import UserPosts from "./UserPosts";
const Journal = ( ) => {
const { params } = useRoute["route"]>( );
const {
- projectIcon, projectId, projectTitle, userId, userIcon, userLogin,
+ projectIcon, projectId, projectTitle, userIcon, userId, userLogin,
} = params || {};
- console.log( "userId", userId );
- console.log( "userIcon", userIcon );
-
if ( projectId ) {
return (
{
);
}
- // TODO: posts for one user
if ( userLogin ) {
- return null;
+ return (
+
+ );
}
return (
From 147e233f47abbb3703c66661dc2b3daff4a0b428 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:23:38 +0200
Subject: [PATCH 057/108] Project id must be defined
---
src/components/Journal/ProjectPosts.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/Journal/ProjectPosts.tsx b/src/components/Journal/ProjectPosts.tsx
index 8571f3b19..f45c1dd04 100644
--- a/src/components/Journal/ProjectPosts.tsx
+++ b/src/components/Journal/ProjectPosts.tsx
@@ -16,7 +16,7 @@ import PostList from "./PostList";
interface Props {
projectIcon?: string;
- projectId?: number;
+ projectId: number;
projectTitle?: string;
}
From eba4a563aab733c03626404af4291ce5a3c9a1fe Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:23:56 +0200
Subject: [PATCH 058/108] Change if to check for id
---
src/components/Journal/Journal.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/Journal/Journal.tsx b/src/components/Journal/Journal.tsx
index a239aa94d..c223036c3 100644
--- a/src/components/Journal/Journal.tsx
+++ b/src/components/Journal/Journal.tsx
@@ -22,7 +22,7 @@ const Journal = ( ) => {
);
}
- if ( userLogin ) {
+ if ( userId ) {
return (
Date: Thu, 16 Jul 2026 20:24:50 +0200
Subject: [PATCH 059/108] Update post list params
---
src/components/Journal/UserPosts.tsx | 28 ++++++++++++++--------------
1 file changed, 14 insertions(+), 14 deletions(-)
diff --git a/src/components/Journal/UserPosts.tsx b/src/components/Journal/UserPosts.tsx
index 8571f3b19..600c685e0 100644
--- a/src/components/Journal/UserPosts.tsx
+++ b/src/components/Journal/UserPosts.tsx
@@ -15,23 +15,23 @@ import {
import PostList from "./PostList";
interface Props {
- projectIcon?: string;
- projectId?: number;
- projectTitle?: string;
+ userIcon?: string;
+ userId: number;
+ userLogin?: string;
}
const ProjectPosts = ( {
- projectIcon,
- projectId,
- projectTitle,
+ userIcon,
+ userId,
+ userLogin,
}: Props ) => {
const navigation
= useNavigation["navigation"]>();
const { t } = useTranslation();
- const queryKey = ["fetchProjectPosts", projectId];
+ const queryKey = ["fetchProjectPosts", userId];
const queryParams = {
- id: projectId,
+ id: userId,
fields: POST_FOR_PROJECT_FIELDS,
};
@@ -41,17 +41,17 @@ const ProjectPosts = ( {
isFetchingNextPage,
totalResults: totalPosts,
} = useInfiniteScroll( queryKey, fetchProjectPosts, queryParams, {
- enabled: !!projectId,
+ enabled: !!userId,
} );
const headerOptions = useMemo(
() => ( {
- headerTitle: projectTitle,
+ headerTitle: userLogin,
headerSubtitle: t( "X-JOURNAL_POSTS", {
count: totalPosts || 0,
} ),
} ),
- [totalPosts, t, projectTitle],
+ [totalPosts, t, userLogin],
);
useEffect( () => {
@@ -64,11 +64,11 @@ const ProjectPosts = ( {
return projectPosts?.map( p => ( {
...p,
parent: {
- id: projectId,
- icon_url: projectIcon,
+ id: userId,
+ icon_url: userIcon,
},
} ) );
- }, [projectIcon, projectId, projectPosts] );
+ }, [userIcon, userId, projectPosts] );
return (
From a163885afe928d31ab3975822e3a8f8737ce99b5 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:29:51 +0200
Subject: [PATCH 060/108] Rename api call wrapper to fetchBlogPosts
---
src/api/posts.ts | 7 ++++---
src/components/Journal/Blog.tsx | 6 +++---
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/src/api/posts.ts b/src/api/posts.ts
index 713d036c2..376014978 100644
--- a/src/api/posts.ts
+++ b/src/api/posts.ts
@@ -2,7 +2,7 @@ import type { ErrorWithResponse, INatApiError } from "api/error";
import handleError from "api/error";
import inatjs from "inaturalistjs";
-const fetchUserPosts = async (
+const fetchBlogPosts = async (
params: Record = {},
opts: Record = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
@@ -11,7 +11,7 @@ const fetchUserPosts = async (
} catch ( e ) {
return handleError(
e as ErrorWithResponse,
- { context: { functionName: "fetchUserPosts", opts } },
+ { context: { functionName: "fetchBlogPosts", opts } },
);
}
};
@@ -31,6 +31,7 @@ const fetchProjectPosts = async (
};
export {
+ fetchBlogPosts,
fetchProjectPosts,
- fetchUserPosts,
+ // fetchUserPosts,
};
diff --git a/src/components/Journal/Blog.tsx b/src/components/Journal/Blog.tsx
index 000c0d287..714c22ba9 100644
--- a/src/components/Journal/Blog.tsx
+++ b/src/components/Journal/Blog.tsx
@@ -1,6 +1,6 @@
import { useNavigation } from "@react-navigation/native";
import { POST_FOR_USER_FIELDS } from "api/fields";
-import { fetchUserPosts } from "api/posts";
+import { fetchBlogPosts } from "api/posts";
import { ScreenShell } from "components/SharedComponents/ViewWrapper";
import type { TabStackScreenProps } from "navigation/types";
import React, {
@@ -18,7 +18,7 @@ const Blog = ( ) => {
const navigation = useNavigation["navigation"]>( );
const { t } = useTranslation( );
- const queryKey = ["fetchUserPosts"];
+ const queryKey = ["fetchBlogPosts"];
const queryParams = {
fields: POST_FOR_USER_FIELDS,
};
@@ -28,7 +28,7 @@ const Blog = ( ) => {
fetchNextPage,
isFetchingNextPage,
totalResults: totalPosts,
- } = useInfiniteScroll( queryKey, fetchUserPosts, queryParams, {
+ } = useInfiniteScroll( queryKey, fetchBlogPosts, queryParams, {
enabled: true,
} );
From eb444be09c821026905cb07366ac5a6a42659ac4 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:31:29 +0200
Subject: [PATCH 061/108] Add new fetchUserPosts, a call that fetches posts of
a user
---
src/api/posts.ts | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/src/api/posts.ts b/src/api/posts.ts
index 376014978..3ebc6fad6 100644
--- a/src/api/posts.ts
+++ b/src/api/posts.ts
@@ -30,8 +30,22 @@ const fetchProjectPosts = async (
}
};
+const fetchUserPosts = async (
+ params: Record = {},
+ opts: Record = {},
+): Promise | null | ErrorWithResponse | INatApiError> => {
+ try {
+ return await inatjs.users.posts( params, opts );
+ } catch ( e ) {
+ return handleError(
+ e as ErrorWithResponse,
+ { context: { functionName: "fetchUserPosts", opts } },
+ );
+ }
+};
+
export {
fetchBlogPosts,
fetchProjectPosts,
- // fetchUserPosts,
+ fetchUserPosts,
};
From 3c0a66a71e68988fe8a99ae2b123b8bece9ce14a Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:33:46 +0200
Subject: [PATCH 062/108] Use new api call
---
src/components/Journal/UserPosts.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/components/Journal/UserPosts.tsx b/src/components/Journal/UserPosts.tsx
index 600c685e0..aa1670ac8 100644
--- a/src/components/Journal/UserPosts.tsx
+++ b/src/components/Journal/UserPosts.tsx
@@ -1,6 +1,6 @@
import { useNavigation } from "@react-navigation/native";
import { POST_FOR_PROJECT_FIELDS } from "api/fields";
-import { fetchProjectPosts } from "api/posts";
+import { fetchUserPosts } from "api/posts";
import { ScreenShell } from "components/SharedComponents/ViewWrapper";
import type { TabStackScreenProps } from "navigation/types";
import React, {
@@ -29,7 +29,7 @@ const ProjectPosts = ( {
= useNavigation["navigation"]>();
const { t } = useTranslation();
- const queryKey = ["fetchProjectPosts", userId];
+ const queryKey = ["fetchUserPosts", userId];
const queryParams = {
id: userId,
fields: POST_FOR_PROJECT_FIELDS,
@@ -40,7 +40,7 @@ const ProjectPosts = ( {
fetchNextPage,
isFetchingNextPage,
totalResults: totalPosts,
- } = useInfiniteScroll( queryKey, fetchProjectPosts, queryParams, {
+ } = useInfiniteScroll( queryKey, fetchUserPosts, queryParams, {
enabled: !!userId,
} );
From 06b7a94903a8996a13fe77be7b0e6b5e369dddfe Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:35:41 +0200
Subject: [PATCH 063/108] Add params type
---
src/api/posts.ts | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/src/api/posts.ts b/src/api/posts.ts
index 3ebc6fad6..c5105df73 100644
--- a/src/api/posts.ts
+++ b/src/api/posts.ts
@@ -1,5 +1,6 @@
import type { ErrorWithResponse, INatApiError } from "api/error";
import handleError from "api/error";
+import type { ApiParams } from "api/types";
import inatjs from "inaturalistjs";
const fetchBlogPosts = async (
@@ -16,8 +17,12 @@ const fetchBlogPosts = async (
}
};
+interface ProjectPostsParams extends ApiParams {
+ id: number;
+}
+
const fetchProjectPosts = async (
- params: Record = {},
+ params: ProjectPostsParams,
opts: Record = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
try {
@@ -30,8 +35,12 @@ const fetchProjectPosts = async (
}
};
+interface UserPostsParams extends ApiParams {
+ id: number;
+}
+
const fetchUserPosts = async (
- params: Record = {},
+ params: UserPostsParams,
opts: Record = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
try {
From b9061706a07980da55660ff9d5da6e1b848d188a Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:41:15 +0200
Subject: [PATCH 064/108] Type opts
---
src/api/posts.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/api/posts.ts b/src/api/posts.ts
index c5105df73..73585e9a9 100644
--- a/src/api/posts.ts
+++ b/src/api/posts.ts
@@ -1,11 +1,11 @@
import type { ErrorWithResponse, INatApiError } from "api/error";
import handleError from "api/error";
-import type { ApiParams } from "api/types";
+import type { ApiOpts, ApiParams } from "api/types";
import inatjs from "inaturalistjs";
const fetchBlogPosts = async (
params: Record = {},
- opts: Record = {},
+ opts: ApiOpts = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
try {
return await inatjs.posts.for_user( params, opts );
@@ -23,7 +23,7 @@ interface ProjectPostsParams extends ApiParams {
const fetchProjectPosts = async (
params: ProjectPostsParams,
- opts: Record = {},
+ opts: ApiOpts = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
try {
return await inatjs.projects.posts( params, opts );
@@ -41,7 +41,7 @@ interface UserPostsParams extends ApiParams {
const fetchUserPosts = async (
params: UserPostsParams,
- opts: Record = {},
+ opts: ApiOpts = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
try {
return await inatjs.users.posts( params, opts );
From 02e868fca02b22194898970e338214013ea80d98 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:41:59 +0200
Subject: [PATCH 065/108] Type params
---
src/api/posts.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/api/posts.ts b/src/api/posts.ts
index 73585e9a9..08730b661 100644
--- a/src/api/posts.ts
+++ b/src/api/posts.ts
@@ -4,7 +4,7 @@ import type { ApiOpts, ApiParams } from "api/types";
import inatjs from "inaturalistjs";
const fetchBlogPosts = async (
- params: Record = {},
+ params: ApiParams = {},
opts: ApiOpts = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
try {
From 9223ddcea9ed630e3b359f6ffd10872da47deab1 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:44:39 +0200
Subject: [PATCH 066/108] Type default response
---
src/api/posts.ts | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/src/api/posts.ts b/src/api/posts.ts
index 08730b661..9fe6926ed 100644
--- a/src/api/posts.ts
+++ b/src/api/posts.ts
@@ -1,12 +1,14 @@
import type { ErrorWithResponse, INatApiError } from "api/error";
import handleError from "api/error";
-import type { ApiOpts, ApiParams } from "api/types";
+import type {
+ ApiDefaultResult, ApiOpts, ApiParams, ApiResponse,
+} from "api/types";
import inatjs from "inaturalistjs";
-const fetchBlogPosts = async (
+const fetchBlogPosts = async (
params: ApiParams = {},
opts: ApiOpts = {},
-): Promise | null | ErrorWithResponse | INatApiError> => {
+): Promise | null | ErrorWithResponse | INatApiError> => {
try {
return await inatjs.posts.for_user( params, opts );
} catch ( e ) {
@@ -21,10 +23,10 @@ interface ProjectPostsParams extends ApiParams {
id: number;
}
-const fetchProjectPosts = async (
+const fetchProjectPosts = async (
params: ProjectPostsParams,
opts: ApiOpts = {},
-): Promise | null | ErrorWithResponse | INatApiError> => {
+): Promise | null | ErrorWithResponse | INatApiError> => {
try {
return await inatjs.projects.posts( params, opts );
} catch ( e ) {
@@ -39,10 +41,10 @@ interface UserPostsParams extends ApiParams {
id: number;
}
-const fetchUserPosts = async (
+const fetchUserPosts = async (
params: UserPostsParams,
opts: ApiOpts = {},
-): Promise | null | ErrorWithResponse | INatApiError> => {
+): Promise | null | ErrorWithResponse | INatApiError> => {
try {
return await inatjs.users.posts( params, opts );
} catch ( e ) {
From 72dba22418e78cf5c4c149803f9533b0ab4d92d0 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:45:56 +0200
Subject: [PATCH 067/108] Catch faulty response and return null
---
src/api/posts.ts | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/src/api/posts.ts b/src/api/posts.ts
index 9fe6926ed..a658e10df 100644
--- a/src/api/posts.ts
+++ b/src/api/posts.ts
@@ -10,7 +10,9 @@ const fetchBlogPosts = async (
opts: ApiOpts = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
try {
- return await inatjs.posts.for_user( params, opts );
+ const response = await inatjs.posts.for_user( params, opts );
+ if ( !response ) { return null; }
+ return response;
} catch ( e ) {
return handleError(
e as ErrorWithResponse,
@@ -28,7 +30,9 @@ const fetchProjectPosts = async (
opts: ApiOpts = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
try {
- return await inatjs.projects.posts( params, opts );
+ const response = await inatjs.projects.posts( params, opts );
+ if ( !response ) { return null; }
+ return response;
} catch ( e ) {
return handleError(
e as ErrorWithResponse,
@@ -46,7 +50,9 @@ const fetchUserPosts = async (
opts: ApiOpts = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
try {
- return await inatjs.users.posts( params, opts );
+ const response = await inatjs.users.posts( params, opts );
+ if ( !response ) { return null; }
+ return response;
} catch ( e ) {
return handleError(
e as ErrorWithResponse,
From 1e74199ed16649c3902106a81be7ba5d1cd60973 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:47:40 +0200
Subject: [PATCH 068/108] Refactor imports
---
src/api/usersTyped.ts | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/src/api/usersTyped.ts b/src/api/usersTyped.ts
index aa0cde902..154fab349 100644
--- a/src/api/usersTyped.ts
+++ b/src/api/usersTyped.ts
@@ -1,10 +1,9 @@
-import inatjs from "inaturalistjs";
-
-import type { ErrorWithResponse, INatApiError } from "./error";
-import handleError from "./error";
+import type { ErrorWithResponse, INatApiError } from "api/error";
+import handleError from "api/error";
import type {
ApiDefaultResult, ApiOpts, ApiParams, ApiResponse,
-} from "./types";
+} from "api/types";
+import inatjs from "inaturalistjs";
interface UsersProjectsParams extends ApiParams {
id: number;
From 4fc7296b6ea867095539d8fb7a8d180e5d350c8b Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:51:20 +0200
Subject: [PATCH 069/108] More generic name
---
src/api/types.d.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/api/types.d.ts b/src/api/types.d.ts
index 94e388fef..92a932493 100644
--- a/src/api/types.d.ts
+++ b/src/api/types.d.ts
@@ -21,7 +21,7 @@ export interface ApiPlace {
place_type?: number | null;
}
-export interface ApiPostForProject {
+export interface ApiPost {
body: string;
id: number;
published_at: string;
@@ -29,7 +29,7 @@ export interface ApiPostForProject {
}
// When using POST_FOR_USER_FIELDS
-export interface ApiPostForUser extends ApiPostForProject {
+export interface ApiPostForUser extends ApiPost {
parent: {
id: number;
icon_url: string | null;
From 2212fb5a26bcd8aec03ca3cef6f38603c218d583 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 20:53:27 +0200
Subject: [PATCH 070/108] Rename data
---
src/components/Journal/UserPosts.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/components/Journal/UserPosts.tsx b/src/components/Journal/UserPosts.tsx
index aa1670ac8..d9bcfae77 100644
--- a/src/components/Journal/UserPosts.tsx
+++ b/src/components/Journal/UserPosts.tsx
@@ -36,7 +36,7 @@ const ProjectPosts = ( {
};
const {
- data: projectPosts,
+ data: userPosts,
fetchNextPage,
isFetchingNextPage,
totalResults: totalPosts,
@@ -59,16 +59,16 @@ const ProjectPosts = ( {
}, [headerOptions, navigation] );
const enrichedPosts = useMemo( () => {
- if ( !projectPosts ) return null;
+ if ( !userPosts ) return null;
- return projectPosts?.map( p => ( {
+ return userPosts?.map( p => ( {
...p,
parent: {
id: userId,
icon_url: userIcon,
},
} ) );
- }, [userIcon, userId, projectPosts] );
+ }, [userIcon, userId, userPosts] );
return (
From 0dab1e9fa58662170acc20882e19dad22bd650de Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 21:04:15 +0200
Subject: [PATCH 071/108] Use uri access fct
---
src/components/UserProfile/UserProfile.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/UserProfile/UserProfile.tsx b/src/components/UserProfile/UserProfile.tsx
index 837267bdf..c8d7d8e90 100644
--- a/src/components/UserProfile/UserProfile.tsx
+++ b/src/components/UserProfile/UserProfile.tsx
@@ -146,7 +146,7 @@ const UserProfile = ( ) => {
const onJournalPostsPressed = ( ) => {
navigation.navigate( "Journal", {
- userIcon: user?.icon_url,
+ userIcon: User.uri( user ),
userId: user?.id,
userLogin: user?.login,
} );
From f3f526bec976a2457b87bd11ac26dabf30982b60 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 21:13:35 +0200
Subject: [PATCH 072/108] Update UserPosts.tsx
---
src/components/Journal/UserPosts.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/components/Journal/UserPosts.tsx b/src/components/Journal/UserPosts.tsx
index d9bcfae77..c7e672b54 100644
--- a/src/components/Journal/UserPosts.tsx
+++ b/src/components/Journal/UserPosts.tsx
@@ -20,7 +20,7 @@ interface Props {
userLogin?: string;
}
-const ProjectPosts = ( {
+const UserPosts = ( {
userIcon,
userId,
userLogin,
@@ -81,4 +81,4 @@ const ProjectPosts = ( {
);
};
-export default ProjectPosts;
+export default UserPosts;
From c05bbe00609d3cce8dc16e2b70f06959333893ee Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Thu, 16 Jul 2026 21:30:18 +0200
Subject: [PATCH 073/108] Update validateProjectFieldsForObservation.test.js
---
tests/unit/helpers/validateProjectFieldsForObservation.test.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/unit/helpers/validateProjectFieldsForObservation.test.js b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
index 229d6579a..c00c71f7b 100644
--- a/tests/unit/helpers/validateProjectFieldsForObservation.test.js
+++ b/tests/unit/helpers/validateProjectFieldsForObservation.test.js
@@ -145,7 +145,7 @@ describe( "validateProjectFieldsForObservation", () => {
}],
};
const mockObservation = {
- observationFieldValues: [{ id: 10, value }],
+ observationFieldValues: [{ obsFieldId: 10, value }],
};
const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
expect( result.valid ).toBe( false );
From cb33c04ec90b54b2df05c2a389cc6201434045ce Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:14:52 -0500
Subject: [PATCH 074/108] MOB-1341: simplify all no-permissions handling to
live in ExploreResults
---
.../Explore/ExploreV2/ExploreV2Container.tsx | 90 ++-----------------
.../Explore/ExploreV2/ExploreV2DebugSheet.tsx | 21 +----
.../ExploreV2/components/ExploreV2Header.tsx | 1 -
.../ExploreV2/helpers/buildQueryParams.ts | 17 ++--
.../ExploreV2/screens/ExploreResults.tsx | 58 +++++++++---
.../ExploreV2/screens/UniversalSearch.tsx | 32 +------
src/providers/ExploreV2Context.tsx | 77 ++--------------
7 files changed, 79 insertions(+), 217 deletions(-)
diff --git a/src/components/Explore/ExploreV2/ExploreV2Container.tsx b/src/components/Explore/ExploreV2/ExploreV2Container.tsx
index b2662beaf..1b08e2c57 100644
--- a/src/components/Explore/ExploreV2/ExploreV2Container.tsx
+++ b/src/components/Explore/ExploreV2/ExploreV2Container.tsx
@@ -1,90 +1,12 @@
import ExploreStackNavigator
from "navigation/StackNavigators/ExploreStackNavigator";
-import {
- defaultExploreV2Location,
- EXPLORE_V2_ACTION,
- EXPLORE_V2_PLACE_MODE,
- ExploreV2Provider,
- useExploreV2,
-} from "providers/ExploreV2Context";
-import React, { useEffect, useEffectEvent } from "react";
-import useLocationPermission from "sharedHooks/useLocationPermission";
+import { ExploreV2Provider } from "providers/ExploreV2Context";
+import React from "react";
-interface ExploreV2WithProviderProps {
- hasPermissions?: boolean;
- hasBlockedPermissions: boolean;
-}
-
-const ExploreV2WithProvider = ( {
- hasPermissions,
- hasBlockedPermissions,
-}: ExploreV2WithProviderProps ) => {
- const { state, dispatch } = useExploreV2( );
- // useEffectEvent is a new pattern for us, which we are adding only to new code for the moment
- // https://github.com/inaturalist/iNaturalistReactNative/pull/3585#discussion_r3220223241
- const onPermissionsResolved = useEffectEvent( async ( ) => {
- const { placeMode } = state.location;
-
- if ( hasPermissions ) {
- if (
- placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED
- && placeMode !== EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION
- ) return;
- // if we have location permissions
- // and place mode isn't one of the viewable modes (worldwide, nearby, specfic place)
- // then attempt to get and set the user's location
- const next = await defaultExploreV2Location( );
- if ( next.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY ) {
- dispatch( {
- type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY,
- lat: next.lat,
- lng: next.lng,
- radius: next.radius,
- } );
- } else {
- dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
- }
- } else if ( hasBlockedPermissions ) {
- if (
- placeMode === EXPLORE_V2_PLACE_MODE.UNINITIALIZED
- || placeMode === EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION
- ) {
- // user has explicitly denied location permissions, set place mode to worldwide
- dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
- }
- } else if ( placeMode === EXPLORE_V2_PLACE_MODE.UNINITIALIZED ) {
- dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_NEEDS_PERMISSION } );
- }
- } );
-
- useEffect( ( ) => {
- if ( hasPermissions !== undefined ) {
- onPermissionsResolved( );
- }
- }, [hasPermissions, hasBlockedPermissions] );
-
- return (
+const ExploreV2Container = ( ) => (
+
- );
-};
-
-const ExploreV2Container = ( ) => {
- const {
- hasPermissions,
- hasBlockedPermissions,
- renderPermissionsGate,
- requestPermissions,
- } = useLocationPermission( );
-
- return (
-
-
- {renderPermissionsGate( undefined )}
-
- );
-};
+
+);
export default ExploreV2Container;
diff --git a/src/components/Explore/ExploreV2/ExploreV2DebugSheet.tsx b/src/components/Explore/ExploreV2/ExploreV2DebugSheet.tsx
index 3a905a015..bf6680702 100644
--- a/src/components/Explore/ExploreV2/ExploreV2DebugSheet.tsx
+++ b/src/components/Explore/ExploreV2/ExploreV2DebugSheet.tsx
@@ -10,7 +10,6 @@ import {
Pressable, ScrollView, Text, View,
} from "components/styledComponents";
import {
- defaultExploreV2Location,
EXPLORE_V2_ACTION,
EXPLORE_V2_PLACE_MODE,
useExploreV2,
@@ -133,20 +132,6 @@ const ExploreV2DebugSheet = ( ) => {
? state.location.place.id
: null;
- const handleNearby = async () => {
- const next = await defaultExploreV2Location();
- if ( next.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY ) {
- dispatch( {
- type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY,
- lat: next.lat,
- lng: next.lng,
- radius: next.radius,
- } );
- } else {
- dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
- }
- };
-
const onClose = () => setVisible( false );
return (
@@ -236,9 +221,9 @@ const ExploreV2DebugSheet = ( ) => {
onPress={() => dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } )}
/>
dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY } )}
/>
{PLACES.map( place => (
{
dispatch( { type: EXPLORE_V2_ACTION.RESET } )}
/>
diff --git a/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx b/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx
index b9e4bced7..c05040311 100644
--- a/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx
+++ b/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx
@@ -42,7 +42,6 @@ function locationLabel( location: ExploreV2LocationState, t: TFunction ): string
case EXPLORE_V2_PLACE_MODE.WORLDWIDE:
return t( "Worldwide" );
case EXPLORE_V2_PLACE_MODE.NEARBY:
- case EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION:
return t( "Nearby" );
case EXPLORE_V2_PLACE_MODE.PLACE:
return location.place.display_name || "";
diff --git a/src/components/Explore/ExploreV2/helpers/buildQueryParams.ts b/src/components/Explore/ExploreV2/helpers/buildQueryParams.ts
index 7b5c0e257..325cf2496 100644
--- a/src/components/Explore/ExploreV2/helpers/buildQueryParams.ts
+++ b/src/components/Explore/ExploreV2/helpers/buildQueryParams.ts
@@ -21,8 +21,15 @@ export interface ExploreV2QueryParams {
verifiable?: boolean;
}
+export interface NearbyCoords {
+ lat: number;
+ lng: number;
+ radius: number;
+}
+
const buildExploreV2QueryParams = (
state: ExploreV2State,
+ nearbyCoords?: NearbyCoords,
): ExploreV2QueryParams => {
const params: ExploreV2QueryParams = {
per_page: PER_PAGE,
@@ -48,16 +55,16 @@ const buildExploreV2QueryParams = (
const { location } = state;
switch ( location.placeMode ) {
case EXPLORE_V2_PLACE_MODE.NEARBY:
- params.lat = location.lat;
- params.lng = location.lng;
- params.radius = location.radius;
+ if ( nearbyCoords ) {
+ params.lat = nearbyCoords.lat;
+ params.lng = nearbyCoords.lng;
+ params.radius = nearbyCoords.radius;
+ }
break;
case EXPLORE_V2_PLACE_MODE.PLACE:
params.place_id = location.place.id;
break;
case EXPLORE_V2_PLACE_MODE.WORLDWIDE:
- case EXPLORE_V2_PLACE_MODE.UNINITIALIZED:
- case EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION:
break;
default: {
// Exhaustiveness check: ts fails if a new placeMode is added without a case.
diff --git a/src/components/Explore/ExploreV2/screens/ExploreResults.tsx b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
index 2e5236545..d1c3b9c71 100644
--- a/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
+++ b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
@@ -1,4 +1,5 @@
import { useNetInfo } from "@react-native-community/netinfo";
+import { useFocusEffect } from "@react-navigation/native";
import { OBSERVATIONS_TAB } from "appConstants/tabs";
import ExploreV2Header
from "components/Explore/ExploreV2/components/ExploreV2Header";
@@ -6,6 +7,8 @@ import ExploreV2Tabs
from "components/Explore/ExploreV2/components/ExploreV2Tabs";
import ExploreV2DebugSheet
from "components/Explore/ExploreV2/ExploreV2DebugSheet";
+import type { NearbyCoords }
+ from "components/Explore/ExploreV2/helpers/buildQueryParams";
import buildExploreV2QueryParams
from "components/Explore/ExploreV2/helpers/buildQueryParams";
import ExploreV2SpeciesView
@@ -23,16 +26,21 @@ import {
import SortButton from "components/SharedComponents/Buttons/SortButton";
import { View } from "components/styledComponents";
import { EXPLORE_V2_ACTION, EXPLORE_V2_PLACE_MODE, useExploreV2 } from "providers/ExploreV2Context";
-import React, { useMemo, useState } from "react";
+import React, { useCallback, useMemo, useState } from "react";
import type { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
import {
OBSERVATIONS_SORT_OPTIONS,
useObservationsSortLabels,
} from "sharedHelpers/observationsSort";
import { useTranslation } from "sharedHooks";
+import useLocationPermission from "sharedHooks/useLocationPermission";
import useSpeciesCount from "sharedHooks/useSpeciesCount";
import useStoredLayout from "sharedHooks/useStoredLayout";
+// Please don't change this to an aliased path or the e2e mock will not get
+// used in our e2e tests on Github Actions
+import fetchCoarseUserLocation from "../../../../sharedHelpers/fetchCoarseUserLocation";
+
interface SortOption {
label: string;
text: string;
@@ -40,7 +48,8 @@ interface SortOption {
}
const ExploreResults = ( ) => {
- const { dispatch, state, requestLocationPermissions } = useExploreV2( );
+ const { dispatch, state } = useExploreV2( );
+ const { hasPermissions, renderPermissionsGate, requestPermissions } = useLocationPermission( );
const { isConnected } = useNetInfo( );
const { t } = useTranslation( );
const [showSortSheet, setShowSortSheet] = useState( false );
@@ -60,13 +69,39 @@ const ExploreResults = ( ) => {
{} as Record,
);
- const queryParams = useMemo(
- ( ) => buildExploreV2QueryParams( state ),
- [state],
- );
+ // undefined = nearby coords not resolved yet; null = resolved but no fix
+ // (falls back to worldwide); object = resolved coords.
+ const isNearby = state.location.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY;
+ const [nearbyCoords, setNearbyCoords] = useState( undefined );
- const canFetch = state.location.placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED
- && state.location.placeMode !== EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION;
+ useFocusEffect( useCallback( ( ) => {
+ let cancelled = false;
+ if ( isNearby && hasPermissions === true ) {
+ setNearbyCoords( undefined );
+ fetchCoarseUserLocation( ).then( location => {
+ if ( !cancelled ) {
+ setNearbyCoords( location?.latitude
+ ? { lat: location.latitude, lng: location.longitude, radius: 1 }
+ : null );
+ }
+ } );
+ }
+ return ( ) => { cancelled = true; };
+ }, [isNearby, hasPermissions] ) );
+
+ const needsPermission = isNearby && hasPermissions === false;
+ const nearbyResolved = !isNearby || needsPermission || nearbyCoords !== undefined;
+ const canFetch = !needsPermission && nearbyResolved;
+
+ const queryParams = useMemo(
+ ( ) => buildExploreV2QueryParams(
+ state,
+ isNearby && nearbyCoords
+ ? nearbyCoords
+ : undefined,
+ ),
+ [state, isNearby, nearbyCoords],
+ );
const {
fetchNextPage,
@@ -99,7 +134,7 @@ const ExploreResults = ( ) => {
text={t( "ALLOW-LOCATION-ACCESS" )}
accessibilityHint={t( "Opens-location-permission-prompt" )}
level="focus"
- onPress={requestLocationPermissions}
+ onPress={requestPermissions}
/>
);
@@ -112,7 +147,7 @@ const ExploreResults = ( ) => {
observationsCount={totalResults}
speciesCount={speciesCount}
/>
- {state.location.placeMode === EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION
+ {needsPermission
? renderPermissionPrompt( )
: ( // more tabs to come in MOB-1347
<>
@@ -135,7 +170,7 @@ const ExploreResults = ( ) => {
hideObsUploadStatus={layout !== "list"}
obsListKey="ExploreV2Observations"
onEndReached={fetchNextPage}
- showNoResults={!canFetch || totalResults === 0}
+ showNoResults={canFetch && totalResults === 0}
testID="ExploreV2ObservationsList"
/>
{
onPressClose={() => setShowSortSheet( false )}
/>
)}
+ {renderPermissionsGate( {} )}
);
};
diff --git a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx
index 2f6994146..d5e1c583e 100644
--- a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx
+++ b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx
@@ -31,9 +31,7 @@ import {
import type { ExploreStackScreenProps } from "navigation/types";
import type { ExploreV2Subject, Place } from "providers/ExploreV2Context";
import {
- defaultExploreV2Location,
EXPLORE_V2_ACTION,
- EXPLORE_V2_PLACE_MODE,
useExploreV2,
} from "providers/ExploreV2Context";
import React, { useCallback, useRef, useState } from "react";
@@ -62,8 +60,7 @@ type SearchResultItem = UniversalSearchResultItem | LocationSearchResultItem;
type SelectedLocation =
| { type: "place"; place: Place }
- | { type: "nearby"; lat: number; lng: number; radius: number }
- | { type: "nearby-needs-permission" }
+ | { type: "nearby" }
| { type: "worldwide" };
const resultKey = ( item: SearchResultItem ): string => {
@@ -158,21 +155,8 @@ const UniversalSearch = ( ) => {
Keyboard.dismiss( );
}, [commitLocation, t] );
- const handleSelectNearby = useCallback( async ( ) => {
- const next = await defaultExploreV2Location( );
- switch ( next.placeMode ) {
- case EXPLORE_V2_PLACE_MODE.NEARBY:
- setSelectedLocation( {
- type: "nearby", lat: next.lat, lng: next.lng, radius: next.radius,
- } );
- break;
- case EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION:
- setSelectedLocation( { type: "nearby-needs-permission" } );
- break;
- default:
- // Permission granted but no fix available: fall back to worldwide.
- setSelectedLocation( { type: "worldwide" } );
- }
+ const handleSelectNearby = useCallback( ( ) => {
+ setSelectedLocation( { type: "nearby" } );
commitLocation( t( "Nearby" ) );
Keyboard.dismiss( );
}, [commitLocation, t] );
@@ -202,15 +186,7 @@ const UniversalSearch = ( ) => {
} );
break;
case "nearby":
- dispatch( {
- type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY,
- lat: selectedLocation.lat,
- lng: selectedLocation.lng,
- radius: selectedLocation.radius,
- } );
- break;
- case "nearby-needs-permission":
- dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_NEEDS_PERMISSION } );
+ dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY } );
break;
default:
dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
diff --git a/src/providers/ExploreV2Context.tsx b/src/providers/ExploreV2Context.tsx
index 56474023e..a6f843a65 100644
--- a/src/providers/ExploreV2Context.tsx
+++ b/src/providers/ExploreV2Context.tsx
@@ -3,15 +3,9 @@ import { OBSERVATIONS_TAB } from "appConstants/tabs";
import * as React from "react";
import { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
-// Please don't change this to an aliased path or the e2e mock will not get
-// used in our e2e tests on Github Actions
-import fetchCoarseUserLocation from "../sharedHelpers/fetchCoarseUserLocation";
-import { checkLocationPermissions } from "../sharedHelpers/geolocationWrapper";
-
export enum EXPLORE_V2_ACTION {
SET_SUBJECT = "SET_SUBJECT",
CLEAR_SUBJECT = "CLEAR_SUBJECT",
- SET_LOCATION_NEEDS_PERMISSION = "SET_LOCATION_NEEDS_PERMISSION",
SET_LOCATION_NEARBY = "SET_LOCATION_NEARBY",
SET_LOCATION_WORLDWIDE = "SET_LOCATION_WORLDWIDE",
SET_LOCATION_PLACE = "SET_LOCATION_PLACE",
@@ -22,8 +16,6 @@ export enum EXPLORE_V2_ACTION {
}
export enum EXPLORE_V2_PLACE_MODE {
- UNINITIALIZED = "UNINITIALIZED",
- NEEDS_PERMISSION = "NEEDS_PERMISSION",
NEARBY = "NEARBY",
WORLDWIDE = "WORLDWIDE",
PLACE = "PLACE"
@@ -68,15 +60,8 @@ export interface ExploreV2Filters {
}
export type ExploreV2LocationState =
- | { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED }
- | { placeMode: EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION }
| { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }
- | {
- placeMode: EXPLORE_V2_PLACE_MODE.NEARBY;
- lat: number;
- lng: number;
- radius: number;
- }
+ | { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY }
| { placeMode: EXPLORE_V2_PLACE_MODE.PLACE; place: Place };
export interface ExploreV2State {
@@ -90,13 +75,7 @@ export interface ExploreV2State {
export type ExploreV2Action =
| { type: EXPLORE_V2_ACTION.SET_SUBJECT; subject: ExploreV2Subject }
| { type: EXPLORE_V2_ACTION.CLEAR_SUBJECT }
- | { type: EXPLORE_V2_ACTION.SET_LOCATION_NEEDS_PERMISSION }
- | {
- type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY;
- lat: number;
- lng: number;
- radius: number;
- }
+ | { type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY }
| { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE }
| {
type: EXPLORE_V2_ACTION.SET_LOCATION_PLACE;
@@ -109,7 +88,7 @@ export type ExploreV2Action =
export const initialExploreV2State: ExploreV2State = {
subject: null,
- location: { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED },
+ location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
filters: {},
activeTab: OBSERVATIONS_TAB,
@@ -124,20 +103,10 @@ export function exploreV2Reducer(
return { ...state, subject: action.subject };
case EXPLORE_V2_ACTION.CLEAR_SUBJECT:
return { ...state, subject: null };
- case EXPLORE_V2_ACTION.SET_LOCATION_NEEDS_PERMISSION:
- return {
- ...state,
- location: { placeMode: EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION },
- };
case EXPLORE_V2_ACTION.SET_LOCATION_NEARBY:
return {
...state,
- location: {
- placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
- lat: action.lat,
- lng: action.lng,
- radius: action.radius,
- },
+ location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
};
case EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE:
return {
@@ -168,37 +137,9 @@ export function exploreV2Reducer(
}
}
-export type DefaultExploreV2Location =
- | { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }
- | { placeMode: EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION }
- | {
- placeMode: EXPLORE_V2_PLACE_MODE.NEARBY;
- lat: number;
- lng: number;
- radius: number;
- };
-
-export async function defaultExploreV2Location( ): Promise {
- const location = await fetchCoarseUserLocation( );
- if ( location && location.latitude ) {
- return {
- placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
- lat: location.latitude,
- lng: location.longitude,
- radius: 1,
- };
- }
- // No coordinates, fallback to worldwide if we already have perms
- const hasPermission = await checkLocationPermissions( );
- return hasPermission
- ? { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }
- : { placeMode: EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION };
-}
-
interface ExploreV2ContextValue {
state: ExploreV2State;
dispatch: ( action: ExploreV2Action ) => void;
- requestLocationPermissions: ( ) => void;
}
const ExploreV2Context = React.createContext(
@@ -207,18 +148,14 @@ const ExploreV2Context = React.createContext(
interface ExploreV2ProviderProps {
children: React.ReactNode;
- requestLocationPermissions: ( ) => void;
}
-export const ExploreV2Provider = ( {
- children,
- requestLocationPermissions,
-}: ExploreV2ProviderProps ) => {
+export const ExploreV2Provider = ( { children }: ExploreV2ProviderProps ) => {
const [state, dispatch] = React.useReducer( exploreV2Reducer, initialExploreV2State );
const value = React.useMemo(
- () => ( { state, dispatch, requestLocationPermissions } ),
- [state, requestLocationPermissions],
+ () => ( { state, dispatch } ),
+ [state],
);
return (
From 3756958e6c1b6059aa9565d01b7817a8f51bf9a0 Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:36:33 -0500
Subject: [PATCH 075/108] MOB-1341: fall back to worldwide
---
.../ExploreV2/screens/ExploreResults.tsx | 33 ++++++++++++-------
1 file changed, 21 insertions(+), 12 deletions(-)
diff --git a/src/components/Explore/ExploreV2/screens/ExploreResults.tsx b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
index d1c3b9c71..b031c6dd2 100644
--- a/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
+++ b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
@@ -49,7 +49,12 @@ interface SortOption {
const ExploreResults = ( ) => {
const { dispatch, state } = useExploreV2( );
- const { hasPermissions, renderPermissionsGate, requestPermissions } = useLocationPermission( );
+ const {
+ hasPermissions,
+ hasBlockedPermissions,
+ renderPermissionsGate,
+ requestPermissions,
+ } = useLocationPermission( );
const { isConnected } = useNetInfo( );
const { t } = useTranslation( );
const [showSortSheet, setShowSortSheet] = useState( false );
@@ -69,28 +74,32 @@ const ExploreResults = ( ) => {
{} as Record,
);
- // undefined = nearby coords not resolved yet; null = resolved but no fix
- // (falls back to worldwide); object = resolved coords.
+ // undefined until nearby coords resolve; blocked / no-fix flip to worldwide.
const isNearby = state.location.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY;
- const [nearbyCoords, setNearbyCoords] = useState( undefined );
+ const [nearbyCoords, setNearbyCoords] = useState( undefined );
useFocusEffect( useCallback( ( ) => {
let cancelled = false;
- if ( isNearby && hasPermissions === true ) {
+ if ( isNearby && hasBlockedPermissions ) {
+ // perms blocked: fall back to worldwide
+ dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
+ } else if ( isNearby && hasPermissions === true ) {
setNearbyCoords( undefined );
fetchCoarseUserLocation( ).then( location => {
- if ( !cancelled ) {
- setNearbyCoords( location?.latitude
- ? { lat: location.latitude, lng: location.longitude, radius: 1 }
- : null );
+ if ( cancelled ) return;
+ if ( location?.latitude ) {
+ setNearbyCoords( { lat: location.latitude, lng: location.longitude, radius: 1 } );
+ } else {
+ // Perms granted but no fix — fall back to worldwide.
+ dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
}
} );
}
return ( ) => { cancelled = true; };
- }, [isNearby, hasPermissions] ) );
+ }, [isNearby, hasPermissions, hasBlockedPermissions, dispatch] ) );
- const needsPermission = isNearby && hasPermissions === false;
- const nearbyResolved = !isNearby || needsPermission || nearbyCoords !== undefined;
+ const needsPermission = isNearby && hasPermissions === false && !hasBlockedPermissions;
+ const nearbyResolved = !isNearby || nearbyCoords !== undefined;
const canFetch = !needsPermission && nearbyResolved;
const queryParams = useMemo(
From 00aa4ef157b8d0112549de532bf2a92baf2dcd5a Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:44:30 -0500
Subject: [PATCH 076/108] MOB-1341: keep coords for successive focuses
---
src/components/Explore/ExploreV2/screens/ExploreResults.tsx | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/src/components/Explore/ExploreV2/screens/ExploreResults.tsx b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
index b031c6dd2..789d740c7 100644
--- a/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
+++ b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
@@ -74,7 +74,6 @@ const ExploreResults = ( ) => {
{} as Record,
);
- // undefined until nearby coords resolve; blocked / no-fix flip to worldwide.
const isNearby = state.location.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY;
const [nearbyCoords, setNearbyCoords] = useState( undefined );
@@ -83,8 +82,7 @@ const ExploreResults = ( ) => {
if ( isNearby && hasBlockedPermissions ) {
// perms blocked: fall back to worldwide
dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
- } else if ( isNearby && hasPermissions === true ) {
- setNearbyCoords( undefined );
+ } else if ( isNearby && hasPermissions === true && nearbyCoords === undefined ) {
fetchCoarseUserLocation( ).then( location => {
if ( cancelled ) return;
if ( location?.latitude ) {
@@ -96,7 +94,7 @@ const ExploreResults = ( ) => {
} );
}
return ( ) => { cancelled = true; };
- }, [isNearby, hasPermissions, hasBlockedPermissions, dispatch] ) );
+ }, [isNearby, hasPermissions, hasBlockedPermissions, nearbyCoords, dispatch] ) );
const needsPermission = isNearby && hasPermissions === false && !hasBlockedPermissions;
const nearbyResolved = !isNearby || nearbyCoords !== undefined;
From 4c6cab9006378e7d348a1a972b0b25038d4c58d0 Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:50:30 -0500
Subject: [PATCH 077/108] MOB-1341: test updates
---
.../ExploreV2/buildQueryParams.test.js | 51 +++++-----
.../components/ExploreV2Header.test.js | 14 +--
.../ExploreV2SpeciesGridItem.test.js | 2 +-
.../components/ExploreV2Tabs.test.js | 2 +-
.../screens/ExploreV2SpeciesView.test.js | 2 +-
.../ExploreV2/screens/UniversalSearch.test.js | 41 +-------
tests/unit/providers/ExploreV2Context.test.js | 95 ++-----------------
7 files changed, 44 insertions(+), 163 deletions(-)
diff --git a/tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js b/tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js
index e84c419c0..7b793a951 100644
--- a/tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js
+++ b/tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js
@@ -40,23 +40,34 @@ describe( "buildExploreV2QueryParams", ( ) => {
} );
describe( "location", ( ) => {
- it( "includes lat/lng/radius in NEARBY mode with coords", ( ) => {
+ it( "includes lat/lng/radius in NEARBY mode when coords are resolved", ( ) => {
const state = {
...initialExploreV2State,
- location: {
- placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
- lat: 37.5,
- lng: -122.1,
- radius: 1,
- },
+ location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
};
- const params = buildExploreV2QueryParams( state );
+ const params = buildExploreV2QueryParams( state, {
+ lat: 37.5,
+ lng: -122.1,
+ radius: 1,
+ } );
expect( params.lat ).toBe( 37.5 );
expect( params.lng ).toBe( -122.1 );
expect( params.radius ).toBe( 1 );
expect( params.place_id ).toBeUndefined( );
} );
+ it( "omits coords in NEARBY mode when coords are unresolved (worldwide fallback)", ( ) => {
+ const state = {
+ ...initialExploreV2State,
+ location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
+ };
+ const params = buildExploreV2QueryParams( state );
+ expect( params.lat ).toBeUndefined( );
+ expect( params.lng ).toBeUndefined( );
+ expect( params.radius ).toBeUndefined( );
+ expect( params.place_id ).toBeUndefined( );
+ } );
+
it( "omits coords and place in WORLDWIDE mode", ( ) => {
const state = {
...initialExploreV2State,
@@ -67,17 +78,6 @@ describe( "buildExploreV2QueryParams", ( ) => {
expect( params.place_id ).toBeUndefined( );
} );
- it( "omits coords and place in NEEDS_PERMISSION mode", ( ) => {
- const state = {
- ...initialExploreV2State,
- location: { placeMode: EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION },
- };
- const params = buildExploreV2QueryParams( state );
- expect( params.lat ).toBeUndefined( );
- expect( params.lng ).toBeUndefined( );
- expect( params.place_id ).toBeUndefined( );
- } );
-
it( "uses place_id in PLACE mode", ( ) => {
const state = {
...initialExploreV2State,
@@ -145,16 +145,15 @@ describe( "buildExploreV2QueryParams", ( ) => {
it( "combines subject, location, and sort into a single query", ( ) => {
const state = {
subject: { type: "taxon", taxon: { id: 42 } },
- location: {
- placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
- lat: 37.5,
- lng: -122.1,
- radius: 1,
- },
+ location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
filters: {},
};
- const params = buildExploreV2QueryParams( state );
+ const params = buildExploreV2QueryParams( state, {
+ lat: 37.5,
+ lng: -122.1,
+ radius: 1,
+ } );
expect( params ).toEqual( {
per_page: 20,
verifiable: true,
diff --git a/tests/unit/components/Explore/ExploreV2/components/ExploreV2Header.test.js b/tests/unit/components/Explore/ExploreV2/components/ExploreV2Header.test.js
index 09b709ef3..ef09b400c 100644
--- a/tests/unit/components/Explore/ExploreV2/components/ExploreV2Header.test.js
+++ b/tests/unit/components/Explore/ExploreV2/components/ExploreV2Header.test.js
@@ -140,19 +140,7 @@ describe( "ExploreV2Header", () => {
} );
it( "renders the Nearby label when location is nearby", () => {
- setState( null, {
- placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
- lat: 1,
- lng: 2,
- radius: 1,
- } );
- renderComponent( );
-
- expect( screen.getByText( "Nearby" ) ).toBeTruthy();
- } );
-
- it( "renders the Nearby label when nearby is intended but permission is pending", () => {
- setState( null, { placeMode: EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION } );
+ setState( null, { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY } );
renderComponent( );
expect( screen.getByText( "Nearby" ) ).toBeTruthy();
diff --git a/tests/unit/components/Explore/ExploreV2/components/ExploreV2SpeciesGridItem.test.js b/tests/unit/components/Explore/ExploreV2/components/ExploreV2SpeciesGridItem.test.js
index 623aa366c..705cef09e 100644
--- a/tests/unit/components/Explore/ExploreV2/components/ExploreV2SpeciesGridItem.test.js
+++ b/tests/unit/components/Explore/ExploreV2/components/ExploreV2SpeciesGridItem.test.js
@@ -50,7 +50,7 @@ const StateProbe = () => {
const actor = userEvent.setup( );
const renderGridItem = ( props = {} ) => renderComponent(
- {}}>
+
,
diff --git a/tests/unit/components/Explore/ExploreV2/components/ExploreV2Tabs.test.js b/tests/unit/components/Explore/ExploreV2/components/ExploreV2Tabs.test.js
index 5948654a9..ea6200b56 100644
--- a/tests/unit/components/Explore/ExploreV2/components/ExploreV2Tabs.test.js
+++ b/tests/unit/components/Explore/ExploreV2/components/ExploreV2Tabs.test.js
@@ -7,7 +7,7 @@ import { renderComponent } from "tests/helpers/render";
const actor = userEvent.setup( );
const renderTabs = props => renderComponent(
- {}}>
+
,
);
diff --git a/tests/unit/components/Explore/ExploreV2/screens/ExploreV2SpeciesView.test.js b/tests/unit/components/Explore/ExploreV2/screens/ExploreV2SpeciesView.test.js
index 33adbdaf1..29b809f9c 100644
--- a/tests/unit/components/Explore/ExploreV2/screens/ExploreV2SpeciesView.test.js
+++ b/tests/unit/components/Explore/ExploreV2/screens/ExploreV2SpeciesView.test.js
@@ -72,7 +72,7 @@ jest.mock( "@tanstack/react-query", () => {
} );
const renderView = ( props = {} ) => renderComponent(
- {}}>
+
{
expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_WORLDWIDE" } );
} );
- it( "fills the field and stages nearby when Nearby is tapped", async ( ) => {
- fetchCoarseUserLocation.mockResolvedValue( { latitude: 10, longitude: 20 } );
+ it( "fills the field and stages the nearby intent when Nearby is tapped", async ( ) => {
renderComponent( );
focusLocation( );
@@ -547,55 +546,25 @@ describe( "UniversalSearch screen", ( ) => {
expect( mockDispatch ).not.toHaveBeenCalled( );
await actor.press( screen.getByTestId( "UniversalSearch.searchButton" ) );
- expect( mockDispatch ).toHaveBeenCalledWith( {
- type: "SET_LOCATION_NEARBY",
- lat: 10,
- lng: 20,
- radius: 1,
- } );
+ expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_NEARBY" } );
} );
it(
- "stages worldwide when permission is granted but no location fix is available",
+ "stages the nearby intent (no prompting, no fetch) regardless of permission",
async ( ) => {
- fetchCoarseUserLocation.mockResolvedValue( null );
- checkLocationPermissions.mockResolvedValue( "granted" );
renderComponent( );
focusLocation( );
await actor.press( screen.getByRole( "button", { name: i18next.t( "Nearby" ) } ) );
- await waitFor( ( ) => {
- expect( screen.getByDisplayValue( i18next.t( "Nearby" ) ) ).toBeTruthy( );
- } );
- expect( mockDispatch ).not.toHaveBeenCalled( );
-
- await actor.press( screen.getByTestId( "UniversalSearch.searchButton" ) );
- expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_WORLDWIDE" } );
- expect( mockDispatch ).not.toHaveBeenCalledWith(
- { type: "SET_LOCATION_NEEDS_PERMISSION" },
- );
- },
- );
-
- it(
- "stages nearby-needs-permission (without prompting) when permission is missing",
- async ( ) => {
- fetchCoarseUserLocation.mockResolvedValue( null );
- checkLocationPermissions.mockResolvedValue( null );
- renderComponent( );
-
- focusLocation( );
- await actor.press( screen.getByRole( "button", { name: i18next.t( "Nearby" ) } ) );
-
await waitFor( ( ) => {
expect( screen.getByDisplayValue( i18next.t( "Nearby" ) ) ).toBeTruthy( );
} );
+ expect( fetchCoarseUserLocation ).not.toHaveBeenCalled( );
expect( mockRequestLocationPermissions ).not.toHaveBeenCalled( );
- expect( mockDispatch ).not.toHaveBeenCalled( );
await actor.press( screen.getByTestId( "UniversalSearch.searchButton" ) );
- expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_NEEDS_PERMISSION" } );
+ expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_NEARBY" } );
},
);
} );
diff --git a/tests/unit/providers/ExploreV2Context.test.js b/tests/unit/providers/ExploreV2Context.test.js
index b769ddd3c..1c9d891d1 100644
--- a/tests/unit/providers/ExploreV2Context.test.js
+++ b/tests/unit/providers/ExploreV2Context.test.js
@@ -1,22 +1,15 @@
import {
- defaultExploreV2Location,
EXPLORE_V2_ACTION,
EXPLORE_V2_PLACE_MODE,
exploreV2Reducer,
initialExploreV2State,
} from "providers/ExploreV2Context";
-import fetchCoarseUserLocation from "sharedHelpers/fetchCoarseUserLocation";
import { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
-jest.mock( "sharedHelpers/fetchCoarseUserLocation", ( ) => ( {
- __esModule: true,
- default: jest.fn( ),
-} ) );
-
describe( "initialExploreV2State", ( ) => {
- it( "starts with no subject, UNINITIALIZED placeMode, newest-upload sort, empty filters", ( ) => {
+ it( "starts with no subject, NEARBY placeMode, newest-upload sort, empty filters", ( ) => {
expect( initialExploreV2State.subject ).toBeNull( );
- expect( initialExploreV2State.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.UNINITIALIZED );
+ expect( initialExploreV2State.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.NEARBY );
expect( initialExploreV2State.sortBy ).toBe( OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST );
expect( initialExploreV2State.filters ).toEqual( {} );
} );
@@ -40,12 +33,7 @@ describe( "exploreV2Reducer", ( ) => {
it( "preserves location, sortBy, and filters when changing subject", ( ) => {
const state = {
subject: null,
- location: {
- placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
- lat: 1,
- lng: 2,
- radius: 3,
- },
+ location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
sortBy: OBSERVATIONS_SORT.MOST_FAVED,
filters: { quality_grade: "research" },
};
@@ -71,7 +59,7 @@ describe( "exploreV2Reducer", ( ) => {
} );
describe( "location actions", ( ) => {
- it( "SET_LOCATION_NEARBY transitions from PLACE and drops place", ( ) => {
+ it( "SET_LOCATION_NEARBY transitions from PLACE and drops place (no coords stored)", ( ) => {
const state = {
...initialExploreV2State,
location: {
@@ -81,45 +69,28 @@ describe( "exploreV2Reducer", ( ) => {
};
const next = exploreV2Reducer( state, {
type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY,
- lat: 37.5,
- lng: -122.1,
- radius: 1,
} );
expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.NEARBY );
- expect( next.location.lat ).toBe( 37.5 );
- expect( next.location.lng ).toBe( -122.1 );
- expect( next.location.radius ).toBe( 1 );
+ expect( next.location.lat ).toBeUndefined( );
expect( next.location.place ).toBeUndefined( );
} );
- it( "SET_LOCATION_WORLDWIDE transitions from NEARBY and drops coords", ( ) => {
+ it( "SET_LOCATION_WORLDWIDE transitions from NEARBY", ( ) => {
const state = {
...initialExploreV2State,
- location: {
- placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
- lat: 1,
- lng: 1,
- radius: 1,
- },
+ location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
};
const next = exploreV2Reducer( state, {
type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE,
} );
expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.WORLDWIDE );
expect( next.location.lat ).toBeUndefined( );
- expect( next.location.lng ).toBeUndefined( );
- expect( next.location.radius ).toBeUndefined( );
} );
- it( "SET_LOCATION_PLACE transitions from NEARBY and drops coords", ( ) => {
+ it( "SET_LOCATION_PLACE transitions from NEARBY", ( ) => {
const state = {
...initialExploreV2State,
- location: {
- placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
- lat: 1,
- lng: 1,
- radius: 1,
- },
+ location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
};
const place = { id: 5, display_name: "Oakland" };
const next = exploreV2Reducer( state, {
@@ -128,7 +99,6 @@ describe( "exploreV2Reducer", ( ) => {
} );
expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.PLACE );
expect( next.location.place ).toEqual( place );
- expect( next.location.lat ).toBeUndefined( );
} );
it( "SET_LOCATION_PLACE replaces an existing place", ( ) => {
@@ -148,32 +118,10 @@ describe( "exploreV2Reducer", ( ) => {
expect( next.location.place ).toEqual( place );
} );
- it( "SET_LOCATION_NEEDS_PERMISSION transitions from UNINITIALIZED", ( ) => {
- const next = exploreV2Reducer( initialExploreV2State, {
- type: EXPLORE_V2_ACTION.SET_LOCATION_NEEDS_PERMISSION,
- } );
- expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION );
- } );
-
- it( "SET_LOCATION_NEEDS_PERMISSION preserves subject, sortBy, and filters", ( ) => {
- const state = {
- subject: { type: "taxon", taxon: { id: 42 } },
- location: { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED },
- sortBy: OBSERVATIONS_SORT.MOST_FAVED,
- filters: { quality_grade: "research" },
- };
- const next = exploreV2Reducer( state, {
- type: EXPLORE_V2_ACTION.SET_LOCATION_NEEDS_PERMISSION,
- } );
- expect( next.subject ).toEqual( state.subject );
- expect( next.sortBy ).toBe( state.sortBy );
- expect( next.filters ).toEqual( state.filters );
- } );
-
it( "preserves subject, sortBy, and filters when changing location", ( ) => {
const state = {
subject: { type: "taxon", taxon: { id: 42 } },
- location: { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED },
+ location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
sortBy: OBSERVATIONS_SORT.MOST_FAVED,
filters: { quality_grade: "research" },
};
@@ -223,26 +171,3 @@ describe( "exploreV2Reducer", ( ) => {
} );
} );
} );
-
-describe( "defaultExploreV2Location", ( ) => {
- beforeEach( ( ) => {
- fetchCoarseUserLocation.mockReset( );
- } );
-
- it( "returns NEARBY with radius 1 when a location is available", async ( ) => {
- fetchCoarseUserLocation.mockResolvedValueOnce( { latitude: 37.5, longitude: -122.1 } );
- const result = await defaultExploreV2Location( );
- expect( result ).toEqual( {
- placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
- lat: 37.5,
- lng: -122.1,
- radius: 1,
- } );
- } );
-
- it( "returns WORLDWIDE when fetchCoarseUserLocation returns null", async ( ) => {
- fetchCoarseUserLocation.mockResolvedValueOnce( null );
- const result = await defaultExploreV2Location( );
- expect( result ).toEqual( { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE } );
- } );
-} );
From ca8a4cfdc8bdcd3490cdf17c753083c0244ca03d Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:59:31 -0500
Subject: [PATCH 078/108] MOB-1341: ExploreResults tests
---
.../ExploreV2/screens/ExploreResults.test.js | 162 ++++++++++++++++++
1 file changed, 162 insertions(+)
create mode 100644 tests/unit/components/Explore/ExploreV2/screens/ExploreResults.test.js
diff --git a/tests/unit/components/Explore/ExploreV2/screens/ExploreResults.test.js b/tests/unit/components/Explore/ExploreV2/screens/ExploreResults.test.js
new file mode 100644
index 000000000..87c412579
--- /dev/null
+++ b/tests/unit/components/Explore/ExploreV2/screens/ExploreResults.test.js
@@ -0,0 +1,162 @@
+import { screen, waitFor } from "@testing-library/react-native";
+import ExploreResults from "components/Explore/ExploreV2/screens/ExploreResults";
+import initI18next from "i18n/initI18next";
+import {
+ EXPLORE_V2_ACTION,
+ EXPLORE_V2_PLACE_MODE,
+ initialExploreV2State,
+} from "providers/ExploreV2Context";
+import React from "react";
+import { renderComponent } from "tests/helpers/render";
+
+jest.mock( "@react-navigation/native", ( ) => {
+ const actualNav = jest.requireActual( "@react-navigation/native" );
+ return {
+ ...actualNav,
+ useFocusEffect: cb => jest.requireActual( "react" ).useEffect( cb, [] ),
+ };
+} );
+
+jest.mock( "providers/ExploreV2Context", ( ) => {
+ const actual = jest.requireActual( "providers/ExploreV2Context" );
+ return { ...actual, useExploreV2: jest.fn( ) };
+} );
+const { useExploreV2 } = require( "providers/ExploreV2Context" );
+
+let mockHasPermissions;
+let mockHasBlockedPermissions;
+const mockDispatch = jest.fn( );
+const mockRequestPermissions = jest.fn( );
+jest.mock( "sharedHooks/useLocationPermission", ( ) => ( {
+ __esModule: true,
+ default: ( ) => ( {
+ hasPermissions: mockHasPermissions,
+ hasBlockedPermissions: mockHasBlockedPermissions,
+ renderPermissionsGate: ( ) => null,
+ requestPermissions: mockRequestPermissions,
+ } ),
+} ) );
+
+jest.mock( "sharedHelpers/fetchCoarseUserLocation", ( ) => ( {
+ __esModule: true,
+ default: jest.fn( ),
+} ) );
+const fetchCoarseUserLocation = require( "sharedHelpers/fetchCoarseUserLocation" ).default;
+
+const mockUseInfiniteExploreScroll = jest.fn( );
+jest.mock( "components/Explore/hooks/useInfiniteExploreScroll", ( ) => ( {
+ __esModule: true,
+ default: args => mockUseInfiniteExploreScroll( args ),
+} ) );
+
+jest.mock( "sharedHooks/useSpeciesCount", ( ) => ( { __esModule: true, default: ( ) => 0 } ) );
+
+const mockState = location => ( {
+ ...initialExploreV2State,
+ location,
+} );
+
+const lastScrollArgs = ( ) => mockUseInfiniteExploreScroll.mock.calls.at( -1 )[0];
+
+beforeAll( async ( ) => {
+ await initI18next( );
+} );
+
+beforeEach( ( ) => {
+ mockHasPermissions = undefined;
+ mockHasBlockedPermissions = false;
+ mockDispatch.mockClear( );
+ mockRequestPermissions.mockClear( );
+ fetchCoarseUserLocation.mockReset( );
+ mockUseInfiniteExploreScroll.mockReset( );
+ mockUseInfiniteExploreScroll.mockReturnValue( {
+ fetchNextPage: jest.fn( ),
+ isFetchingNextPage: false,
+ handlePullToRefresh: jest.fn( ),
+ observations: [],
+ totalResults: 0,
+ } );
+} );
+
+describe( "ExploreResults nearby resolution", ( ) => {
+ it( "includes fetched coordinates in the query when nearby with permission", async ( ) => {
+ mockHasPermissions = true;
+ fetchCoarseUserLocation.mockResolvedValueOnce( { latitude: 37.5, longitude: -122.1 } );
+ useExploreV2.mockReturnValue( {
+ state: mockState( { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY } ),
+ dispatch: mockDispatch,
+ } );
+
+ renderComponent( );
+
+ await waitFor( ( ) => {
+ const { params, enabled } = lastScrollArgs( );
+ expect( params.lat ).toBe( 37.5 );
+ expect( params.lng ).toBe( -122.1 );
+ expect( params.radius ).toBe( 1 );
+ expect( enabled ).toBe( true );
+ } );
+ } );
+
+ it( "shows the permission prompt and does not fetch when nearby, no permission", async ( ) => {
+ mockHasPermissions = false;
+ useExploreV2.mockReturnValue( {
+ state: mockState( { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY } ),
+ dispatch: mockDispatch,
+ } );
+
+ renderComponent( );
+
+ expect(
+ await screen.findByText( /To view nearby organisms/ ),
+ ).toBeVisible( );
+ expect( fetchCoarseUserLocation ).not.toHaveBeenCalled( );
+ expect( lastScrollArgs( ).enabled ).toBe( false );
+ } );
+
+ it( "dispatches worldwide without prompting when permission is blocked", async ( ) => {
+ mockHasPermissions = false;
+ mockHasBlockedPermissions = true;
+ useExploreV2.mockReturnValue( {
+ state: mockState( { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY } ),
+ dispatch: mockDispatch,
+ } );
+
+ renderComponent( );
+
+ await waitFor( ( ) => expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE,
+ } ) );
+ expect( screen.queryByText( /To view nearby organisms/ ) ).toBeNull( );
+ expect( fetchCoarseUserLocation ).not.toHaveBeenCalled( );
+ } );
+
+ it( "dispatches worldwide when permission is granted but there is no fix", async ( ) => {
+ mockHasPermissions = true;
+ fetchCoarseUserLocation.mockResolvedValueOnce( null );
+ useExploreV2.mockReturnValue( {
+ state: mockState( { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY } ),
+ dispatch: mockDispatch,
+ } );
+
+ renderComponent( );
+
+ await waitFor( ( ) => expect( mockDispatch ).toHaveBeenCalledWith( {
+ type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE,
+ } ) );
+ } );
+
+ it( "fetches worldwide without coordinates when worldwide", async ( ) => {
+ mockHasPermissions = true;
+ useExploreV2.mockReturnValue( {
+ state: mockState( { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE } ),
+ dispatch: mockDispatch,
+ } );
+
+ renderComponent( );
+
+ await waitFor( ( ) => expect( lastScrollArgs( ).enabled ).toBe( true ) );
+ expect( lastScrollArgs( ).params.lat ).toBeUndefined( );
+ expect( fetchCoarseUserLocation ).not.toHaveBeenCalled( );
+ } );
+} );
From b8b0dc2aa898ffef99a9d3eaf2254eaef0c0f196 Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:49:15 -0500
Subject: [PATCH 079/108] MOB-1341: code review
---
.../Explore/ExploreV2/screens/ExploreResults.tsx | 11 +++--------
1 file changed, 3 insertions(+), 8 deletions(-)
diff --git a/src/components/Explore/ExploreV2/screens/ExploreResults.tsx b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
index 789d740c7..0c66bf416 100644
--- a/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
+++ b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
@@ -85,7 +85,7 @@ const ExploreResults = ( ) => {
} else if ( isNearby && hasPermissions === true && nearbyCoords === undefined ) {
fetchCoarseUserLocation( ).then( location => {
if ( cancelled ) return;
- if ( location?.latitude ) {
+ if ( typeof location?.latitude === "number" ) {
setNearbyCoords( { lat: location.latitude, lng: location.longitude, radius: 1 } );
} else {
// Perms granted but no fix — fall back to worldwide.
@@ -101,13 +101,8 @@ const ExploreResults = ( ) => {
const canFetch = !needsPermission && nearbyResolved;
const queryParams = useMemo(
- ( ) => buildExploreV2QueryParams(
- state,
- isNearby && nearbyCoords
- ? nearbyCoords
- : undefined,
- ),
- [state, isNearby, nearbyCoords],
+ ( ) => buildExploreV2QueryParams( state, nearbyCoords ),
+ [state, nearbyCoords],
);
const {
From cafe0a49dede6ec7d7c182ed5cd4cbf6ec29a7fc Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Fri, 17 Jul 2026 13:30:06 +0200
Subject: [PATCH 080/108] Swap comment
---
src/navigation/types.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/navigation/types.ts b/src/navigation/types.ts
index f598bc050..2efec5f07 100644
--- a/src/navigation/types.ts
+++ b/src/navigation/types.ts
@@ -328,8 +328,8 @@ export type BaseTabStackParamList = {
// From UserProfile
// {
// userId: user?.id,
- // userIcon: user?.login,
- // userLogin: user?.icon_url,
+ // userIcon: user?.icon_url,
+ // userLogin: user?.login,
// }
// From ProjectDetails
// {
From c5e6d6447cb94bc00a11a1de37670d0f976b36e2 Mon Sep 17 00:00:00 2001
From: Johannes Klein <17345891+jtklein@users.noreply.github.com>
Date: Fri, 17 Jul 2026 13:32:58 +0200
Subject: [PATCH 081/108] Generic params type to get a record by id from api
---
src/api/posts.ts | 14 +++-----------
src/api/types.d.ts | 4 ++++
src/api/usersTyped.ts | 8 ++------
3 files changed, 9 insertions(+), 17 deletions(-)
diff --git a/src/api/posts.ts b/src/api/posts.ts
index a658e10df..84ecf505a 100644
--- a/src/api/posts.ts
+++ b/src/api/posts.ts
@@ -1,7 +1,7 @@
import type { ErrorWithResponse, INatApiError } from "api/error";
import handleError from "api/error";
import type {
- ApiDefaultResult, ApiOpts, ApiParams, ApiResponse,
+ ApiDefaultResult, ApiGetByIdParams, ApiOpts, ApiParams, ApiResponse,
} from "api/types";
import inatjs from "inaturalistjs";
@@ -21,12 +21,8 @@ const fetchBlogPosts = async (
}
};
-interface ProjectPostsParams extends ApiParams {
- id: number;
-}
-
const fetchProjectPosts = async (
- params: ProjectPostsParams,
+ params: ApiGetByIdParams,
opts: ApiOpts = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
try {
@@ -41,12 +37,8 @@ const fetchProjectPosts = async (
}
};
-interface UserPostsParams extends ApiParams {
- id: number;
-}
-
const fetchUserPosts = async (
- params: UserPostsParams,
+ params: ApiGetByIdParams,
opts: ApiOpts = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
try {
diff --git a/src/api/types.d.ts b/src/api/types.d.ts
index 92a932493..d6e4281b3 100644
--- a/src/api/types.d.ts
+++ b/src/api/types.d.ts
@@ -14,6 +14,10 @@ export interface ApiParams {
ttl?: number;
}
+export interface ApiGetByIdParams extends ApiParams {
+ id: number;
+}
+
export interface ApiPlace {
id?: number;
name?: string;
diff --git a/src/api/usersTyped.ts b/src/api/usersTyped.ts
index 154fab349..fb93b7936 100644
--- a/src/api/usersTyped.ts
+++ b/src/api/usersTyped.ts
@@ -1,16 +1,12 @@
import type { ErrorWithResponse, INatApiError } from "api/error";
import handleError from "api/error";
import type {
- ApiDefaultResult, ApiOpts, ApiParams, ApiResponse,
+ ApiDefaultResult, ApiGetByIdParams, ApiOpts, ApiResponse,
} from "api/types";
import inatjs from "inaturalistjs";
-interface UsersProjectsParams extends ApiParams {
- id: number;
-}
-
const fetchUserProjects = async (
- params: UsersProjectsParams,
+ params: ApiGetByIdParams,
opts: ApiOpts = {},
): Promise | null | ErrorWithResponse | INatApiError> => {
try {
From 429962745cef5c9d0fd12ec6cd77d193a5a537d2 Mon Sep 17 00:00:00 2001
From: Johannes Klein
Date: Fri, 17 Jul 2026 16:15:48 +0200
Subject: [PATCH 082/108] E2e android needs secrets check and skip (#3844)
* Use a more generic name
* Check for secrets before running Android e2e pipeline
Switch to ubuntu from macos compared to ios e2e pipeline
* Require checksecret for build
---------
Co-authored-by: Johannes Klein <17345891+jtklein@users.noreply.github.com>
---
.github/workflows/e2e_android.yml | 15 +++++++++++++++
.github/workflows/e2e_ios.yml | 4 ++--
2 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/e2e_android.yml b/.github/workflows/e2e_android.yml
index eea994f2c..59f3fd4e2 100644
--- a/.github/workflows/e2e_android.yml
+++ b/.github/workflows/e2e_android.yml
@@ -13,7 +13,22 @@ concurrency:
cancel-in-progress: true
jobs:
+ checksecret:
+ name: check for secrets presence
+ runs-on: ubuntu-latest
+ outputs:
+ is_SECRETS_PRESENT_set: ${{ steps.checksecret_job.outputs.is_SECRETS_PRESENT_set }}
+ steps:
+ - name: Check whether secrets are present in the action runner
+ id: checksecret_job
+ env:
+ SECRETS_PRESENT: ${{ secrets.OAUTH_CLIENT_SECRET }}
+ run: |
+ echo "is_SECRETS_PRESENT_set: ${{ env.SECRETS_PRESENT != '' }}"
+ echo "::set-output name=is_SECRETS_PRESENT_set::${{ env.SECRETS_PRESENT != '' }}"
build:
+ needs: checksecret
+ if: needs.checksecret.outputs.is_SECRETS_PRESENT_set == 'true'
# 4-core Ubunutu GitHub Larger Runner
# https://docs.github.com/en/enterprise-cloud@latest/billing/reference/actions-runner-pricing#x64-powered-larger-runners
runs-on: ubuntu-24.04-m
diff --git a/.github/workflows/e2e_ios.yml b/.github/workflows/e2e_ios.yml
index eea480673..2bc0b5dd2 100644
--- a/.github/workflows/e2e_ios.yml
+++ b/.github/workflows/e2e_ios.yml
@@ -15,12 +15,12 @@ concurrency:
jobs:
checksecret:
- name: check for oauth client
+ name: check for secrets presence
runs-on: macos-26
outputs:
is_SECRETS_PRESENT_set: ${{ steps.checksecret_job.outputs.is_SECRETS_PRESENT_set }}
steps:
- - name: Check whether unity activation requests should be done
+ - name: Check whether secrets are present in the action runner
id: checksecret_job
env:
SECRETS_PRESENT: ${{ secrets.OAUTH_CLIENT_SECRET }}
From 399098b12b44e3c1dd6102efbc86dcd959dca6b7 Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Fri, 17 Jul 2026 09:50:47 -0500
Subject: [PATCH 083/108] exploreV2 prototype cleanup
---
src/components/Explore/ExploreContainer.js | 73 +++----
.../Explore/ExploreFiltersContainer.tsx | 58 -----
.../Explore/ExploreSearchContainer.tsx | 104 ---------
src/components/Explore/ExploreV2.tsx | 202 ------------------
.../Explore/RootExploreContainer.js | 73 +++----
.../StackNavigators/TabStackNavigator.tsx | 10 -
src/navigation/types.ts | 4 +-
7 files changed, 55 insertions(+), 469 deletions(-)
delete mode 100644 src/components/Explore/ExploreFiltersContainer.tsx
delete mode 100644 src/components/Explore/ExploreSearchContainer.tsx
delete mode 100644 src/components/Explore/ExploreV2.tsx
diff --git a/src/components/Explore/ExploreContainer.js b/src/components/Explore/ExploreContainer.js
index 0f932a3af..a898eb005 100644
--- a/src/components/Explore/ExploreContainer.js
+++ b/src/components/Explore/ExploreContainer.js
@@ -12,13 +12,11 @@ import {
} from "providers/ExploreContext";
import type { Node } from "react";
import React, { useCallback, useEffect, useState } from "react";
-import { useCurrentUser, useFeatureFlag } from "sharedHooks";
+import { useCurrentUser } from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission";
-import { FeatureFlag } from "stores/createFeatureFlagSlice";
import useStore from "stores/useStore";
import Explore from "./Explore";
-import ExploreV2 from "./ExploreV2";
import mapParamsToAPI from "./helpers/mapParamsToAPI";
import useExploreHeaderCount from "./hooks/useExploreHeaderCount";
import useParams from "./hooks/useParams";
@@ -26,7 +24,6 @@ import useParams from "./hooks/useParams";
const ExploreContainerWithContext = ( ): Node => {
const navigation = useNavigation( );
const { isConnected } = useNetInfo( );
- const exploreV2Enabled = useFeatureFlag( FeatureFlag.ExploreV2Enabled );
const exploreView = useStore( state => state.exploreView );
const setExploreView = useStore( state => state.setExploreView );
@@ -137,48 +134,32 @@ const ExploreContainerWithContext = ( ): Node => {
return (
<>
- {!exploreV2Enabled
- ? (
- dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
- }
- isConnected={isConnected}
- isFetchingHeaderCount={isFetchingHeaderCount}
- openFiltersModal={openFiltersModal}
- queryParams={queryParams}
- showFiltersModal={showFiltersModal}
- updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
- updateLocation={updateLocation}
- updateUser={updateUser}
- updateProject={updateProject}
- placeMode={state.placeMode}
- hasLocationPermissions={hasLocationPermissions}
- renderLocationPermissionsGate={renderPermissionsGate}
- requestLocationPermissions={requestLocationPermissions}
- startFetching={startFetching}
- />
- )
- : (
-
- )}
+ dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
+ }
+ isConnected={isConnected}
+ isFetchingHeaderCount={isFetchingHeaderCount}
+ openFiltersModal={openFiltersModal}
+ queryParams={queryParams}
+ showFiltersModal={showFiltersModal}
+ updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
+ updateLocation={updateLocation}
+ updateUser={updateUser}
+ updateProject={updateProject}
+ placeMode={state.placeMode}
+ hasLocationPermissions={hasLocationPermissions}
+ renderLocationPermissionsGate={renderPermissionsGate}
+ requestLocationPermissions={requestLocationPermissions}
+ startFetching={startFetching}
+ />
{renderPermissionsGate( {
onPermissionGranted: startFetching,
} ) }
diff --git a/src/components/Explore/ExploreFiltersContainer.tsx b/src/components/Explore/ExploreFiltersContainer.tsx
deleted file mode 100644
index 36449523a..000000000
--- a/src/components/Explore/ExploreFiltersContainer.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useNavigation } from "@react-navigation/native";
-import type { ApiProject } from "api/types";
-import {
- ExploreProvider,
-} from "providers/ExploreContext";
-import React from "react";
-
-import FilterModalV2 from "./Modals/FilterModalV2";
-
-const ExploreFiltersContainerWithContext = () => {
- const navigation = useNavigation();
-
- const closeModal = () => {
- navigation.goBack();
- };
-
- const filterByIconicTaxonUnknown = () => {
- console.log( " Not implemented in ExploreV2 yet" );
- };
-
- const updateTaxon = (
- taxon: {
- name: string;
- } | null,
- ) => {
- console.log( " Not implemented in ExploreV2 yet", taxon );
- };
-
- const updateUser = (
- user: {
- login: string;
- } | null,
- ) => {
- console.log( " Not implemented in ExploreV2 yet", user );
- };
-
- const updateProject = ( project: ApiProject ) => {
- console.log( " Not implemented in ExploreV2 yet", project );
- };
-
- return (
-
- );
-};
-
-const ExploreFiltersContainer = () => (
-
-
-
-);
-
-export default ExploreFiltersContainer;
diff --git a/src/components/Explore/ExploreSearchContainer.tsx b/src/components/Explore/ExploreSearchContainer.tsx
deleted file mode 100644
index 9dfaa6023..000000000
--- a/src/components/Explore/ExploreSearchContainer.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { useNavigation, useRoute } from "@react-navigation/native";
-import type { ApiPlace, ApiProject, ApiTaxon } from "api/types";
-import { View } from "components/styledComponents";
-import type { TabStackScreenProps } from "navigation/types";
-import {
- ExploreProvider,
-} from "providers/ExploreContext";
-import React from "react";
-import type { RealmTaxon } from "realmModels/types";
-import { useLocationPermission } from "sharedHooks";
-
-import ExploreLocationSearch from "./SearchScreens/ExploreLocationSearch";
-import ExploreProjectSearch from "./SearchScreens/ExploreProjectSearch";
-import ExploreTaxonSearch from "./SearchScreens/ExploreTaxonSearch";
-import ExploreUserSearch from "./SearchScreens/ExploreUserSearch";
-
-const ExploreSearchContainerWithContext = () => {
- const navigation = useNavigation["navigation"]>();
- const { params } = useRoute["route"]>();
-
- const {
- hasPermissions,
- renderPermissionsGate,
- requestPermissions,
- } = useLocationPermission( );
-
- const initialSearchMode = params?.initialSearchMode || "none";
-
- const closeModal = () => {
- navigation.goBack();
- };
-
- const updateTaxon = (
- taxon: {
- name: string;
- } | null,
- ) => {
- console.log( "Not implemented in ExploreV2 yet.", taxon );
- };
-
- const updateLocation = ( location: "worldwide" | ApiPlace ) => {
- console.log( "Not implemented in ExploreV2 yet.", location );
- };
-
- const updateUser = ( user: null | { login: string } ) => {
- console.log( "Not implemented in ExploreV2 yet.", user );
- };
- const updateProject = ( project: null | ApiProject ) => {
- console.log( "Not implemented in ExploreV2 yet.", project );
- };
-
- if ( initialSearchMode === "taxon" ) {
- return (
- {
- navigation.push( "TaxonDetails", { id: taxon.id } );
- }}
- updateTaxon={updateTaxon}
- />
- );
- }
-
- if ( initialSearchMode === "location" ) {
- return (
-
- );
- }
-
- if ( initialSearchMode === "users" ) {
- return (
-
- );
- }
-
- if ( initialSearchMode === "projects" ) {
- return (
-
- );
- }
-
- return (
-
- {renderPermissionsGate( {} )}
-
- );
-};
-
-const ExploreSearchContainer = () => (
-
-
-
-);
-
-export default ExploreSearchContainer;
diff --git a/src/components/Explore/ExploreV2.tsx b/src/components/Explore/ExploreV2.tsx
deleted file mode 100644
index faa7a844f..000000000
--- a/src/components/Explore/ExploreV2.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-import { refresh } from "@react-native-community/netinfo";
-import { useNavigation } from "@react-navigation/native";
-import classnames from "classnames";
-import {
- Body2,
- Button,
- INatIconButton,
- OfflineNotice,
- ViewWrapper,
-} from "components/SharedComponents";
-import { Pressable, View } from "components/styledComponents";
-import { PLACE_MODE } from "providers/ExploreContext";
-import React from "react";
-import { Alert } from "react-native";
-import {
- useDebugMode,
- useStoredLayout,
- useTranslation,
-} from "sharedHooks";
-import type { RenderLocationPermissionsGateFunction } from "sharedHooks/useLocationPermission";
-import { getShadow } from "styles/global";
-
-import IdentifiersView from "./IdentifiersView";
-import ObservationsView from "./ObservationsView";
-import ObservationsViewBar from "./ObservationsViewBar";
-import ObserversView from "./ObserversView";
-import SpeciesView from "./SpeciesView";
-
-const DROP_SHADOW = getShadow( {
- offsetHeight: 4,
- elevation: 6,
-} );
-
-enum EXPLORE_VIEW {
- OBSERVATIONS = "observations",
- IDENTIFIERS = "identifiers",
- OBSERVERS = "observers",
- SPECIES = "species"
-}
-
-enum EXPLORE_OBSERVATIONS_LAYOUT {
- GRID = "grid",
- LIST = "list",
- MAP = "map"
-}
-
-interface Props {
- canFetch?: boolean;
- currentExploreView: EXPLORE_VIEW;
- handleUpdateCount: ( exploreView: EXPLORE_VIEW, totalResults: number ) => void;
- hasLocationPermissions?: boolean;
- isConnected: boolean;
- placeMode: string;
- queryParams: object;
- renderLocationPermissionsGate: RenderLocationPermissionsGateFunction;
- requestLocationPermissions: ( ) => void;
-}
-
-const ExploreV2 = ( {
- canFetch,
- currentExploreView,
- handleUpdateCount,
- hasLocationPermissions,
- isConnected,
- placeMode,
- queryParams,
- renderLocationPermissionsGate,
- requestLocationPermissions,
-}: Props ) => {
- const navigation = useNavigation();
- const { t } = useTranslation( );
- const { layout, writeLayoutToStorage } = useStoredLayout( "exploreObservationsLayout" ) as {
- layout: EXPLORE_OBSERVATIONS_LAYOUT | null;
- writeLayoutToStorage: ( newValue: EXPLORE_OBSERVATIONS_LAYOUT ) => void;
- };
- const { isDebug } = useDebugMode( );
-
- const renderMainContent = ( ) => {
- if ( isConnected === false ) {
- return (
- refresh()}
- />
- );
- }
- // hasLocationPermissions === undefined means we haven't checked for location permissions yet
- if ( placeMode === PLACE_MODE.NEARBY && hasLocationPermissions === false ) {
- return (
-
-
- {t( "To-view-nearby-organisms-please-enable-location" )}
-
-
- );
- }
- return (
-
- {currentExploreView === EXPLORE_VIEW.OBSERVATIONS && (
-
- )}
- {currentExploreView === EXPLORE_VIEW.SPECIES && (
-
- )}
- {currentExploreView === EXPLORE_VIEW.OBSERVERS && (
-
- )}
- {currentExploreView === EXPLORE_VIEW.IDENTIFIERS && (
-
- )}
-
- );
- };
-
- return (
- <>
-
-
- navigation.navigate( "ExploreFilters" )}
- >
- {/* eslint-disable-next-line i18next/no-literal-string */}
- TODO: Header Link to Filters
-
- {currentExploreView === "observations" && (
-
- )}
- {renderMainContent()}
- {isDebug && (
- {
- Alert.alert(
- "ExploreV2 Info",
- `queryParams: ${JSON.stringify( queryParams )}`,
- );
- }}
- />
- )}
-
-
- {/*
- Leaving this here so that it is easier to reason about differences between Explore
- and ExploreV2.
- */}
- {null}
- >
- );
-};
-
-export default ExploreV2;
diff --git a/src/components/Explore/RootExploreContainer.js b/src/components/Explore/RootExploreContainer.js
index 306de80a0..fe94add31 100644
--- a/src/components/Explore/RootExploreContainer.js
+++ b/src/components/Explore/RootExploreContainer.js
@@ -17,13 +17,11 @@ import React, {
useRef,
useState,
} from "react";
-import { useCurrentUser, useFeatureFlag } from "sharedHooks";
+import { useCurrentUser } from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission";
-import { FeatureFlag } from "stores/createFeatureFlagSlice";
import useStore from "stores/useStore";
import Explore from "./Explore";
-import ExploreV2 from "./ExploreV2";
import mapParamsToAPI from "./helpers/mapParamsToAPI";
import useExploreHeaderCount from "./hooks/useExploreHeaderCount";
@@ -31,7 +29,6 @@ const RootExploreContainerWithContext = ( ): Node => {
const navigation = useNavigation( );
const { isConnected } = useNetInfo( );
const currentUser = useCurrentUser( );
- const exploreV2Enabled = useFeatureFlag( FeatureFlag.ExploreV2Enabled );
const rootExploreView = useStore( state => state.rootExploreView );
const setRootExploreView = useStore( state => state.setRootExploreView );
const rootStoredParams = useStore( state => state.rootStoredParams );
@@ -214,48 +211,32 @@ const RootExploreContainerWithContext = ( ): Node => {
return (
<>
- {!exploreV2Enabled
- ? (
- dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
- }
- currentExploreView={rootExploreView}
- setCurrentExploreView={setRootExploreView}
- isConnected={isConnected}
- isFetchingHeaderCount={isFetchingHeaderCount}
- handleUpdateCount={handleUpdateCount}
- openFiltersModal={openFiltersModal}
- queryParams={queryParams}
- showFiltersModal={showFiltersModal}
- updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
- updateLocation={updateLocation}
- updateUser={updateUser}
- updateProject={updateProject}
- placeMode={state.placeMode}
- hasLocationPermissions={hasLocationPermissions}
- requestLocationPermissions={requestLocationPermissions}
- startFetching={startFetching}
- renderLocationPermissionsGate={renderPermissionsGate}
- />
- )
- : (
-
- )}
+ dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
+ }
+ currentExploreView={rootExploreView}
+ setCurrentExploreView={setRootExploreView}
+ isConnected={isConnected}
+ isFetchingHeaderCount={isFetchingHeaderCount}
+ handleUpdateCount={handleUpdateCount}
+ openFiltersModal={openFiltersModal}
+ queryParams={queryParams}
+ showFiltersModal={showFiltersModal}
+ updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
+ updateLocation={updateLocation}
+ updateUser={updateUser}
+ updateProject={updateProject}
+ placeMode={state.placeMode}
+ hasLocationPermissions={hasLocationPermissions}
+ requestLocationPermissions={requestLocationPermissions}
+ startFetching={startFetching}
+ renderLocationPermissionsGate={renderPermissionsGate}
+ />
{renderPermissionsGate( {
onPermissionGranted: async ( ) => {
await updateLocation( "nearby" );
diff --git a/src/navigation/StackNavigators/TabStackNavigator.tsx b/src/navigation/StackNavigators/TabStackNavigator.tsx
index 6e69cf095..61acabace 100644
--- a/src/navigation/StackNavigators/TabStackNavigator.tsx
+++ b/src/navigation/StackNavigators/TabStackNavigator.tsx
@@ -7,8 +7,6 @@ import UiLibrary from "components/Developer/UiLibrary";
import UiLibraryItem from "components/Developer/UiLibraryItem";
import Donate from "components/Donate/Donate";
import ExploreContainer from "components/Explore/ExploreContainer";
-import ExploreFiltersContainer from "components/Explore/ExploreFiltersContainer";
-import ExploreSearchContainer from "components/Explore/ExploreSearchContainer";
import ExploreV2Container from "components/Explore/ExploreV2/ExploreV2Container";
import RootExploreContainer from "components/Explore/RootExploreContainer";
import Help from "components/Help/Help";
@@ -204,14 +202,6 @@ const TabStackNavigator = ( { route }: BottomTabProps ) => {
name="Explore"
component={ExploreContainer}
/>
-
-
Date: Fri, 17 Jul 2026 10:52:12 -0500
Subject: [PATCH 084/108] MOB-1341: fix for stale tab counts
---
.../ExploreV2/screens/ExploreResults.tsx | 8 ++++--
.../ExploreV2/screens/ExploreResults.test.js | 26 +++++++++++++++++++
2 files changed, 32 insertions(+), 2 deletions(-)
diff --git a/src/components/Explore/ExploreV2/screens/ExploreResults.tsx b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
index 0c66bf416..a550f1478 100644
--- a/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
+++ b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx
@@ -146,8 +146,12 @@ const ExploreResults = ( ) => {
{needsPermission
? renderPermissionPrompt( )
diff --git a/tests/unit/components/Explore/ExploreV2/screens/ExploreResults.test.js b/tests/unit/components/Explore/ExploreV2/screens/ExploreResults.test.js
index 87c412579..43d018612 100644
--- a/tests/unit/components/Explore/ExploreV2/screens/ExploreResults.test.js
+++ b/tests/unit/components/Explore/ExploreV2/screens/ExploreResults.test.js
@@ -114,6 +114,32 @@ describe( "ExploreResults nearby resolution", ( ) => {
expect( lastScrollArgs( ).enabled ).toBe( false );
} );
+ it( "does not show stale counts in the tabs when nearby, no permission", async ( ) => {
+ // Simulate the shared query cache still holding the previous (worldwide)
+ // result: the disabled query returns a non-zero totalResults.
+ mockUseInfiniteExploreScroll.mockReturnValue( {
+ fetchNextPage: jest.fn( ),
+ isFetchingNextPage: false,
+ handlePullToRefresh: jest.fn( ),
+ observations: [],
+ totalResults: 42,
+ } );
+ mockHasPermissions = false;
+ useExploreV2.mockReturnValue( {
+ state: mockState( { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY } ),
+ dispatch: mockDispatch,
+ } );
+
+ renderComponent( );
+
+ expect(
+ await screen.findByText( /To view nearby organisms/ ),
+ ).toBeVisible( );
+ // The stale worldwide count must not leak into the tabs.
+ expect( screen.queryByText( "42" ) ).toBeNull( );
+ expect( screen.getAllByText( "--" ).length ).toBeGreaterThanOrEqual( 1 );
+ } );
+
it( "dispatches worldwide without prompting when permission is blocked", async ( ) => {
mockHasPermissions = false;
mockHasBlockedPermissions = true;
From a819b9f3cb72ccf97a8f537551b8d7156ba7e55a Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Fri, 17 Jul 2026 11:22:49 -0500
Subject: [PATCH 085/108] run npm run icons and clean up share extension
---
.../app/src/main/assets/fonts/INatIcon.ttf | Bin 31340 -> 31960 bytes
android/link-assets-manifest.json | 2 +-
.../project.pbxproj | 8 ++++----
ios/link-assets-manifest.json | 2 +-
4 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/android/app/src/main/assets/fonts/INatIcon.ttf b/android/app/src/main/assets/fonts/INatIcon.ttf
index 68c4f0506d768b0eabca5c9635a012ff90acf335..5f4b7ad3fc2eedf75dc15c63deabc4c99526050b 100644
GIT binary patch
delta 1011
zcmX|AT}V@57=GXH`_8wyA2(||S>)1foo%|!ZOwVnvWRK)b0HET5z#PBs@2E^Cgm}@
z=|UozcvBZb5>ip*pqnJRiEg60i10G$M#PJ(ZiKMjZO$r*R%dGUsRn4|;Du}Af
z3CBX8VKe%TxsWZC3r&PRgu~&b2#cJEj78=ntF3zLO54w9Fq(^wMW081$Ml##HW6Ei
zZ;PLf&&R*Ei}pW>Ly5V>mt7)OPz
zF=}j+hz7M3R8Q&u$rc}`x-cC_OGi4B+Jm}BEo?_4L{bV%Rx(P=LbPh)m)=ebc}N|^
z3<>V&%#c?XlD~AwTg8(Kv1m*@Qffo8WMa6xuRB}~Gv4j?I(v*U&YS2kx7E%C*MwUY
zl1r^lQq6_1FpYC`qfnK4G$9EixU@<&YRDeR1_D_Zb=^#GWatt*ZAwLjy){mbMv|E-
zHoKV=1eKyk3b&xSu+cu04#wQV%f(D&%VN0usoFt{1-Db8j0uejO)xH{R!?GJDObvg=ZcRqsdtyPU3CSE0oz
r)HhK^9m+9UP^Q~sKRJ<;LD7nV
zfd{Ccks&v+qJVK0qacti0aUM$mzbLxpeAMTlYya7g@J)dtRTO*&dFz21L7Ew+#&J%V_{Na2ezF|nM#j3$7a02(CwnpROwMDTsObk*#lYynzzk;T
z0BJ6uk$wy;j50v|EIAgJ5y!}f}G
zvIEa1KUD+O8r4T?GHMZOOVklszP|*m|XwW#M@lNxA)+wz&+A`Wf+HKlrwBKp}
z(uvSHpz})CNVh?Eo$ejIEBX@pHTrAxKNy4<%rNXS{I%JI_o(OQ$jA&Cpbrj}{@Z-0
M>;v=W9aRcU05ei=PXGV_
diff --git a/android/link-assets-manifest.json b/android/link-assets-manifest.json
index 34a4c6db9..39e30b83d 100644
--- a/android/link-assets-manifest.json
+++ b/android/link-assets-manifest.json
@@ -3,7 +3,7 @@
"data": [
{
"path": "assets/fonts/INatIcon.ttf",
- "sha1": "89aa6f39308fa5bad4edbdd509983df51f06f30b"
+ "sha1": "35ca4fba9e8a1542c0ee4a06d4b5035627d92cbb"
},
{
"path": "assets/fonts/Lato-Bold.ttf",
diff --git a/ios/iNaturalistReactNative.xcodeproj/project.pbxproj b/ios/iNaturalistReactNative.xcodeproj/project.pbxproj
index 149c6398d..328e220c0 100644
--- a/ios/iNaturalistReactNative.xcodeproj/project.pbxproj
+++ b/ios/iNaturalistReactNative.xcodeproj/project.pbxproj
@@ -31,9 +31,9 @@
8F346E4A2CF6912700CED7B4 /* geomodel.mlmodel in Sources */ = {isa = PBXBuildFile; fileRef = 8F346E492CF6912700CED7B4 /* geomodel.mlmodel */; };
8FE171A22E97F0780003E759 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 8FE171A12E97F0780003E759 /* GoogleService-Info.plist */; };
8FF73F732F69C8CE007CF4F0 /* AutoContinueShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FF73F722F69C8CE007CF4F0 /* AutoContinueShareViewController.swift */; };
- 99CC9E26994B44F7B52A1B5D /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 8B9F9C9225124178BD456CA4 /* INatIcon.ttf */; };
AE4DC81B3A87484CB3FD6750 /* Lato-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4B0AEEF6CA584BCF9880EB35 /* Lato-Regular.ttf */; };
E5DFC1C6FBFA45739CE91C69 /* Lato-MediumItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 69DF855D92EA4ADFB73B47F1 /* Lato-MediumItalic.ttf */; };
+ D224B9770DAC4D9098E09BFF /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 54EEE7CC3E4645E4B9CE1AA8 /* INatIcon.ttf */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -92,7 +92,6 @@
8B65ED3A29F575FE0054CCEF /* ShareViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ShareViewController.swift; path = "../../node_modules/react-native-share-menu/ios/ShareViewController.swift"; sourceTree = ""; };
8B65ED3C29F576D00054CCEF /* iNaturalistReactNative-ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "iNaturalistReactNative-ShareExtension.entitlements"; sourceTree = ""; };
8B8BAD0429F54EB300CE5C9F /* iNaturalistReactNative.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = iNaturalistReactNative.entitlements; path = iNaturalistReactNative/iNaturalistReactNative.entitlements; sourceTree = ""; };
- 8B9F9C9225124178BD456CA4 /* INatIcon.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = INatIcon.ttf; path = ../assets/fonts/INatIcon.ttf; sourceTree = ""; };
8C2D97D72EED451C887998A8 /* Lato-BoldItalic.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Lato-BoldItalic.ttf"; path = "../assets/fonts/Lato-BoldItalic.ttf"; sourceTree = ""; };
8F1AC6762BC1B610002F994B /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; };
8F346E492CF6912700CED7B4 /* geomodel.mlmodel */ = {isa = PBXFileReference; lastKnownFileType = file.mlmodel; path = geomodel.mlmodel; sourceTree = ""; };
@@ -100,6 +99,7 @@
8FF73F722F69C8CE007CF4F0 /* AutoContinueShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AutoContinueShareViewController.swift; path = "../node_modules/react-native-share-menu/ios/AutoContinueShareViewController.swift"; sourceTree = SOURCE_ROOT; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F15C1390617A309CE0A194B2 /* Pods_iNaturalistReactNative_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iNaturalistReactNative_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 54EEE7CC3E4645E4B9CE1AA8 /* INatIcon.ttf */ = {isa = PBXFileReference; name = "INatIcon.ttf"; path = "../assets/fonts/INatIcon.ttf"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -229,7 +229,7 @@
3A9BAF07FCF24F668E2EF5AB /* Lato-Medium.ttf */,
69DF855D92EA4ADFB73B47F1 /* Lato-MediumItalic.ttf */,
4B0AEEF6CA584BCF9880EB35 /* Lato-Regular.ttf */,
- 8B9F9C9225124178BD456CA4 /* INatIcon.ttf */,
+ 54EEE7CC3E4645E4B9CE1AA8 /* INatIcon.ttf */,
);
name = Resources;
sourceTree = "";
@@ -338,7 +338,7 @@
E5DFC1C6FBFA45739CE91C69 /* Lato-MediumItalic.ttf in Resources */,
AE4DC81B3A87484CB3FD6750 /* Lato-Regular.ttf in Resources */,
716F7BDCD8B943479083CFAC /* Settings.bundle in Resources */,
- 99CC9E26994B44F7B52A1B5D /* INatIcon.ttf in Resources */,
+ D224B9770DAC4D9098E09BFF /* INatIcon.ttf in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/ios/link-assets-manifest.json b/ios/link-assets-manifest.json
index 34a4c6db9..39e30b83d 100644
--- a/ios/link-assets-manifest.json
+++ b/ios/link-assets-manifest.json
@@ -3,7 +3,7 @@
"data": [
{
"path": "assets/fonts/INatIcon.ttf",
- "sha1": "89aa6f39308fa5bad4edbdd509983df51f06f30b"
+ "sha1": "35ca4fba9e8a1542c0ee4a06d4b5035627d92cbb"
},
{
"path": "assets/fonts/Lato-Bold.ttf",
From b7f5c7e00fe6540f71aa8e281386e4f59e694fae Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Fri, 17 Jul 2026 09:48:23 -0700
Subject: [PATCH 086/108] wire obs results to new query
---
.../MyObservations/MyObservationsResults.tsx | 35 ++++++++++++++++---
1 file changed, 31 insertions(+), 4 deletions(-)
diff --git a/src/components/MyObservations/MyObservationsResults.tsx b/src/components/MyObservations/MyObservationsResults.tsx
index 188c179a2..bec6c4414 100644
--- a/src/components/MyObservations/MyObservationsResults.tsx
+++ b/src/components/MyObservations/MyObservationsResults.tsx
@@ -37,8 +37,10 @@ import {
useStoredLayout,
useTranslation,
} from "sharedHooks";
+import useFeatureFlag from "sharedHooks/useFeatureFlag";
import useLocalObservationIds from "sharedHooks/useLocalObservationIds";
import useObservationCounts from "sharedHooks/useObservationCounts";
+import { FeatureFlag } from "stores/createFeatureFlagSlice";
import {
UPLOAD_PENDING,
} from "stores/createUploadObservationsSlice";
@@ -46,6 +48,7 @@ import useStore, { zustandStorage } from "stores/useStore";
import type { SpeciesCount } from "types/sorting";
import FullScreenActivityIndicator from "./FullScreenActivityIndicator";
+import useMyObservationsQuery from "./hooks/useMyObservationsQuery";
import useSyncObservations from "./hooks/useSyncObservations";
import useUploadObservations from "./hooks/useUploadObservations";
import MyObservationsEmptySimple from "./MyObservationsEmptySimple";
@@ -93,7 +96,20 @@ const MyObservationsResults = ( ) => {
return unsubscribe;
}, [navigation, setJustFinishedSignup] );
- const observationIds = useLocalObservationIds();
+ const localObservationIds = useLocalObservationIds();
+ const sortMyObservationsEnabled = useFeatureFlag( FeatureFlag.SortMyObservationsEnabled );
+ const {
+ observationIds: serverOrderedObservationIds,
+ isServerAuthoritative,
+ isFetchingNextPage: isFetchingNextPageFromQuery,
+ fetchNextPage: fetchNextPageFromQuery,
+ refetch: refetchFromQuery,
+ } = useMyObservationsQuery( );
+ // Only use server-ordered list when the flag is on and the selected sort requires it
+ const useServerOrder = sortMyObservationsEnabled && isServerAuthoritative;
+ const observationIds = useServerOrder
+ ? serverOrderedObservationIds
+ : localObservationIds;
const {
numUnuploadedObservations,
numObsMissingBasics,
@@ -244,7 +260,10 @@ const MyObservationsResults = ( ) => {
const handlePullToRefresh = useCallback( async ( ) => {
await syncManually( { skipUploads: true } );
refetchObservationsUpdates( );
- }, [syncManually, refetchObservationsUpdates] );
+ if ( useServerOrder ) {
+ refetchFromQuery( );
+ }
+ }, [syncManually, refetchObservationsUpdates, useServerOrder, refetchFromQuery] );
// Scroll the list to the offset we need to restore, e.g. when you are
// scrolled way down, edit an observation, and return. Entering ObsEdit
@@ -365,6 +384,14 @@ const MyObservationsResults = ( ) => {
const numTotalObservations = totalResultsRemote || observationIds.length;
+ // Pagination for the rendered list follows whichever source is authoritative:
+ const isFetchingNextPageForList = useServerOrder
+ ? isFetchingNextPageFromQuery
+ : isFetchingNextPage;
+ const handleEndReached = useServerOrder
+ ? fetchNextPageFromQuery
+ : fetchNextPage;
+
useEffect( ( ) => {
// persist this number in zustand so a user can see their latest observations count
// even if they're offline
@@ -442,7 +469,7 @@ const MyObservationsResults = ( ) => {
handlePullToRefresh={handlePullToRefresh}
handleSyncButtonPress={handleSyncButtonPress}
isConnected={isConnected}
- isFetchingNextPage={isFetchingNextPage}
+ isFetchingNextPage={isFetchingNextPageForList}
isFetchingTaxa={isFetchingTaxa}
justFinishedSignup={justFinishedSignup}
layout={layout}
@@ -455,7 +482,7 @@ const MyObservationsResults = ( ) => {
numObsMissingBasics={numObsMissingBasics}
observationIds={observationIds}
observationsSortOptionId={myObsState.observationsSort}
- onEndReached={fetchNextPage}
+ onEndReached={handleEndReached}
onListLayout={restoreScrollOffset}
onScroll={onScroll}
openSheet={openSheet}
From 3aba9a57fa395c748a7d5ae65635594c72573819 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Fri, 17 Jul 2026 09:56:11 -0700
Subject: [PATCH 087/108] remove debug sheet
---
.../MyObsServerOrderedDebugSheet.tsx | 195 ------------------
.../MyObservations/MyObservationsResults.tsx | 76 ++++---
2 files changed, 36 insertions(+), 235 deletions(-)
delete mode 100644 src/components/MyObservations/MyObsServerOrderedDebugSheet.tsx
diff --git a/src/components/MyObservations/MyObsServerOrderedDebugSheet.tsx b/src/components/MyObservations/MyObsServerOrderedDebugSheet.tsx
deleted file mode 100644
index ba80b3648..000000000
--- a/src/components/MyObservations/MyObsServerOrderedDebugSheet.tsx
+++ /dev/null
@@ -1,195 +0,0 @@
-/* eslint-disable i18next/no-literal-string */
-import classnames from "classnames";
-import useMyObservationsQuery from "components/MyObservations/hooks/useMyObservationsQuery";
-import { INatIconButton } from "components/SharedComponents";
-import Modal from "components/SharedComponents/Modal";
-import Body3 from "components/SharedComponents/Typography/Body3";
-import Heading4 from "components/SharedComponents/Typography/Heading4";
-import {
- Image, Pressable, ScrollView, Text, View,
-} from "components/styledComponents";
-import { RealmContext } from "providers/contexts";
-import {
- MY_OBSERVATIONS_ACTION,
- useMyObservations,
-} from "providers/MyObservationsContext";
-import React, { useState } from "react";
-import type { RealmObservation } from "realmModels/types";
-import { OBSERVATIONS_SORT, OBSERVATIONS_SORT_OPTIONS } from "sharedHelpers/observationsSort";
-import useDebugMode from "sharedHooks/useDebugMode";
-
-const { useObject } = RealmContext;
-
-const SORT_LABELS: Record = {
- [OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST]: "Uploaded ↓",
- [OBSERVATIONS_SORT.DATE_UPLOADED_OLDEST]: "Uploaded ↑",
- [OBSERVATIONS_SORT.DATE_OBSERVED_NEWEST]: "Observed ↓",
- [OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST]: "Observed ↑",
-};
-
-const SCROLL_CONTENT = { paddingBottom: 32 };
-
-interface DebugButtonProps {
- label: string;
- onPress: ( ) => void;
- active?: boolean;
-}
-
-const DebugButton = ( { label, onPress, active }: DebugButtonProps ) => (
-
- {label}
-
-);
-
-interface DebugRealmObservation extends RealmObservation {
- id?: number;
-}
-
-interface ObservationRowProps {
- uuid: string;
- index: number;
-}
-
-const ObservationRow = ( { uuid, index }: ObservationRowProps ) => {
- const observation = useObject( "Observation", uuid );
- const thumbnailUrl = observation?.observationPhotos?.[0]?.photo?.url;
- const name = observation?.taxon?.preferredCommonName
- || observation?.taxon?.name
- || "(no taxon)";
- const uploadedAt = observation?._created_at
- ? observation._created_at.toLocaleDateString( )
- : "—";
- const observedOn = observation?.observed_on
- ? new Date( observation.observed_on ).toLocaleDateString( )
- : "—";
-
- return (
-
- {index + 1}
- {thumbnailUrl
- ?
- : }
-
- {name}
-
- {`id: ${observation?.id ?? "—"} · uuid: ${uuid}`}
-
- {`uploaded: ${uploadedAt}`}
- {`observed: ${observedOn}`}
-
-
- );
-};
-
-interface DebugSheetContentProps {
- onClose: ( ) => void;
-}
-
-// Split out from the sheet shell so useObservationsQuery (and the network
-// fetch it can trigger) only runs while the sheet is actually open
-const DebugSheetContent = ( { onClose }: DebugSheetContentProps ) => {
- const { state, dispatch } = useMyObservations( );
- const {
- observationIds,
- isServerAuthoritative,
- isLoading,
- isFetchingNextPage,
- error,
- refetch,
- fetchNextPage,
- } = useMyObservationsQuery( );
-
- return (
-
-
- MyObs Query Debug
-
- Close
-
-
-
-
- {OBSERVATIONS_SORT_OPTIONS.map( sort => (
- dispatch( {
- type: MY_OBSERVATIONS_ACTION.SET_OBSERVATIONS_SORT,
- observationsSort: sort,
- } )}
- />
- ) )}
- refetch( )} />
-
-
- {`Server authoritative: ${isServerAuthoritative
- ? "yes"
- : "no"}`}
-
- {isLoading && Loading…}
- {!!error && {`Error: ${error.message}`}}
- {`Showing ${observationIds.length} observations`}
- {observationIds.map( ( { uuid }, index ) => (
-
- ) )}
- {isServerAuthoritative && (
- !isFetchingNextPage && fetchNextPage( )}
- />
- )}
-
-
- );
-};
-
-const MyObsServerOrderedDebugSheet = ( ) => {
- const { isDebug } = useDebugMode( );
- const [visible, setVisible] = useState( false );
-
- if ( !isDebug ) return null;
-
- const onClose = ( ) => setVisible( false );
-
- return (
- <>
- setVisible( true )}
- />
-
- : null}
- />
- >
- );
-};
-
-export default MyObsServerOrderedDebugSheet;
diff --git a/src/components/MyObservations/MyObservationsResults.tsx b/src/components/MyObservations/MyObservationsResults.tsx
index bec6c4414..2e6d06d57 100644
--- a/src/components/MyObservations/MyObservationsResults.tsx
+++ b/src/components/MyObservations/MyObservationsResults.tsx
@@ -56,7 +56,6 @@ import MyObservationsSimple, {
OBSERVATIONS_TAB,
TAXA_TAB,
} from "./MyObservationsSimple";
-import MyObsServerOrderedDebugSheet from "./MyObsServerOrderedDebugSheet";
const { useRealm } = RealmContext;
@@ -459,45 +458,42 @@ const MyObservationsResults = ( ) => {
}
return (
- <>
-
-
- >
+
);
};
From f221cffbd51d5e9a295a72ab364323bfb3ba494a Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Fri, 17 Jul 2026 11:31:22 -0700
Subject: [PATCH 088/108] scroll to top of list when sort selection changes
---
src/components/MyObservations/MyObservationsSimple.tsx | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/components/MyObservations/MyObservationsSimple.tsx b/src/components/MyObservations/MyObservationsSimple.tsx
index 02c283db7..1e23a2ffb 100644
--- a/src/components/MyObservations/MyObservationsSimple.tsx
+++ b/src/components/MyObservations/MyObservationsSimple.tsx
@@ -360,6 +360,14 @@ const MyObservationsSimple = ( {
}
setObservationsSortOptionId( optionId );
+ // scroll to the top of the newly sorted list
+ // TODO: add local sort to handle logged-out users
+ setTimeout( () => {
+ if ( listRef?.current ) {
+ listRef.current.scrollToOffset( { offset: 0, animated: true } );
+ }
+ }, 0 );
+
setOpenSheet( ACTIVE_SHEET.NONE );
};
From a38414a0e569b84105c6e2479bc0c4d51cc54211 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Fri, 17 Jul 2026 12:23:46 -0700
Subject: [PATCH 089/108] only show full loading screen if we have no remote
obs
---
src/components/MyObservations/MyObservationsResults.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/MyObservations/MyObservationsResults.tsx b/src/components/MyObservations/MyObservationsResults.tsx
index 2e6d06d57..0b96b19fb 100644
--- a/src/components/MyObservations/MyObservationsResults.tsx
+++ b/src/components/MyObservations/MyObservationsResults.tsx
@@ -443,7 +443,7 @@ const MyObservationsResults = ( ) => {
if ( !layout ) { return null; }
- if ( observationIds.length === 0 ) {
+ if ( observationIds.length === 0 && !totalResultsRemote ) {
return showNoResults
? (
Date: Fri, 17 Jul 2026 21:39:52 +0200
Subject: [PATCH 090/108] Bump websocket-driver from 0.7.4 to 0.7.5 (#3840)
Bumps [websocket-driver](https://github.com/faye/websocket-driver-node) from 0.7.4 to 0.7.5.
- [Changelog](https://github.com/faye/websocket-driver-node/blob/main/CHANGELOG.md)
- [Commits](https://github.com/faye/websocket-driver-node/compare/0.7.4...0.7.5)
---
updated-dependencies:
- dependency-name: websocket-driver
dependency-version: 0.7.5
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
package-lock.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 504082295..82b969b4a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23153,9 +23153,9 @@
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/websocket-driver": {
- "version": "0.7.4",
- "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
- "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz",
+ "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==",
"license": "Apache-2.0",
"dependencies": {
"http-parser-js": ">=0.5.1",
From cbf12c222f0767d5aedb05247789b779430b0a0f Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Fri, 17 Jul 2026 14:52:14 -0500
Subject: [PATCH 091/108] MOB-1344: tests for UniversalSearch offline state
---
.../ExploreV2/screens/UniversalSearch.test.js | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js b/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js
index 2daccd82d..3ff4262de 100644
--- a/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js
+++ b/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js
@@ -1,3 +1,4 @@
+import { useNetInfo } from "@react-native-community/netinfo";
import {
act, fireEvent, screen, userEvent, waitFor,
} from "@testing-library/react-native";
@@ -182,6 +183,8 @@ beforeEach( ( ) => {
useIconicTaxa.mockReturnValue( ICONIC_TAXA );
useUniversalSearch.mockReturnValue( { results: [], isLoading: false, refetch: jest.fn( ) } );
useLocationSearch.mockReturnValue( { results: [], isLoading: false, refetch: jest.fn( ) } );
+ // Default to online; the offline tests override this per-case.
+ useNetInfo.mockReturnValue( { isConnected: true } );
} );
afterEach( ( ) => {
@@ -311,6 +314,34 @@ describe( "UniversalSearch screen", ( ) => {
).toBeNull( );
} );
+ describe( "offline state", ( ) => {
+ it( "shows the offline notice, not the no-results message, when offline with a query", ( ) => {
+ useNetInfo.mockReturnValue( { isConnected: false } );
+ renderComponent( );
+
+ typeQuery( "ver" );
+
+ expect(
+ screen.getByText( i18next.t( "You-are-offline-Tap-to-try-again" ) ),
+ ).toBeTruthy( );
+ expect(
+ screen.queryByText( i18next.t( "No-results-found-for-that-search" ) ),
+ ).toBeNull( );
+ } );
+
+ it( "retries the subject search when the offline notice is tapped", async ( ) => {
+ const refetch = jest.fn( );
+ useNetInfo.mockReturnValue( { isConnected: false } );
+ useUniversalSearch.mockReturnValue( { results: [], isLoading: false, refetch } );
+ renderComponent( );
+
+ typeQuery( "ver" );
+ await actor.press( screen.getByLabelText( i18next.t( "Internet-Connection-Required" ) ) );
+
+ expect( refetch ).toHaveBeenCalled( );
+ } );
+ } );
+
it( "does not show results until the user has typed a query", ( ) => {
useUniversalSearch.mockReturnValue( {
results: MIXED_RESULTS,
From f5027dff27fc2a1ddfdcab45ea1e7eac38b5da3f Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Fri, 17 Jul 2026 15:46:45 -0700
Subject: [PATCH 092/108] handle local sort for logged out users
---
.../MyObservations/MyObservationsResults.tsx | 6 +-
.../hooks/useMyObservationsQuery.ts | 61 ++++++++++++-------
src/sharedHelpers/observationsSort.ts | 19 ++++++
src/sharedHooks/useLocalObservationIds.ts | 16 +++--
4 files changed, 73 insertions(+), 29 deletions(-)
diff --git a/src/components/MyObservations/MyObservationsResults.tsx b/src/components/MyObservations/MyObservationsResults.tsx
index 0b96b19fb..acbf48519 100644
--- a/src/components/MyObservations/MyObservationsResults.tsx
+++ b/src/components/MyObservations/MyObservationsResults.tsx
@@ -98,7 +98,7 @@ const MyObservationsResults = ( ) => {
const localObservationIds = useLocalObservationIds();
const sortMyObservationsEnabled = useFeatureFlag( FeatureFlag.SortMyObservationsEnabled );
const {
- observationIds: serverOrderedObservationIds,
+ observationIds: queryObservationIds,
isServerAuthoritative,
isFetchingNextPage: isFetchingNextPageFromQuery,
fetchNextPage: fetchNextPageFromQuery,
@@ -106,8 +106,8 @@ const MyObservationsResults = ( ) => {
} = useMyObservationsQuery( );
// Only use server-ordered list when the flag is on and the selected sort requires it
const useServerOrder = sortMyObservationsEnabled && isServerAuthoritative;
- const observationIds = useServerOrder
- ? serverOrderedObservationIds
+ const observationIds = sortMyObservationsEnabled
+ ? queryObservationIds
: localObservationIds;
const {
numUnuploadedObservations,
diff --git a/src/components/MyObservations/hooks/useMyObservationsQuery.ts b/src/components/MyObservations/hooks/useMyObservationsQuery.ts
index bbb8f9da4..0a1775564 100644
--- a/src/components/MyObservations/hooks/useMyObservationsQuery.ts
+++ b/src/components/MyObservations/hooks/useMyObservationsQuery.ts
@@ -2,6 +2,7 @@ import { RealmContext } from "providers/contexts";
import { useMyObservations } from "providers/MyObservationsContext";
import { useMemo } from "react";
import { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
+import { useCurrentUser } from "sharedHooks";
import useLocalObservationIds from "sharedHooks/useLocalObservationIds";
import useServerOrderedObservations from "./useServerOrderedObservations";
@@ -25,12 +26,22 @@ interface UseMyObservationsQueryResult {
// and interact with their obs offline. This hook uses selected sort to determine whether Realm or
// the server should be the authoritative source of a user's observations (unsynced obs
// are always merged in at the top regardless of source).
+//
+// Logged-out users can never have server-ordered observations, since they can't upload until
+// they log in, so a non-default sort is applied to their local observations instead.
const useMyObservationsQuery = ( ): UseMyObservationsQueryResult => {
const { state } = useMyObservations( );
+ const currentUser = useCurrentUser( );
const isDefaultSort = state.observationsSort === OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST;
+ const sortLocally = !isDefaultSort && !currentUser;
+ const isServerAuthoritative = !isDefaultSort && !!currentUser;
- const localObservationIds = useLocalObservationIds( );
+ const localObservationIds = useLocalObservationIds(
+ sortLocally
+ ? state.observationsSort
+ : undefined,
+ );
const {
observationIds: serverObservationIds,
@@ -41,7 +52,7 @@ const useMyObservationsQuery = ( ): UseMyObservationsQueryResult => {
refetch,
} = useServerOrderedObservations( {
sortBy: state.observationsSort,
- enabled: !isDefaultSort,
+ enabled: isServerAuthoritative,
} );
// if we want obs from the server, we'll want to prepend local, unsynced obs to the top
@@ -66,34 +77,40 @@ const useMyObservationsQuery = ( ): UseMyObservationsQueryResult => {
// dedupe in case any locally unsynced obs also exist in the server results
const observationIds = useMemo( ( ) => {
- if ( isDefaultSort ) return localObservationIds;
+ if ( isDefaultSort || sortLocally ) return localObservationIds;
const unsyncedUuids = new Set( unsyncedObservationIds.map( o => o.uuid ) );
return [
...unsyncedObservationIds,
...serverObservationIds.filter( o => !unsyncedUuids.has( o.uuid ) ),
];
- }, [isDefaultSort, localObservationIds, unsyncedObservationIds, serverObservationIds] );
+ }, [
+ isDefaultSort,
+ sortLocally,
+ localObservationIds,
+ unsyncedObservationIds,
+ serverObservationIds,
+ ] );
return {
observationIds,
- isServerAuthoritative: !isDefaultSort,
- isLoading: isDefaultSort
- ? false
- : isLoading,
- isFetchingNextPage: isDefaultSort
- ? false
- : isFetchingNextPage,
- error: isDefaultSort
- ? null
- : error,
- // since we never fetched for default sort, we don't need to refetch or paginate.
- // pagination is still handled by useInfiniteObservationsScroll
- refetch: isDefaultSort
- ? NOOP_REFETCH
- : refetch,
- fetchNextPage: isDefaultSort
- ? NOOP_FETCH_NEXT_PAGE
- : fetchNextPage,
+ isServerAuthoritative,
+ isLoading: isServerAuthoritative
+ ? isLoading
+ : false,
+ isFetchingNextPage: isServerAuthoritative
+ ? isFetchingNextPage
+ : false,
+ error: isServerAuthoritative
+ ? error
+ : null,
+ // pagination only applies to the server-authoritative case; the default sort is handled by
+ // useInfiniteObservationsScroll, and locally-sorted data for logged-out users loads from Realm
+ refetch: isServerAuthoritative
+ ? refetch
+ : NOOP_REFETCH,
+ fetchNextPage: isServerAuthoritative
+ ? fetchNextPage
+ : NOOP_FETCH_NEXT_PAGE,
};
};
diff --git a/src/sharedHelpers/observationsSort.ts b/src/sharedHelpers/observationsSort.ts
index 9bf2eee76..c7d38644f 100644
--- a/src/sharedHelpers/observationsSort.ts
+++ b/src/sharedHelpers/observationsSort.ts
@@ -36,6 +36,25 @@ export function observationSortToApiParams( sort: OBSERVATIONS_SORT ): Observati
return OBSERVATIONS_SORT_TO_API_PARAMS[sort];
}
+// [property, reverse] tuple, matching Realm's .sorted() argument shape
+type ObservationRealmSort = [string, boolean];
+
+const OBSERVATIONS_SORT_TO_REALM_SORT: Record = {
+ [OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST]: ["_created_at", true],
+ [OBSERVATIONS_SORT.DATE_UPLOADED_OLDEST]: ["_created_at", false],
+ // observed_on (a real date) is only populated once an observation has been uploaded and the
+ // server computes it -- local-only observations only ever have the
+ // observed_on_string set, so local sorting needs to use that field instead
+ [OBSERVATIONS_SORT.DATE_OBSERVED_NEWEST]: ["observed_on_string", true],
+ [OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST]: ["observed_on_string", false],
+};
+
+// For sorting local, unsynced observations directly in a Realm query -- used for logged-out
+// users, who can never have server-ordered observations
+export function observationSortToRealmSort( sort: OBSERVATIONS_SORT ): ObservationRealmSort {
+ return OBSERVATIONS_SORT_TO_REALM_SORT[sort];
+}
+
export function useObservationsSortLabels( ): Record {
const { t } = useTranslation( );
return {
diff --git a/src/sharedHooks/useLocalObservationIds.ts b/src/sharedHooks/useLocalObservationIds.ts
index a5e285d22..7aad3fa23 100644
--- a/src/sharedHooks/useLocalObservationIds.ts
+++ b/src/sharedHooks/useLocalObservationIds.ts
@@ -1,19 +1,27 @@
import { RealmContext } from "providers/contexts";
import { useMemo } from "react";
import Observation from "realmModels/Observation";
+import type { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
+import { observationSortToRealmSort } from "sharedHelpers/observationsSort";
const { useQuery } = RealmContext;
-const useLocalObservationIds = ( ) => {
+const useLocalObservationIds = ( sortBy?: OBSERVATIONS_SORT ) => {
const unsyncedObs = useQuery(
{
type: Observation,
query: observations => observations
.filtered( "_deleted_at == nil OR _pending_deletion == false OR _pending_deletion == nil" )
- .sorted( [["needs_sync", true], ["_created_at", true]] ),
- keyPaths: ["uuid"],
+ .sorted( sortBy
+ ? [observationSortToRealmSort( sortBy )]
+ : [["needs_sync", true], ["_created_at", true]] ),
+ // widening this beyond uuid means more frequent re-renders for every consumer,
+ // so we only do it when we need a local sort applied to the results
+ keyPaths: sortBy
+ ? ["uuid", "_created_at", "observed_on_string"]
+ : ["uuid"],
},
- [],
+ [sortBy],
);
return useMemo(
From 6d4555ae06702dde089ab99c04cf2b0f2167d3ce Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Fri, 17 Jul 2026 16:18:46 -0700
Subject: [PATCH 093/108] test update
---
.../MyObservations/hooks/useMyObservationsQuery.test.js | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js b/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js
index 75f520b95..f831adf20 100644
--- a/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js
+++ b/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js
@@ -5,6 +5,7 @@ import useServerOrderedObservations
import { useMyObservations } from "providers/MyObservationsContext";
import { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
+import useCurrentUser from "sharedHooks/useCurrentUser";
import factory from "tests/factory";
import setupUniqueRealm from "tests/helpers/uniqueRealm";
@@ -18,6 +19,11 @@ jest.mock( "providers/MyObservationsContext", ( ) => ( {
useMyObservations: jest.fn( ),
} ) );
+jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
+ __esModule: true,
+ default: jest.fn( ),
+} ) );
+
// UNIQUE REALM SETUP
const mockRealmIdentifier = __filename;
const { mockRealmModelsIndex, uniqueRealmBeforeAll, uniqueRealmAfterAll } = setupUniqueRealm(
@@ -62,6 +68,8 @@ const defaultServerResult = {
refetch: jest.fn( ),
};
+const mockUser = factory( "LocalUser" );
+
beforeEach( ( ) => {
// clear leftover state from previous test
const realm = global.mockRealms[mockRealmIdentifier];
@@ -69,6 +77,7 @@ beforeEach( ( ) => {
realm.deleteAll( );
}, "clear realm before each useMyObservationsQuery test" );
useServerOrderedObservations.mockReturnValue( defaultServerResult );
+ useCurrentUser.mockReturnValue( mockUser );
} );
afterEach( ( ) => {
From 00cad360d298d2e8972da5b499296b0f2faa3546 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Fri, 17 Jul 2026 16:24:33 -0700
Subject: [PATCH 094/108] add test
---
.../hooks/useMyObservationsQuery.test.js | 27 +++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js b/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js
index f831adf20..35a42e875 100644
--- a/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js
+++ b/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js
@@ -165,4 +165,31 @@ describe( "useMyObservationsQuery", ( ) => {
otherServerObs,
] );
} );
+
+ it( "applies the selected sort to local observations when there is no current user", ( ) => {
+ useCurrentUser.mockReturnValue( null );
+ useMyObservations.mockReturnValue( {
+ state: { observationsSort: OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST },
+ } );
+ useServerOrderedObservations.mockReturnValue( {
+ ...defaultServerResult,
+ observationIds: [{ uuid: "should-be-ignored-when-logged-out" }],
+ } );
+ const olderObs = factory( "LocalObservation", { observed_on_string: "2020-01-01T00:00:00" } );
+ const newerObs = factory( "LocalObservation", { observed_on_string: "2022-06-15T00:00:00" } );
+ // create newer-first so a passing test can't be explained by insertion order
+ createObservation( newerObs );
+ createObservation( olderObs );
+
+ const { result } = renderHook( ( ) => useMyObservationsQuery( ) );
+
+ expect( result.current.observationIds ).toEqual( [
+ { uuid: olderObs.uuid },
+ { uuid: newerObs.uuid },
+ ] );
+ expect( result.current.isServerAuthoritative ).toEqual( false );
+ expect( useServerOrderedObservations ).toHaveBeenCalledWith(
+ expect.objectContaining( { enabled: false } ),
+ );
+ } );
} );
From 84ba54564d964d538d77227c1275ae77e69cc056 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Sun, 19 Jul 2026 14:22:32 -0700
Subject: [PATCH 095/108] rm todo
---
src/components/MyObservations/MyObservationsSimple.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/components/MyObservations/MyObservationsSimple.tsx b/src/components/MyObservations/MyObservationsSimple.tsx
index 1e23a2ffb..99210704a 100644
--- a/src/components/MyObservations/MyObservationsSimple.tsx
+++ b/src/components/MyObservations/MyObservationsSimple.tsx
@@ -361,7 +361,6 @@ const MyObservationsSimple = ( {
setObservationsSortOptionId( optionId );
// scroll to the top of the newly sorted list
- // TODO: add local sort to handle logged-out users
setTimeout( () => {
if ( listRef?.current ) {
listRef.current.scrollToOffset( { offset: 0, animated: true } );
From 046a94b9ac5035bab152cbe0d13dfd642111186d Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Mon, 20 Jul 2026 10:35:08 -0500
Subject: [PATCH 096/108] MOB-1345: unobserved field for API
---
src/api/types.d.ts | 1 +
src/i18n/l10n/en.ftl | 2 ++
src/i18n/l10n/en.ftl.json | 1 +
3 files changed, 4 insertions(+)
diff --git a/src/api/types.d.ts b/src/api/types.d.ts
index d6e4281b3..77404e9e5 100644
--- a/src/api/types.d.ts
+++ b/src/api/types.d.ts
@@ -297,4 +297,5 @@ export interface ApiObservationsSearchParams extends ApiParams {
typeof ORDER_BY_UPDATED_AT |
typeof ORDER_BY_VOTES;
return_bounds?: boolean;
+ unobserved_by_user_id?: number;
}
diff --git a/src/i18n/l10n/en.ftl b/src/i18n/l10n/en.ftl
index 2cf89e2bb..f071fcc61 100644
--- a/src/i18n/l10n/en.ftl
+++ b/src/i18n/l10n/en.ftl
@@ -1350,6 +1350,8 @@ Unknown--user = Unknown
# Generic error message
Unknown-error = Unknown error
Unknown-organism = Unknown organism
+# Header title for the Explore context showing species the user has not observed
+Unobserved = Unobserved
Unreviewed-observations-only = Unreviewed observations only
Upload-Complete = Upload Complete
Upload-in-progress = Upload in progress
diff --git a/src/i18n/l10n/en.ftl.json b/src/i18n/l10n/en.ftl.json
index 648c3564f..a6cc5d758 100644
--- a/src/i18n/l10n/en.ftl.json
+++ b/src/i18n/l10n/en.ftl.json
@@ -860,6 +860,7 @@
"Unknown--user": "Unknown",
"Unknown-error": "Unknown error",
"Unknown-organism": "Unknown organism",
+ "Unobserved": "Unobserved",
"Unreviewed-observations-only": "Unreviewed observations only",
"Upload-Complete": "Upload Complete",
"Upload-in-progress": "Upload in progress",
From 62ba1f156f6096e8b02e0874d2fa5d7d2e8868e0 Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Mon, 20 Jul 2026 10:51:53 -0500
Subject: [PATCH 097/108] MOB-1345: header reorg and support for unobserved
---
.../ExploreV2/components/ExploreV2Header.tsx | 149 ++++++++++++------
1 file changed, 101 insertions(+), 48 deletions(-)
diff --git a/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx b/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx
index b9e4bced7..27851c6cc 100644
--- a/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx
+++ b/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx
@@ -32,6 +32,8 @@ function subjectLabel( subject: ExploreV2Subject | null, t: TFunction ): string
return subject.user.login;
case "project":
return subject.project.title;
+ case "unobserved":
+ return t( "Unobserved" );
default:
return t( "All-organisms" );
}
@@ -96,66 +98,117 @@ const SubjectThumbnail = ( { subject }: { subject: ExploreV2Subject } ) => {
}
};
+const LocationSubtitle = ( { place }: { place: string } ) => {
+ if ( !place ) { return null; }
+ return (
+
+
+
+ {place}
+
+
+ );
+};
+
+const TitleHeader = ( {
+ title,
+ place,
+ testID,
+}: {
+ title: string;
+ place?: string;
+ testID?: string;
+} ) => (
+
+
+ {title}
+
+ {place
+ ?
+ : null}
+
+);
+
+const SubjectHeader = ( {
+ subject,
+ label,
+ place,
+ prefersCommonNames,
+ scientificNameFirst,
+}: {
+ subject: ExploreV2Subject;
+ label: string;
+ place: string;
+ prefersCommonNames?: boolean;
+ scientificNameFirst?: boolean;
+} ) => (
+
+
+
+ {subject.type === "taxon"
+ ? (
+
+ )
+ : (
+
+ {label}
+
+ )}
+
+
+
+);
+
const ExploreV2Header = ( ) => {
const { t } = useTranslation( );
const { state } = useExploreV2( );
const currentUser = useCurrentUser( );
const navigation = useNavigation["navigation"]>( );
- const subject = subjectLabel( state.subject, t );
+ const { subject } = state;
const place = locationLabel( state.location, t );
+ let headerContent;
+ if ( subject && subject.type !== "unobserved" ) {
+ headerContent = (
+
+ );
+ } else if ( subject?.type === "unobserved" ) {
+ headerContent = (
+
+ );
+ } else {
+ headerContent = ;
+ }
+
return (
- {state.subject
- ? (
-
-
-
- {state.subject.type === "taxon"
- ? (
-
- )
- : (
-
- {subject}
-
- )}
- {place
- ? (
-
-
-
- {place}
-
-
- )
- : null}
-
-
- )
- : (
-
-
- {place}
-
-
- )}
+ {headerContent}
Date: Mon, 20 Jul 2026 10:53:09 -0500
Subject: [PATCH 098/108] MOB-1345: unobserved in context and set from default
options
---
.../ExploreV2/components/DefaultSearchOptions.tsx | 10 ++++++++--
src/providers/ExploreV2Context.tsx | 3 ++-
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/src/components/Explore/ExploreV2/components/DefaultSearchOptions.tsx b/src/components/Explore/ExploreV2/components/DefaultSearchOptions.tsx
index 1a9d13912..cae301e8f 100644
--- a/src/components/Explore/ExploreV2/components/DefaultSearchOptions.tsx
+++ b/src/components/Explore/ExploreV2/components/DefaultSearchOptions.tsx
@@ -83,8 +83,14 @@ const DefaultSearchOptions = ( { onSelectSubject }: Props ) => {
accessibilityRole="button"
accessibilityLabel={t( "Species-I-havent-observed" )}
className={ROW_CLASSES}
- // TODO MOB-1345
- onPress={( ) => undefined}
+ onPress={( ) => onSelectSubject( {
+ type: "unobserved",
+ user: {
+ id: currentUser.id,
+ login: currentUser.login,
+ icon_url: currentUser.icon_url,
+ },
+ } )}
testID="DefaultSearchOptions.unobserved"
>
{t( "Species-I-havent-observed" )}
diff --git a/src/providers/ExploreV2Context.tsx b/src/providers/ExploreV2Context.tsx
index 56474023e..58b7bfdba 100644
--- a/src/providers/ExploreV2Context.tsx
+++ b/src/providers/ExploreV2Context.tsx
@@ -60,7 +60,8 @@ export type ExploreV2Tab = typeof OBSERVATIONS_TAB | typeof SPECIES_TAB;
export type ExploreV2Subject =
| { type: "taxon"; taxon: Taxon }
| { type: "user"; user: User }
- | { type: "project"; project: Project };
+ | { type: "project"; project: Project }
+ | { type: "unobserved"; user: User };
// To be added to in MOB-1346
export interface ExploreV2Filters {
From b18127c69f20bd0d241008e7cf51a8d859121fda Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Mon, 20 Jul 2026 10:56:10 -0500
Subject: [PATCH 099/108] MOB-1345: unobserved_by_user_id in query params
---
src/components/Explore/ExploreV2/helpers/buildQueryParams.ts | 4 ++++
src/sharedHooks/useSpeciesCount.ts | 1 +
2 files changed, 5 insertions(+)
diff --git a/src/components/Explore/ExploreV2/helpers/buildQueryParams.ts b/src/components/Explore/ExploreV2/helpers/buildQueryParams.ts
index 7b5c0e257..5ccf36d14 100644
--- a/src/components/Explore/ExploreV2/helpers/buildQueryParams.ts
+++ b/src/components/Explore/ExploreV2/helpers/buildQueryParams.ts
@@ -14,6 +14,7 @@ export interface ExploreV2QueryParams {
taxon_id?: number;
user_id?: number;
project_id?: number;
+ unobserved_by_user_id?: number;
lat?: number;
lng?: number;
radius?: number;
@@ -41,6 +42,9 @@ const buildExploreV2QueryParams = (
case "project":
params.project_id = state.subject.project.id;
break;
+ case "unobserved":
+ params.unobserved_by_user_id = state.subject.user.id;
+ break;
default:
break;
}
diff --git a/src/sharedHooks/useSpeciesCount.ts b/src/sharedHooks/useSpeciesCount.ts
index 96d5b371d..db14fde67 100644
--- a/src/sharedHooks/useSpeciesCount.ts
+++ b/src/sharedHooks/useSpeciesCount.ts
@@ -7,6 +7,7 @@ export interface SpeciesCountParams extends ApiParams {
taxon_id?: number;
user_id?: number;
project_id?: number;
+ unobserved_by_user_id?: number;
place_id?: number;
lat?: number;
lng?: number;
From 511e8dab8a6f90fc389341ed1c1441674b805b35 Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Mon, 20 Jul 2026 10:59:46 -0500
Subject: [PATCH 100/108] MOB-1345: unobserved subject for universal search
screen
---
.../Explore/ExploreV2/helpers/universalSearchSubject.ts | 4 ++++
src/components/Explore/ExploreV2/screens/UniversalSearch.tsx | 4 ++--
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/components/Explore/ExploreV2/helpers/universalSearchSubject.ts b/src/components/Explore/ExploreV2/helpers/universalSearchSubject.ts
index c7e8673a6..fcbba999a 100644
--- a/src/components/Explore/ExploreV2/helpers/universalSearchSubject.ts
+++ b/src/components/Explore/ExploreV2/helpers/universalSearchSubject.ts
@@ -1,5 +1,6 @@
import type { UniversalSearchResultItem }
from "components/Explore/ExploreV2/hooks/useUniversalSearch";
+import type { TFunction } from "i18next";
import type { ExploreV2Subject } from "providers/ExploreV2Context";
import { log } from "sharedHelpers/logger";
import { generateTaxonPieces } from "sharedHelpers/taxon";
@@ -54,6 +55,7 @@ export const resultToSubject = ( result: UniversalSearchResultItem ): ExploreV2S
export const subjectToText = (
subject: ExploreV2Subject,
commonNameIsPrimary: boolean,
+ t: TFunction,
): string => {
switch ( subject.type ) {
case "user":
@@ -64,6 +66,8 @@ export const subjectToText = (
return ( commonNameIsPrimary && subject.taxon.preferred_common_name )
? generateTaxonPieces( subject.taxon ).commonName ?? subject.taxon.name
: subject.taxon.name;
+ case "unobserved":
+ return t( "Species-I-havent-observed" );
default:
logger.error( `subjectToText: Unknown explore
subject type: ${( subject as { type: string } ).type}` );
diff --git a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx
index 2f6994146..758526d2e 100644
--- a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx
+++ b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx
@@ -138,9 +138,9 @@ const UniversalSearch = ( ) => {
const handleSubjectSelect = useCallback( ( subject: ExploreV2Subject ) => {
setSelectedSubject( subject );
- commitSubject( subjectToText( subject, commonNameIsPrimary ) );
+ commitSubject( subjectToText( subject, commonNameIsPrimary, t ) );
locationInputRef.current?.focus( );
- }, [commitSubject, commonNameIsPrimary] );
+ }, [commitSubject, commonNameIsPrimary, t] );
const handleLocationSelect = useCallback( ( place: LocationSearchResultItem ) => {
setSelectedLocation( {
From 8beb1153500593376f37541894d4b6f7cd32db79 Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Mon, 20 Jul 2026 11:23:35 -0500
Subject: [PATCH 101/108] MOB-1345: unobserved string
---
src/i18n/strings.ftl | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/i18n/strings.ftl b/src/i18n/strings.ftl
index 2cf89e2bb..f071fcc61 100644
--- a/src/i18n/strings.ftl
+++ b/src/i18n/strings.ftl
@@ -1350,6 +1350,8 @@ Unknown--user = Unknown
# Generic error message
Unknown-error = Unknown error
Unknown-organism = Unknown organism
+# Header title for the Explore context showing species the user has not observed
+Unobserved = Unobserved
Unreviewed-observations-only = Unreviewed observations only
Upload-Complete = Upload Complete
Upload-in-progress = Upload in progress
From 3911b362bb46a4232f8914b64ded76cfba5ffc75 Mon Sep 17 00:00:00 2001
From: sepeterson <10458078+sepeterson@users.noreply.github.com>
Date: Mon, 20 Jul 2026 12:30:40 -0500
Subject: [PATCH 102/108] MOB-1345: tests
---
.../ExploreV2/buildQueryParams.test.js | 11 +++++++++
.../components/ExploreV2Header.test.js | 13 ++++++++++
.../ExploreV2/screens/UniversalSearch.test.js | 24 +++++++++++++++++++
3 files changed, 48 insertions(+)
diff --git a/tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js b/tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js
index e84c419c0..ec85a0b62 100644
--- a/tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js
+++ b/tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js
@@ -37,6 +37,17 @@ describe( "buildExploreV2QueryParams", ( ) => {
const params = buildExploreV2QueryParams( state );
expect( params.project_id ).toBe( 12 );
} );
+
+ it( "maps an unobserved subject to unobserved_by_user_id", ( ) => {
+ const state = {
+ ...initialExploreV2State,
+ subject: { type: "unobserved", user: { id: 99 } },
+ };
+ const params = buildExploreV2QueryParams( state );
+ expect( params.unobserved_by_user_id ).toBe( 99 );
+ expect( params.user_id ).toBeUndefined( );
+ expect( params.taxon_id ).toBeUndefined( );
+ } );
} );
describe( "location", ( ) => {
diff --git a/tests/unit/components/Explore/ExploreV2/components/ExploreV2Header.test.js b/tests/unit/components/Explore/ExploreV2/components/ExploreV2Header.test.js
index 09b709ef3..6f220b089 100644
--- a/tests/unit/components/Explore/ExploreV2/components/ExploreV2Header.test.js
+++ b/tests/unit/components/Explore/ExploreV2/components/ExploreV2Header.test.js
@@ -124,6 +124,19 @@ describe( "ExploreV2Header", () => {
expect( screen.getByTestId( "IconicTaxonName.iconicTaxonIcon" ) ).toBeTruthy();
} );
+ it( "renders the Unobserved title and location without a subject thumbnail", () => {
+ setState(
+ { type: "unobserved", user: { id: 7, login: "seth_msp" } },
+ { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE },
+ );
+ renderComponent( );
+
+ expect( screen.getByText( "Unobserved" ) ).toBeTruthy();
+ expect( screen.getByText( "Worldwide" ) ).toBeTruthy();
+ expect( screen.getByTestId( "ExploreV2Header.unobserved" ) ).toBeTruthy();
+ expect( screen.queryByTestId( "ExploreV2Header.subject" ) ).toBeNull();
+ } );
+
it( "renders only the place name when there is no subject", () => {
setState( null );
renderComponent( );
diff --git a/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js b/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js
index 2daccd82d..631c63c7b 100644
--- a/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js
+++ b/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js
@@ -382,6 +382,30 @@ describe( "UniversalSearch screen", ( ) => {
);
} );
+ it( "stages an unobserved subject when the unobserved row is tapped", async ( ) => {
+ renderComponent( );
+
+ await actor.press( screen.getByTestId( "DefaultSearchOptions.unobserved" ) );
+
+ // the selection is staged locally, not written to context until Search
+ expect( mockDispatch ).not.toHaveBeenCalled( );
+ // the subject field shows the "Species I haven't observed" label
+ expect(
+ screen.getByDisplayValue( i18next.t( "Species-I-havent-observed" ) ),
+ ).toBeTruthy( );
+
+ await actor.press( screen.getByTestId( "UniversalSearch.searchButton" ) );
+ expect( mockDispatch ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ type: "SET_SUBJECT",
+ subject: expect.objectContaining( {
+ type: "unobserved",
+ user: expect.objectContaining( { id: 99 } ),
+ } ),
+ } ),
+ );
+ } );
+
it( "hides the current user row when logged out", ( ) => {
useCurrentUser.mockReturnValue( null );
renderComponent( );
From 050227e3f34d28c4de1481e58648b8449e851418 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Tue, 21 Jul 2026 10:27:17 -0700
Subject: [PATCH 103/108] add taxon id to server-ordered hook
---
.../MyObservations/hooks/useServerOrderedObservations.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/components/MyObservations/hooks/useServerOrderedObservations.ts b/src/components/MyObservations/hooks/useServerOrderedObservations.ts
index 1b541a7e8..b43beb01e 100644
--- a/src/components/MyObservations/hooks/useServerOrderedObservations.ts
+++ b/src/components/MyObservations/hooks/useServerOrderedObservations.ts
@@ -25,6 +25,7 @@ interface SearchObservationsResponse {
interface UseServerOrderedObservationsParams {
sortBy: OBSERVATIONS_SORT;
+ taxonId?: number;
enabled?: boolean;
}
@@ -40,6 +41,7 @@ interface UseServerOrderedObservationsResult {
const useServerOrderedObservations = ( {
sortBy,
+ taxonId,
enabled = true,
}: UseServerOrderedObservationsParams ): UseServerOrderedObservationsResult => {
const realm = useRealm( );
@@ -48,6 +50,9 @@ const useServerOrderedObservations = ( {
const baseParams = {
user_id: currentUser?.id,
...observationSortToApiParams( sortBy ),
+ ...( taxonId
+ ? { taxon_id: taxonId }
+ : {} ),
per_page: PER_PAGE,
fields: Observation.ADVANCED_MODE_LIST_FIELDS,
// Bypass API response caching so newly created/updated observations show up
From 9c8ffc641ab3ecb34c0ed08ff43d849124aebb1a Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Tue, 21 Jul 2026 12:42:07 -0700
Subject: [PATCH 104/108] wire taxon search into useMyObservationsQuery
---
.../MyObservations/MyObservationsResults.tsx | 9 ++++++---
.../hooks/useMyObservationsQuery.ts | 15 ++++++++-------
2 files changed, 14 insertions(+), 10 deletions(-)
diff --git a/src/components/MyObservations/MyObservationsResults.tsx b/src/components/MyObservations/MyObservationsResults.tsx
index acbf48519..545160820 100644
--- a/src/components/MyObservations/MyObservationsResults.tsx
+++ b/src/components/MyObservations/MyObservationsResults.tsx
@@ -97,6 +97,7 @@ const MyObservationsResults = ( ) => {
const localObservationIds = useLocalObservationIds();
const sortMyObservationsEnabled = useFeatureFlag( FeatureFlag.SortMyObservationsEnabled );
+ const searchMyObservationsEnabled = useFeatureFlag( FeatureFlag.SearchMyObservationsEnabled );
const {
observationIds: queryObservationIds,
isServerAuthoritative,
@@ -104,9 +105,11 @@ const MyObservationsResults = ( ) => {
fetchNextPage: fetchNextPageFromQuery,
refetch: refetchFromQuery,
} = useMyObservationsQuery( );
- // Only use server-ordered list when the flag is on and the selected sort requires it
- const useServerOrder = sortMyObservationsEnabled && isServerAuthoritative;
- const observationIds = sortMyObservationsEnabled
+ // Only use server-ordered result when at least one of the features that needs it is enabled;
+ // when neither is, we use the plain local list anyway
+ const myObsQueryEnabled = sortMyObservationsEnabled || searchMyObservationsEnabled;
+ const useServerOrder = myObsQueryEnabled && isServerAuthoritative;
+ const observationIds = myObsQueryEnabled
? queryObservationIds
: localObservationIds;
const {
diff --git a/src/components/MyObservations/hooks/useMyObservationsQuery.ts b/src/components/MyObservations/hooks/useMyObservationsQuery.ts
index 0a1775564..fdb692a17 100644
--- a/src/components/MyObservations/hooks/useMyObservationsQuery.ts
+++ b/src/components/MyObservations/hooks/useMyObservationsQuery.ts
@@ -23,9 +23,9 @@ interface UseMyObservationsQueryResult {
}
// We want to preserve offline behavior for the default sort (created at, desc) so a user can see
-// and interact with their obs offline. This hook uses selected sort to determine whether Realm or
-// the server should be the authoritative source of a user's observations (unsynced obs
-// are always merged in at the top regardless of source).
+// and interact with their obs offline. This hook uses selected sort and/or an active taxon search
+// to determine whether Realm or the server should be the authoritative source of a user's
+// observations (unsynced obs are always merged in at the top regardless of source).
//
// Logged-out users can never have server-ordered observations, since they can't upload until
// they log in, so a non-default sort is applied to their local observations instead.
@@ -34,8 +34,9 @@ const useMyObservationsQuery = ( ): UseMyObservationsQueryResult => {
const { state } = useMyObservations( );
const currentUser = useCurrentUser( );
const isDefaultSort = state.observationsSort === OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST;
+ const hasActiveSearch = !!state.searchedTaxon;
const sortLocally = !isDefaultSort && !currentUser;
- const isServerAuthoritative = !isDefaultSort && !!currentUser;
+ const isServerAuthoritative = ( !isDefaultSort || hasActiveSearch ) && !!currentUser;
const localObservationIds = useLocalObservationIds(
sortLocally
@@ -52,6 +53,7 @@ const useMyObservationsQuery = ( ): UseMyObservationsQueryResult => {
refetch,
} = useServerOrderedObservations( {
sortBy: state.observationsSort,
+ taxonId: state.searchedTaxon?.id,
enabled: isServerAuthoritative,
} );
@@ -77,15 +79,14 @@ const useMyObservationsQuery = ( ): UseMyObservationsQueryResult => {
// dedupe in case any locally unsynced obs also exist in the server results
const observationIds = useMemo( ( ) => {
- if ( isDefaultSort || sortLocally ) return localObservationIds;
+ if ( !isServerAuthoritative ) return localObservationIds;
const unsyncedUuids = new Set( unsyncedObservationIds.map( o => o.uuid ) );
return [
...unsyncedObservationIds,
...serverObservationIds.filter( o => !unsyncedUuids.has( o.uuid ) ),
];
}, [
- isDefaultSort,
- sortLocally,
+ isServerAuthoritative,
localObservationIds,
unsyncedObservationIds,
serverObservationIds,
From 27ccb51e13142ba09d54506bc5f263aec95ff081 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Tue, 21 Jul 2026 13:00:51 -0700
Subject: [PATCH 105/108] empty search state
---
.../MyObservations/MyObservationsResults.tsx | 6 ++
.../MyObservations/MyObservationsSimple.tsx | 83 ++++++++++---------
.../Search/SearchEmptyState.tsx | 3 -
3 files changed, 51 insertions(+), 41 deletions(-)
diff --git a/src/components/MyObservations/MyObservationsResults.tsx b/src/components/MyObservations/MyObservationsResults.tsx
index 545160820..725c6bca5 100644
--- a/src/components/MyObservations/MyObservationsResults.tsx
+++ b/src/components/MyObservations/MyObservationsResults.tsx
@@ -101,6 +101,7 @@ const MyObservationsResults = ( ) => {
const {
observationIds: queryObservationIds,
isServerAuthoritative,
+ isLoading: isLoadingFromQuery,
isFetchingNextPage: isFetchingNextPageFromQuery,
fetchNextPage: fetchNextPageFromQuery,
refetch: refetchFromQuery,
@@ -112,6 +113,10 @@ const MyObservationsResults = ( ) => {
const observationIds = myObsQueryEnabled
? queryObservationIds
: localObservationIds;
+ const showSearchEmptyState = searchMyObservationsEnabled
+ && !!myObsState.searchedTaxon
+ && !isLoadingFromQuery
+ && observationIds.length === 0;
const {
numUnuploadedObservations,
numObsMissingBasics,
@@ -493,6 +498,7 @@ const MyObservationsResults = ( ) => {
setOpenSheet={setOpenSheet}
setSpeciesSortOptionId={setSpeciesSortOptionId}
showNoResults={showNoResults}
+ showSearchEmptyState={showSearchEmptyState}
speciesSortOptionId={myObsState.speciesSort}
taxa={taxa}
toggleLayout={toggleLayout}
diff --git a/src/components/MyObservations/MyObservationsSimple.tsx b/src/components/MyObservations/MyObservationsSimple.tsx
index 99210704a..c2233836b 100644
--- a/src/components/MyObservations/MyObservationsSimple.tsx
+++ b/src/components/MyObservations/MyObservationsSimple.tsx
@@ -50,6 +50,7 @@ import { ACTIVE_SHEET } from "./MyObservationsResults";
import MyObservationsSimpleHeader from "./MyObservationsSimpleHeader";
import PivotCardObsGridItem from "./PivotCardObsGridItem";
import SearchedTaxonBanner from "./Search/SearchedTaxonBanner";
+import SearchEmptyState from "./Search/SearchEmptyState";
import SimpleHeader from "./SimpleHeader";
import SimpleTaxonGridItem from "./SimpleTaxonGridItem";
@@ -80,6 +81,7 @@ interface Props {
setOpenSheet: ( value: ACTIVE_SHEET ) => void;
setSpeciesSortOptionId: ( value: SPECIES_SORT ) => void;
showNoResults: boolean;
+ showSearchEmptyState: boolean;
speciesSortOptionId: SPECIES_SORT;
taxa?: SpeciesCount[];
toggleLayout: ( ) => void;
@@ -138,6 +140,7 @@ const MyObservationsSimple = ( {
setOpenSheet,
setSpeciesSortOptionId,
showNoResults,
+ showSearchEmptyState,
speciesSortOptionId,
taxa,
toggleLayout,
@@ -414,44 +417,48 @@ const MyObservationsSimple = ( {
)}
{ activeTab === OBSERVATIONS_TAB && (
- <>
-
-
- {sortMyObservationsEnabled && (
- setOpenSheet( ACTIVE_SHEET.SORT )}
- accessibilityLabel={t( "Change-observations-sort-order" )}
- />
- )}
- >
+ showSearchEmptyState
+ ?
+ : (
+ <>
+
+
+ {sortMyObservationsEnabled && (
+ setOpenSheet( ACTIVE_SHEET.SORT )}
+ accessibilityLabel={t( "Change-observations-sort-order" )}
+ />
+ )}
+ >
+ )
) }
{ ( activeTab === TAXA_TAB && taxa.length > 0 ) && (
<>
diff --git a/src/components/MyObservations/Search/SearchEmptyState.tsx b/src/components/MyObservations/Search/SearchEmptyState.tsx
index ab01ee652..5f602f94b 100644
--- a/src/components/MyObservations/Search/SearchEmptyState.tsx
+++ b/src/components/MyObservations/Search/SearchEmptyState.tsx
@@ -1,6 +1,3 @@
-// TODO: This component is intentionally not rendered anywhere yet.
-// This is the empty state for the Search My Observations feature.
-
import {
Body1,
Button,
From a5fb725fc6e4653cebbdd49d8660e99513152ad8 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Tue, 21 Jul 2026 13:50:58 -0700
Subject: [PATCH 106/108] add offline gate to search myobs taxon screen
---
.../Search/SearchMyObservationsTaxon.tsx | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/components/MyObservations/Search/SearchMyObservationsTaxon.tsx b/src/components/MyObservations/Search/SearchMyObservationsTaxon.tsx
index 9d9b446a8..279a218e5 100644
--- a/src/components/MyObservations/Search/SearchMyObservationsTaxon.tsx
+++ b/src/components/MyObservations/Search/SearchMyObservationsTaxon.tsx
@@ -1,3 +1,4 @@
+import { useNetInfo } from "@react-native-community/netinfo";
import { useNavigation } from "@react-navigation/native";
import type { ApiTaxon } from "api/types";
import {
@@ -11,6 +12,7 @@ import {
useMyObservations,
} from "providers/MyObservationsContext";
import React, { useCallback, useState } from "react";
+import { Alert } from "react-native";
import type { RealmTaxon, RealmUser } from "realmModels/types";
import { taxonDisplayName } from "sharedHelpers/taxon";
import { useCurrentUser, useTranslation } from "sharedHooks";
@@ -22,6 +24,7 @@ const SearchMyObservationsTaxon = ( ) => {
const { state, dispatch } = useMyObservations( );
const { searchedTaxon } = state;
const currentUser = useCurrentUser( ) as RealmUser | null;
+ const { isConnected } = useNetInfo( );
const [taxonQuery, setTaxonQuery] = useState( ( ) => (
searchedTaxon
@@ -35,6 +38,13 @@ const SearchMyObservationsTaxon = ( ) => {
const onTaxonSelected = useCallback( ( newTaxon: ApiTaxon | null ) => {
if ( newTaxon && typeof newTaxon.id === "number" && newTaxon.name ) {
+ if ( currentUser && !isConnected ) {
+ Alert.alert(
+ t( "You-are-offline" ),
+ t( "Please-try-again-when-you-are-online" ),
+ );
+ return;
+ }
// useTaxonSearch can return either ApiTaxon-shaped or RealmTaxon-shaped
// taxa depending on the source, so we have to check for both here.
// TODO: normalize taxa at ingest.
@@ -56,7 +66,7 @@ const SearchMyObservationsTaxon = ( ) => {
dispatch( { type: MY_OBSERVATIONS_ACTION.CLEAR_TAXON_SEARCH } );
}
closeScreen( );
- }, [closeScreen, dispatch] );
+ }, [closeScreen, currentUser, dispatch, isConnected, t] );
const resetSearch = useCallback( ( ) => {
setTaxonQuery( "" );
From 771b94aa8897026ea88326cee215a8db96bef7b2 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Tue, 21 Jul 2026 14:15:15 -0700
Subject: [PATCH 107/108] scroll to top when the active search changes or is
cleared
---
src/components/MyObservations/MyObservationsResults.tsx | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/src/components/MyObservations/MyObservationsResults.tsx b/src/components/MyObservations/MyObservationsResults.tsx
index 725c6bca5..25b6714fd 100644
--- a/src/components/MyObservations/MyObservationsResults.tsx
+++ b/src/components/MyObservations/MyObservationsResults.tsx
@@ -284,6 +284,13 @@ const MyObservationsResults = ( ) => {
myObsOffsetToRestore,
] );
+ // Scroll to the top whenever the active taxon search changes
+ useEffect( ( ) => {
+ if ( listRef.current ) {
+ listRef.current.scrollToOffset( { offset: 0, animated: true } );
+ }
+ }, [myObsState.searchedTaxon?.id] );
+
// API call fetching obs has completed but results are not yet stored in realm
// for display here
const showLoading = ( totalResultsRemote || 0 ) > 0 && observationIds.length === 0;
From 0d024bd71e75c24400b9448a18463092f7ca9d62 Mon Sep 17 00:00:00 2001
From: Abbey Campbell
Date: Tue, 21 Jul 2026 15:12:35 -0700
Subject: [PATCH 108/108] add tests
---
.../MyObservationsSimple.test.js | 11 ++++-
.../hooks/useMyObservationsQuery.test.js | 48 +++++++++++++++++++
.../useServerOrderedObservations.test.js | 23 +++++++++
3 files changed, 81 insertions(+), 1 deletion(-)
diff --git a/tests/unit/components/MyObservations/MyObservationsSimple.test.js b/tests/unit/components/MyObservations/MyObservationsSimple.test.js
index 4949ea697..e903aef59 100644
--- a/tests/unit/components/MyObservations/MyObservationsSimple.test.js
+++ b/tests/unit/components/MyObservations/MyObservationsSimple.test.js
@@ -75,7 +75,7 @@ jest.mock( "sharedHooks/useDeviceOrientation", ( ) => ( {
default: jest.fn( () => ( DEVICE_ORIENTATION_PHONE_PORTRAIT ) ),
} ) );
-const renderMyObservations = layout => renderComponent(
+const renderMyObservations = ( layout, showSearchEmptyState = false ) => renderComponent(
renderComponent(
toggleLayout={jest.fn( )}
setShowLoginSheet={jest.fn( )}
activeTab={OBSERVATIONS_TAB}
+ showSearchEmptyState={showSearchEmptyState}
/>
,
);
@@ -131,6 +132,14 @@ describe( "MyObservationsSimple", () => {
} );
} );
+ it( "renders SearchEmptyState instead of the observations list when a search has no "
+ + "results", ( ) => {
+ renderMyObservations( "list", true );
+
+ expect( screen.getByTestId( "MyObservationsSearchEmptyState.reset" ) ).toBeTruthy( );
+ expect( screen.queryByTestId( "MyObservationsAnimatedList" ) ).toBeNull( );
+ } );
+
describe( "grid view", ( ) => {
describe( "portrait orientation", ( ) => {
describe( "on a phone", ( ) => {
diff --git a/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js b/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js
index 35a42e875..23f2e4ae5 100644
--- a/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js
+++ b/tests/unit/components/MyObservations/hooks/useMyObservationsQuery.test.js
@@ -166,6 +166,54 @@ describe( "useMyObservationsQuery", ( ) => {
] );
} );
+ it( "is server authoritative for an active taxon search even under the default sort", ( ) => {
+ const searchedTaxon = { id: 121323, name: "Reptilia" };
+ useMyObservations.mockReturnValue( {
+ state: {
+ observationsSort: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
+ searchedTaxon,
+ },
+ } );
+ const serverObs = { uuid: factory( "LocalObservation" ).uuid };
+ useServerOrderedObservations.mockReturnValue( {
+ ...defaultServerResult,
+ observationIds: [serverObs],
+ } );
+
+ const { result } = renderHook( ( ) => useMyObservationsQuery( ) );
+
+ expect( result.current.isServerAuthoritative ).toEqual( true );
+ expect( result.current.observationIds ).toEqual( [serverObs] );
+ expect( useServerOrderedObservations ).toHaveBeenCalledWith(
+ expect.objectContaining( { enabled: true, taxonId: searchedTaxon.id } ),
+ );
+ } );
+
+ it( "ignores an active taxon search when there is no current user", ( ) => {
+ useCurrentUser.mockReturnValue( null );
+ const searchedTaxon = { id: 121323, name: "Reptilia" };
+ useMyObservations.mockReturnValue( {
+ state: {
+ observationsSort: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
+ searchedTaxon,
+ },
+ } );
+ useServerOrderedObservations.mockReturnValue( {
+ ...defaultServerResult,
+ observationIds: [{ uuid: "should-be-ignored-when-logged-out" }],
+ } );
+ const localObs = factory( "LocalObservation", { needs_sync: false } );
+ createObservation( localObs );
+
+ const { result } = renderHook( ( ) => useMyObservationsQuery( ) );
+
+ expect( result.current.isServerAuthoritative ).toEqual( false );
+ expect( result.current.observationIds ).toEqual( [{ uuid: localObs.uuid }] );
+ expect( useServerOrderedObservations ).toHaveBeenCalledWith(
+ expect.objectContaining( { enabled: false } ),
+ );
+ } );
+
it( "applies the selected sort to local observations when there is no current user", ( ) => {
useCurrentUser.mockReturnValue( null );
useMyObservations.mockReturnValue( {
diff --git a/tests/unit/components/MyObservations/hooks/useServerOrderedObservations.test.js b/tests/unit/components/MyObservations/hooks/useServerOrderedObservations.test.js
index f115d3f1c..85e56557d 100644
--- a/tests/unit/components/MyObservations/hooks/useServerOrderedObservations.test.js
+++ b/tests/unit/components/MyObservations/hooks/useServerOrderedObservations.test.js
@@ -76,6 +76,29 @@ describe( "useServerOrderedObservations", ( ) => {
} ) );
} );
+ it( "includes taxon_id in API params when a taxonId is provided", ( ) => {
+ renderHook( ( ) => useServerOrderedObservations( {
+ sortBy: OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST,
+ taxonId: 121323,
+ } ) );
+
+ const [queryKey] = useAuthenticatedInfiniteQuery.mock.calls[0];
+ const [, params] = queryKey;
+ expect( params ).toEqual( expect.objectContaining( {
+ taxon_id: 121323,
+ } ) );
+ } );
+
+ it( "omits taxon_id entirely from API params when no taxonId is provided", ( ) => {
+ renderHook( ( ) => useServerOrderedObservations( {
+ sortBy: OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST,
+ } ) );
+
+ const [queryKey] = useAuthenticatedInfiniteQuery.mock.calls[0];
+ const [, params] = queryKey;
+ expect( params ).not.toHaveProperty( "taxon_id" );
+ } );
+
it( "disables the query when enabled is false or there is no current user", ( ) => {
const { rerender } = renderHook(
props => useServerOrderedObservations( props ),