95 KiB
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).
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:
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:
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:
+ (NSValueTransformer *)typeJSONTransformer {
NSDictionary *typeMappings = @{
@"collection": @(ExploreProjectTypeCollection),
@"umbrella": @(ExploreProjectTypeUmbrella),
@"": @(ExploreProjectTypeOldStyle),
};
return [NSValueTransformer mtl_valueMappingTransformerWithDictionary:typeMappings];
}
A missing/nil project_type also defaults to OldStyle:
- (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:
- (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")
typedef NS_ENUM(NSInteger, ExploreObsFieldDataType) {
ExploreObsFieldDataTypeText,
ExploreObsFieldDataTypeNumeric,
ExploreObsFieldDataTypeDate,
ExploreObsFieldDataTypeTime,
ExploreObsFieldDataTypeDateTime,
ExploreObsFieldDataTypeTaxon,
ExploreObsFieldDataTypeDna
};
API datatype string mapping:
+ (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 |:
+ (NSValueTransformer *)allowedValuesJSONTransformer {
return [MTLValueTransformer transformerWithBlock:^id(NSString *allowedValues) {
return [allowedValues componentsSeparatedByString:@"|"];
}];
}
The text-or-dna helper used everywhere:
- (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:
+ (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:
- (BOOL)joined {
return NO;
}
ExploreProjectObsField (the per-project field config carrying required and position):
@interface ExploreProjectObsField : MTLModel <MTLJSONSerializing>
@property (nonatomic, assign) BOOL required;
@property (nonatomic, assign) NSInteger position;
@property (nonatomic, assign) NSInteger projectObsFieldId;
@property (nonatomic) ExploreObsField *obsField;
@end
+ (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:
+ (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):
+ (NSDictionary *)JSONKeyPathsByPropertyKey{
return @{
@"projectObsId": @"id",
@"uuid": @"uuid",
@"project": @"project",
};
}
ExploreObsFieldValue:
+ (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):
@"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",
};
+ (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:
@interface ExploreProjectRealm : RLMObject <ProjectVisualization>
@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<ExploreProjectObsFieldRealm *><ExploreProjectObsFieldRealm> *projectObsFields;
+ (NSDictionary *)valueForMantleModel:(ExploreProject *)model;
+ (NSDictionary *)valueForCoreDataModel:(id)model;
- (NSString *)titleForTypeOfProject;
@end
+ (NSString *)primaryKey {
return @"projectId";
}
Fields are displayed sorted by position:
- (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:
@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
+ (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:
@interface ExploreObsFieldRealm : RLMObject
@property RLMArray<RLMString> *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):
@interface ExploreProjectObservationRealm : RLMObject <Uploadable>
@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
+ (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):
@interface ExploreObsFieldValueRealm : RLMObject <Uploadable>
@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
+ (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):
// to-many relationships
@property RLMArray<ExploreObservationPhotoRealm *><ExploreObservationPhotoRealm> *observationPhotos;
@property RLMArray<ExploreObservationSoundRealm *><ExploreObservationSoundRealm> *observationSounds;
@property RLMArray<ExploreCommentRealm *><ExploreCommentRealm> *comments;
@property RLMArray<ExploreIdentificationRealm *><ExploreIdentificationRealm> *identifications;
@property RLMArray<ExploreFaveRealm *><ExploreFaveRealm> *faves;
@property RLMArray<ExploreObsFieldValueRealm *><ExploreObsFieldValueRealm> *observationFieldValues;
@property RLMArray<ExploreProjectObservationRealm *><ExploreProjectObservationRealm> *projectObservations;
@property (readonly) NSArray *observationMedia;
@property NSString *validationErrorMsg;
OFV lookup by field, used by all the field UI:
- (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):
// 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:
@property RLMArray<ExploreProjectRealm *><ExploreProjectRealm> *joinedProjects;
- (BOOL)hasJoinedProjectWithId:(NSInteger)projectId;
- (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:
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):
- (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;
}
- (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:
} 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:
@protocol ProjectObservationsViewControllerDelegate <NSObject>
@optional
- (void)projectObsDelegateDeletedProjectObservation:(ExploreProjectObservationRealm *)po;
- (void)projectObsDelegateDeletedObsFieldValue:(ExploreObsFieldValueRealm *)ofv;
@end
@interface ProjectObservationsViewController : UITableViewController
@property ExploreObservationRealm *observation;
@property (weak, nonatomic) id <ProjectObservationsViewControllerDelegate> delegate;
@end
viewDidLoad reads joined projects from Realm and only re-syncs when the network is reachable (this is the offline-aware path):
- (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):
- (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:
- (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:
- (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:
@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:
- (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:
- (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):
-(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
- (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:
ActionSheetDatePickerin DateAndTime mode - time:
ActionSheetDatePickerin Time mode
3.5 Per-type input dispatch on row tap
- (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:
datefields use the date+time picker mode (UIDatePickerModeDateAndTime), same asdatetime— 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:
- (void)taxaSearchViewControllerChoseTaxon:(id <TaxonVisualization>)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:
- (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];
}
- (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:
- (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::
- (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:
- (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;
}
- (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"):
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
self.backgroundColor = [UIColor colorWithHexString:@"#f1f7e5"];
self.indentationLevel = 3;
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
self.backgroundColor = [UIColor colorWithHexString:@"#f1f7e5"];
The free-text placeholder:
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:
- (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:
- (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:
- (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:
- (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<Uploadable> *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<Uploadable> *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:
- (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:
- (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];
}
- (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; includesproject_observation_fields)POST /v1/projects/{id}/join— join (no body)DELETE /v1/projects/{id}/leave— leavePOST/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}andDELETE /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:
@protocol Uploadable <NSObject>
- (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 <Uploadable>)model;
+ (void)deleteWithoutSync:(id <Uploadable>)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:
- (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:
- (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):
- (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:
- (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:
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:
- (void)syncChildRecord:(id <Uploadable>)child ofObservation:(ExploreObservationRealm *)observation {
NSString *HTTPMethod = child.timeSynced ? @"PUT" : @"POST";
NSString *childUUID = [child uuid];
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):
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:
@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):
- (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:
/*
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:
if (self.deletedRecordsNeedingSync.count > 0) {
[self syncDeletes];
} else {
[self syncUploads];
}
}
The delete operation hits DELETE /v1/{endpointName}/{recordId} and treats 404/403 as success:
NSString *deletePath = [NSString stringWithFormat:@"/v1/%@/%ld", self.endpointName, (long)self.recordId];
} 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:
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:
/*
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:
// 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
- (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
ExploreProjectObservationRealmrecords 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
-joinbelow; 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:
- (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:
- (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:
- (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:
- (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:
- (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
viewDidLoadonly runs when reachable (section 3.2, lines 126–140). Offline it uses the cachedmeUser.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 atProjectObservationsViewController.m:155);validatedSavein 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.mcontains 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)
- Traditional = any project whose
project_typeis notcollection/umbrella(empty string or missing both map to OldStyle). - Select is not a datatype: text/dna + more than one
allowed_valuesentry; exactly one allowed value renders as free text.allowed_valuesarrives pipe-delimited. - 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". - The
dnadatatype exists and is treated as text (canBeTreatedAsText). - Client required-field validation exists but is unwired dead code — RN must implement it properly per the POD requirement.
- PO upload body is flat; OFV body is nested under
observation_field_value— asymmetric on purpose. - Strict orderings: upload photos → sounds → OFVs → POs; delete Observation → ProjectObservation → Photo → ObservationFieldValue (POs before OFVs to avoid 422s).
datefields 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).saveVisibleObservationFieldValuesonly persists visible rows — fragile for long field lists; do not replicate.- Toggling a project ON writes to Realm immediately, even for unsaved observations; removals are staged in
recordsToDeleteand committed at save. - Membership is
ExploreUserRealm.joinedProjects(a user→projects list), not a flag on the project; checks are linear scans. syncUserProjectson the Projects tab deletes allExploreProjectRealmrows before re-fetching; the chooser's variant only clears thejoinedProjectslist. Both rely on PK upsert to re-link.- 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/obsFieldValueIdafter upload. - 404/403 on a queued DELETE is treated as success so unsynced or foreign records never block the queue.