-
About the Project
The User Interface Screens
- The Portfolios Screen
- The Clubs and Museums Screen
- The Images Screen
- The Preferences Screen
- The Readme Screen
- The Who's Who Screen
- The Prelude Screen
- Role of the Database
- The Data Model
- Organization
- Photographer
- MemberPortfolio
- OrganizationType
- Language
- LocalizedRemark
- How Data is Loaded
- The Old Approach
- The New Approach
- Level 1: central list of photo clubs
- Level 2: local lists of photo club members
- Level 3: local image portfolios per club member
- When Data is Loaded
- Background Threads
- SwiftUI View Updates
- Core Data Contexts
- Comparison to SQL Transactions
The App's Architecture
This iOS app showcases photographs made by members of photography clubs. It thus serves as a permanent online gallery with selected work by these photographers.
Tha app allows viewers to see images from multiple clubs within a single app. This aims to provide a degree of uniformity, thus sparing the user from having to find a club's website, discovering where to find the photos within the site and how to browse through these portfolios. Starting in version 2 the app's name was changed to Photo Club Hub (the previous name was Photo Club Waalre) to emphasize the multi-club aspect.
To achieve this, the app fetches online lists of photo clubs, lists of club members and their curated photos. This ensures that photo clubs, club members and their portfolio's can be added or updated without requiring a change to the app software. More importantly, this also allows the clubs to manage their own data.
See the chapter on how to add a club's data as
3 distict data layers or Levels
.
The app showcases curated images made by members of photo clubs.
Photo clubs are thus the distinguishing feature of this app.
You can first look up a photo club and then find its members in the Portfolio
screen.
Or you can alternatively look up a photographer and then the associated photo club in the Who's Who
screen.
Either way, once you have chosen a photographer-and-club combination, you can view the photo portfolio of that club member.
-
photo club Clickers (hosted on
www.PhotoClubClickers.com
)- member Bill
- photos by Bill as a a member the Clickers
- member John
- photos John as a member of the Clickers
- member Bill
-
photo club Zoomers (hosted on
www.PhotoClubZoomers.com
)- member John
- photos by John as a member of the Zoomers
- member Sean
- photos by Sean as a member of the Zoomers
- member John
-
photographer Bill
- photo club Clickers (hosted on
www.PhotoClubclickers.com
)- photos by Bill as a member of the Clickers
- photo club Clickers (hosted on
-
photographer John
- photo club Clickers (hosted on
www.PhotoClubClickers.com
)- photos by John as a member of the Clickers
- photo club Zoomers (hosted on
www.photoclubzoomers.com
)- photos by John as a member of the Zoomers
- photo club Clickers (hosted on
-
photographer Sean
- photo club Zoomers (hosted on
www.PhotoClubZoomers.com
)- photos by Sean as a member of the Zoomers
- photo club Zoomers (hosted on
-
website for Bill (hosted on
www.BillIsAwesome.com
)- photo gallery with portraits
- portrait photos by Bill
- photo gallery with landscapes
- landscape photos by Bill
- photo gallery with portraits
-
website for John (hosted on
www.JohnIsAwesomeToo.com
)- photo gallery with macros
- macro photos by John
- :
- photo gallery with macros
Details (click to expand)
Here is a schematic representation of the Portfolios
screen.
This screen puts the photo club first, and then allows you to select club members and their work:
An alternative navigation path is provided by the Who's Who
screen.
This screen puts the photographer first, thus allowing you to find a photographer even if you don't
know the name of the club (or clubs) the photographer is associated with:
For comparison, traditional personal websites stress the photographer's images, without any reference to clubs:
Details (click to expand)
If a photographer joined multiple photo clubs, the app can show multiple portfolios (with independent content) for that photographer - one per photo club. This could mean that the photographer is currently a member of two different clubs. But it could also mean that a photographer left one club and joined another club. Or variations of these scenarios.
In all cases, the
portfolio
concept groups the images both by photographer and by photo club.
The app is thus not intended as a personal website replacement, but it can have links to a photographer's personal website. Furthermore, nothing prevents you from supporting an online group of photography friends - assuming that they are interested in organizing this together.
You could even consider yourself a one-person club and put your images in a single portfolio below that club. Or you could use the club level to group a few individual photographers (by region or interest) as long as the members of this non-club are willing to align (e.g. maintain the list of portfolios=photographers who are then a member of a loosely-knit club).
Usage of the various screens in the user interface:
The Portfolios
screen lists all the photo clubs featured by the app.
It allows you to first select a photo club and then select the portfolio of one of its members.
The Search
bar filters the lists of club members using the photographer's full name.
Swiping left deletes an entry, but this is not normally needed and is not permanent (yet)).
The Clubs and Museums
screen lists all photo clubs that are known to the app.
Each entry predominantly contains a map showing where the club is located and optionally your current location.
A button with a lock icon toggles whether the map is can be controlled interactively (scroll, zoom, rotate, 3D).
By default, the maps are not interactive. This mode helps scroll through the list of clubs rather than scrolling within a map.
A purple pin on the map shows where the selected club is based (e.g., a school or municipal building).
A blue pin shows the location of any other photo club that happens to be in the displayed region.
The screen can also show any photo museums that happen to be in sight. These have different markers than the photo clubs.
The plan is that the screen can switch between listing all photo clubs and listing all photo museums.
The Images
screen displays one portfolio (of one photographer associated with one club.
It can be reached by tapping on a portfolio
in either the Portfolios
or the Who's Who
screen.
The title at the top of the screen shows the selected photographer and selected club affiliation:
"Jane Doe @ Club F/8".
Images are shown in present-to-past order, based on the images's capture date. For Fotogroep Waalre, the year the image was made is shown in the caption.
You can swipe left or right to manually move backwards or forwards through the portfolio.
There is also an autoplay mode for an automatic slide show. This screen is (for Fotogroep Waalre)
currently based on a Javascript plug-in (Juicebox Pro
) that is normally used in website creation.
The Preferences
screen allows you to configure which types of portfolios you want to include in the
Portfolios
screen. You can, for example, choose whether to include former members.
The Preferences
screen probably should also filter the Who's Who
screen - but it doesn't yet.
The Readme
screen contains background information on the app and info on app usage.
The Who's Who
screen lists all the photographers known to the app.
It allows you to first select the photographer and then select that person's club-specific portfolio.
If available, club-independent information (like birthdays) for that photographer is displayed here.
The Search
bar filters on photographer names.
The Prelude
screen shows an opening animation.
Clicking outside the central image brings you to the central Portfolios
screen.
Details about Prelude screen (click to expand)
When the app launches, it shows a large version of the app’s icon. Tapping on the icon turns it into an interactive image illustrating how most digital cameras detect color.
This involves a Bayer color filter array that filters the light reaching each photocell or pixel. In a 24 MPixel camera, the image sensor typically consist of an array of 4000 by 6000 photocells. Each photocell on the chip itself is not color-sensitive. But by placing a miniscule red, green of blue color filter on cop, it because best at seeing one specific color range. Thus in most cameras, only one color is measured per pixel: the two missing color channels for that pixel are estimated using color information from surrounding pixels.
Tapping inside the image allows you to zoom in or out to your heart's content.
Tapping outside the image brings you to the central screen of the app: the Portfolios
screen.
You will see the Prelude animation after you shut down and restart the app. On wide screens (iPad, iPhone Pro Max) you can see the animation again using an extra navigation button at the top of the screen.
Why provide such a fancy opening screen? Well, it was partly a nice challenge to make (it actually runs on your device's GPU cores). But it also helps explain the app's logo: the Bayer filter array indeed consists of an array of repeated red, blue, and two green pixels.
- all clubs in the system are technically handled in the same way (although some may have provided more data)
- users can find all supported clubs on the provided maps
- a photographer is shown associated with multiple clubs if applicable (e.g., former club, current club)
- the app is stepwise being prepared for larger amounts of data (data is distributed over sites)
- the app is starting to enable that clubs can manage their own data (data "within" a club is managed by the club)
- In the
Portfolios
screen, a search scans photographers' full names. Searching onJan
might returnJan Stege
,Ariejan van Twisk
andJos Jansen
. If you need to search on club names, go to theClubs and Museums
screen. - In the
Clubs and Museums
screen, searches try to match against the organization names and towns. Searching onBer
might matchFFC Shot71 (Berlicum)
andMuseum für Fotografie (Berlin)
andThe Victoria & Albert Museum (London)
. Note that the town is the location specified in theroot.level1.json
file and not its translated version, which can be different. - In the
Who's Who
screen, searches try to match the photographer's full name. Searching onJan
might returnJan Stege
,Ariejan van Twisk
andJos Jansen
.
Version 1 of the app only supported Photo Club Waalre (known as Fotogroep Waalre in Dutch). Version 2 added support for multiple photo clubs. This means:
A new companion app Photo Club Hub HTML is underway that uses the same input data used by this app to generate (static) HTML web pages. Initial focus in the HTML app is to generate a static webpage with members and portfolio links. That page can be added to a club's existing Wordpress site.
It provides an alternative for "the rest of us" who don't use an iPhone or iPad. But also for anyone who want to view the information or photos on a laptop or desktop computer.
The three screens with long lists (Portfolios
, Clubs and Museums
, Who's Who
) each have a Search Bar
where you can enter what you are looking for. This reduces the list to items that match that filter criterion.
Details on the Search Bar(click to expand)
On an iPad, the search bar is always visible and at the top of the screen. On an iPhone, scroll up rapidly until you hit the top of the list.
The text you type inside the search bar is matched against key fields for the records shown in the list.
Design detail: Search Bar filtering is done in the app's user interface and not by the CoreData database.
Details on Keyword Standardization (click to expand)
Standardization also helps in displaying the texts in multiple languages: the translations are only defined
in one local (Level 0
). This means that if the Sierra Club associates their member Ansel Adams with "Black & White"
they automatically get translations of "Black & White".
If the app is thus set to Dutch, the user then sees "Zwart-Wit" rather than "Black & White".
Information on how to define keywords per photographer and how to define standardized keywords can be found in
the explanations about adding Level 2
and Level 0
data respectively.
Details on Museums (click to expand)
A photo museum is not a photo club and is displayed on the maps using a dedicated marker. Techncially, the app doesn't allow museums to have "members" that share images with the museum.
Consider the showing of museums a bonus that may interest some users. You are welcome to add a favorite photo museum via a GitHub Pull Request. It only requires extending a JSON file. The file format is documented below under How Data is Loaded.
The top of the Portfolios
screen as well as two other screens can be dragged down to force a refresh of all app data.
Refreshing is usually not needed, but can be used to remove database records
that were downloaded earlier but are no longer in use.
The feature is a fast alternative to uninstalling and reinstalling the app:
the database gets cleared and the missing data immediately gets downloaded again.
Details on Data Synchronization (click to expand)
Whenever the app is launched, it fetches fresh information from online servers. The use of online data ensures that the app stays up-to-date with respect to the online lists with clubs and museums (`Level 1`), club members (`Level 2`) and portfolio photos (`Level 3`).This fresh online data is merged with an on-device data (CoreData
) consisting of a persistent copy of the data
received during earlier app runs. This data merging thus updates the on-device database.
The app's user interface is immediately updated whenever the database is updated.
A problem could occur when a club (or museum or member) is deleted in the online version of the information.
Let's say, for example, that the club's identifying name or town got edited in the Level
1 list.
This means that the online list now contains a "different" club: one with another identifying name/town combination.
Unfortunately the app has now way of knowing that this "new" club is actually the same as the club that seemingly vanished.
So, from the app's perspective, the original club vanished and a new club appeared as two separate events.
The new club simply need to be loaded. But the app currently doesn't detect the disappearance of the orignal club.
There is a GitHub ticker to add future automatic detection of "disappeared" or renamed clubs.
A temporary workaround is to use the pull down to refresh feature: it simply deletes the entire content of the database and then reloads. This fully synchronizes the device's internal (CoreData) database with the online data. Alternatively, the user could delete the app and reinstall it.
Another application of pull down to refresh is to force a reload of online data when you know that the online data just changed.
The Clubs and Museums and Who's Who screens try to prevent showing partial maps or partial photographer info.
Details about smart scrolling
Apple introduced ScrollTargetBehavior
for SwiftUI's ScrollViews
in iOS 17.
It allows the app to control where on the page a scroll motion comes to a stop,
thus avoiding the display of a list section with some of the top information not visible.
In iOS 17 this feature assumed that the screen consists of a list of items of equal height. Because iOS 18 can apparently handle list items with varying height, the app's fancy scrolling content looks slightly better under iOS 18.
Unfortunately, the Portfolios screen doesn't provide this fancy scrolling feature
because it relies on a "segmented List" view rather than on ScrollView
.
So we had to choose between removing segmentation or not providing fancy scrolling for the Portfolios
screen.
The app is designed so that the required information about photo clubs is provided and maintained by the clubs themselves.
This is important because this allows the app to support many clubs. But is also also necessary in order to give clubs control over their data: a club knows best what to mention regarding the club, who the current members are, who the club officials are, and which images the members want in their portfolios.
Level 1
consists of the club’s name and geographic location.Level 2
lists the members per club.Level 3
links to portfolios per club member.Level 0
holds some basic configuration data.keywords
lists the keywords that can be linked to one or morephotographers
to describe their main genres. Keywords apply to the photographer in general, and not to the photographer's membership of a particular club. If multiple clubs assign keywords to the same photographer, the lists are automatically merged ("union").idString
only serves to identify a keyword. Preferably use the English version of the keyword - but a text like "Keyword #123" will also work.localizations
is a list of translations of the keyword into one or more languages.language
contains the isoCode (typically 2 letters) for the language. Use the 3 letter code only for uncommon languages for which no 2 letter code exists. The codes must match the standard ISO 639 list. It is important to use the correct values, becauseisoCode
is compared to the preference codes provided by iOS. Example: "DE" is "German".localizedString
contains the translation of the keyword into the indicated language. If possible provide translations for all languages the app supports (EN and NL). Additional translations are fine, and will be used where appropriate.
languages
lists the language codes used for localized photographerkeywords
and organizationremarks
.isoCode
is the 2 (ISO 639-1) or 3 letter (ISO 639-2) language code for the language. Use the 2 letter codes when available.languageNameEN
is the name of the language in English. Example: "Chinese". So this can tell you that ZH represents Chinese. We expect that we can translate the language's name to other languages programmatically, should that be necessary.
usage
(within akeyword
) is a description of what is meant by the keyword. We couldn't call it a description because that is a reserved name in Swift.clubs
andmuseums
are required to distinguish photo clubs from photo museums.- Syntactially either can be omitted, but then you wouldn't have photo clubs or museums in the app.
- When loaded into the app's internal database,
clubs
andmuseums
determine theOrganizationType
(club
ormuseum
) of eachOrganization
object. This in turn determines which marker type is shown on maps.
- When loaded into the app's internal database,
- Syntactially either can be omitted, but then you wouldn't have photo clubs or museums in the app.
town
can be a city (London) or smaller locality (Land's End)town
is not directy visible in the user interface, although it may look that way. The user interface displays a language-localized name generated using thecoordinates
. This co-calledlocalizedTown
may contain the same string astown
, or be a translation oftown
, or may even hold larger or smaller administrative entity. The file'stown
field is used to ensure that there is a unique ID for a club or museum. SoFotoclub Lucifer
inVessem
would be considered unrelated to aFotoclub Lucifer
inEersel
. Thetown
field also serves to document the record in theroot.level1.json
file: it is clearer to say "Victoria and Albert Museum (London)" than to say just "Victoria and Albert Museum" and leave you to determine the location using itscoordinates
.- Similarly, the user interface can display a computed
localizedCountry
name that is automatically generated using the providedcoordinates
. The Level 1 data thus does not need or include acountry
attribute. This is convenient because country names commonly get translated into local languages (Italia
,Italy
,İtalya
, etc.).
- Similarly, the user interface can display a computed
town
andfullName
together serve to identify clubs or museums.- It is thus possible to have two clubs with the same name in different cities. Two separate clubs or museums with an identical name in the same town would be confusing, and would be treated as a single entity by the app until the data about the original
town
/fullname
combination is removed from the in-device database. Removal of obsoleteOrganization
records is unfortunately not done automatically yet. - Try to avoid changing either string. Because the app doesn't associate the new ID with the original ID, this results in the app displaying two different clubs or museums when the
- It is thus possible to have two clubs with the same name in different cities. Two separate clubs or museums with an identical name in the same town would be confusing, and would be treated as a single entity by the app until the data about the original
nickName
is a short version offullName
that is displayed in confined spaces such as on maps. It can be any string, but please keep it short.coordinates
is used to draw the club on the map and to generate localized versions of the names of towns and countries.latitude
should be in the range [-90.0, +90.0] where negative values are used for the Sounthern hemisphere (e.g., Australia).longitude
should be in the range [-180.0, +180.0] where negative values are used for the Western hemisphere (e.g., USA).website
holds a URL to the club's general purpose website. It can be opened by the app in a separate browser window.level2URL
(for clubs only) holds the address of theLevel 2
membership list. It is not used yet (April 2024).wikipedia
contains a URL to a Wikipedia page for a museum. It could be used for photo clubs - but Wikipedia pages for photo clubs probably don't/won't exist.remark
contains a brief note with something worth mentioning about the club or museum. Theremark
contains an array of alternative strings in multiple languages. The app chooses one of the provided languages to display based on the device's language setting.language
is the two or three letter ISO-639 code for a language.EN
is English,FI
is Finnish.- If the device's preferred language doesn't match any of the provided languages, the app will default to EN, if available. If EN is unavailable, it will select one of the available languages.
value
is the text to display for that particular remark in that language.
nlSpecific
is a container for fields that are only relevant for clubs in the Netherlands.fotobondNumber
is an ID number assigned by the Dutch national federation of photo clubs.- There used to be a
kvkNumber
as well (Chamber of Commerce) but that was removed because it wasn't worth the effort.
club
has the same structure as a singleclub
record from theroot.level1.json
file. It serves to label theLevel2
file so you can tell which club it belongs to.idPlus
and its 3 fields (town
,fullName
, andnickName
) are all required.town
andfullName
must exactly match the corresponding fields in theroot.level1.json
file.
members
is a list with the club's current and optionally former members.- Optional fields (that are ignored)
- the
level2URL
field can be included, but it's value does not overrule thelevel2URL
value inroot.level1.json
for safety reasons.
- the
- Optional fields (that are used)
- a club's
wikipedia
,fotobondNumber
,coordinates
,website
, andlocalizedRemarks
fields overrule the correspondingroot.level1.json
fields if needed. This allows a club to correct the centrally-provided information with club-provided information. Note that not all these optional fields are shown in the example: see the Level 1 documentation for more details. givenName
,infixName
andfamilyName
are used to uniquely identify the photographer.infixName
will often be empty. It enables correctly sorting European surnames: "van Aalst" sorts like "Aalst" in the Who's Who screen.- An omitted "infixName" is interpreted as "infixName" = "".
- the
level3URL
field allows the app to find the Level 3 information with the selected images for this member. - the
roles
field indicates whether a member fullfills a role as a club officer (e.g. chairman). If a givenrole
is not mentioned, a default value offalse
is assumed. Manymembers
have an empty or even absentroles
section. Somemembers
may have multiple roles (e.g.,secretary
andadmin
). - the
status
entries indicate a member's status in the club. If a givenstatus
is not mentioned, a default value offalse
is assumed. Manymembers
have an empty or even absentstatus
section. Somemembers
may have multiple special statuses (e.g.,former
andhonorary
). isFormerMember
can be set to true if the person left the club and the club wants to keep that member's Portfolio visible. The user interface will stateformer member
where applicable. By default (see Preferences) former members are not shown. When shown, users see "Former member of ". The user interface can generate text for more complex cases like "Former honorary member of ".isDeceased
is a special variant ofisFormerMember
. If deceased members are not removed from the level2.json list, this allows the user interface to indicate this. By default (see Preferences) former and deceased members are not shown. When shown, users see "Deceased, former member of " and the text is shown in a different color.isHonaryMember
can be used if the person is no longer an active member, but is still treated as a member (e.g., after retiring) because of past achievements. Most clubs won't need this feature.isMentor
is for coaches who coach or previously (isFormerMember
totrue
) coached the club. They can have a Portfolio (e.g. with pictures of them or pictures of their own work).isProspectiveMember
is a possible future member who is currently participating in some of the club activities, but isn't formally a member yet. Most clubs won't need this feature.birthday
can be the full date of birth but currently only the month and date are shown in the user interface. So you can provide a dummy year (like9999
) if that is preferred.website
is a personal photography-related website. If the website URL is available, the app provides a link to it.photographerImage
is a depiction of the photographer. A special mode may be added whereby this photo or avatar of the photographer is shown instead of the featured image made by the photographer.featuredImage
is a URL to a single image that can be shown beside the member's name. It is visible in thePortfolios
screen and theWho's Who
screen.level3URL
is URL to a file containing the selected portfolio images made by this particular member in the context of a given photo club.keywords
is a list of keywords describing what the photographer is mainly known for. The list of strings atLevel 2
is used to define the applicable keywords. It doesn't define how they are displayed because multi-language versions of keywords are defined inLevel 0
. Example: the input identifier "Landscape" that can be associated with a given photographer in theLevel 2
file can be translated (using Level 0 data) to "Landschaft" in German or "Landscape" in "English". The identifier should preferably match the English translation for practical reasons but, technically speaking, doesn't need be. Question: so what happens if a keyword string in aLevel2.json
file does not occur in the list of keywords in theLevel0.json
file? Answer: it is shown as an error (gray?) in the user interface, thus prompting the user to fix the issue. The keyword reverts to the standard color as soon as that problem is resolved. Rationale: you might enter a typo ("Landscapes" instead of "Landscape"). But it also signals a "non-standard" term (e.g., using "Scenery" or "Desert" instead of "Landscape").
- a club's
To add a club to the app, the app distinguishes 3 hierarchical layers of information:
We call these layers of information Levels
because, like a game, you can only reach a level after you completed all lower levels.
And - again like a game - reaching a particular level "unlocks" extra app functionality for that club.
A club can take as long as they want (days, weeks, months) before proceeding to the next level.
This means that an app user may find clubs at different Levels
throughout the app.
To the user this simply means that some clubs have shared more information than others.
Museums are handled in pretty much the same way as photo clubs, but only Level 1
is applicable for museums.
So you won't find members of a museum or portfolios associated with these members.
Internally the app actually supports one extra level of data:
Users and clubs don't normally need to worry about this layer. Level 0
data is shared across clubs and thus managed centrally.
Its main content is a list of standardized keywords for photographers, plus translations of these keywords into supported languages.
When a club is at Level 1
, it shows up as a marker on the maps (leftmost screenshot).
This is because the app knows the club's name and the latitude/longitude where it is based.
For clubs at Level 2
, the app also knows the names and optional roles of club members.
As illustrated in the center screenshot, the club and its members are now shown on the Portfolio
and Who's Who
screens.
Clubs with zero members (as far as the app is concerned) are not shown on either screens.
For clubs at Level 3
, the app is aware of the image portfolios of club members (rightmost screenshot),
and allows app users to browse member photos.
Technically different club members don't need to reach Level 3
at the same time: you can first add a test portfolio for
one member, then expand to all members, and later add some recent former members if you want.
Attempting to view a portfolio of a club member without an available portfolio will display a red built-in placeholder image.
Level 0
contains standardized keywords, standardized languages, and the translations of keywords into these languages.
You may want to skip reading about Level 0
on a first reading, as it only describes supporting data rather than key app data.
Level 0 example (click to expand)
{
"keywords": [
{
"idString": "Landscape",
"localizations": [
{ "language": "EN", "localizedString": "Landscape" },
{ "language": "NL", "localizedString": "Landschap" },
{ "language": "AR", "localizedString": "منظر جمالي" }
]
},
{
"idString": "Black & White",
"localizations": [
{ "language": "EN", "localizedString": "Black & White" },
{ "language": "NL", "localizedString": "Zwart-wit" }
]
"optional": {
"usage": "Grayscale or monochrome images"
}
},
],
"languages": [
{
"isoCode": "EN",
"languageNameEN": "English"
},
{
"isoCode": "NL",
"languageNameEN": "Dutch"
},
{
"isoCode": "AR",
"languageNameEN": "Arabic"
}
]
}
Mandatory Level 0 fields (click to expand)
Optional Level 0 fields (click to expand)
Adding photo clubs (or museums) to get to Level 1
requires providing a name, location and a few optional URLs.
This enables the app to list the items on the Clubs and Museums
screen and display them using location markers on the maps.
Level 1
data is technically stored in a single root.level1.json
file
that is centrally hosted.
Whenever the app is launched, it updates any outdated on-device data by reading this central online file.
If you send us a club's Level 1
information, we are happy to add it for you to this central root.level1.json
file.
However, where possible, we prefer if you provide the change (and any future updates) as a GitHub pull request.
This reduces the work required to handle many updates, and reduces the risk of administrative errors.
The same applies if you want to add a photo museum to the central root.level1.json
file.
Level 1 example (click to expand)
Here is an example of the format of the root.level1.json
file containing only one photo club and one photo museum.
{
"clubs": [
{
"idPlus": {
"town": "Eindhoven",
"fullName": "Fotogroep de Gender",
"nickName": "fgDeGender"
},
"coordinates": {
"latitude": 51.42398,
"longitude": 5.45010
},
"optional": {
"website": "https://www.fcdegender.nl",
"level2URL": "https://www.example.com/fgDeGender.level2.json",
"remark": [
{ "language": "NL", "value": "Opgelet: Fotogroep de Gender gebruikt als domeinnaam nog altijd fcdegender.nl (van Fotoclub)." }
],
"nlSpecific": {
"fotobondNumber": 1620
}
}
}
],
"museums": [
{
"idPlus": {
"town": "New York",
"fullName": "Fotografiska New York",
"nickName": "Fotografiska NYC"
},
"coordinates": {
"latitude": 40.739278,
"longitude": -73.986722
},
"optional": {
"website": "https://www.fotografiska.com/nyc/",
"wikipedia": "https://en.wikipedia.org/wiki/Fotografiska_New_York",
"remark": [
{ "language": "EN", "value": "Fotografiska New York is a branch of the Swedish Fotografiska museum." },
{ "language": "NL", "value": "Fotografiska New York is een dependance van het Fotografiska museum in Stockholm." }
]
}
}
]
}
The actual root.level1.json
file contains many club and museum records within their respective sections (delimited using [{},{},{}}]
syntax).
Note the comma's delimiting the array elements - the JSON data format is very picky about missing or extra comma's
because JSON files are often generated by software rather than by hand.
You can indentally check the basic syntax of JSON files using online JSON validators such as
JSONLint.
Mandatory Level 1 fields (click to expand)
Optional Level 1 fields (click to expand)
Level 2
support requires providing a list of the members as a file per club.
A club's Level 2
data shows up in the Portfolios screen as a list of club members per club.
Each Level 2
JSON file lists the current (and optionally former) members of a single club.
For each member, a URL is stored pointing to the Level 3
file (portfolio per member).
Level 2
lists also includes the URL of an image used as thumbnail for that member.
Fotogroep de Gender and Fotogroep Waalre in the Netherlands have .level2.json
files with membership data.
Storing Level 2 data (click to expand)
A Level 2
file needs to be in a JSON format so that the app can interpret the data.
You can check the basic syntax of JSON files using online JSON validators such as
JSONLint.
A Level 2
file can be located anywhere online, but is by default stored on the a club's existing website.
You could, for example, store it inside an existing Wordpress site using
Wordpress' built-in features for uploading files (called media
or library
).
The files are downloaded in background after app startup using a URL address found within the central Level 1
file.
The Level 2
data is loaded in the app's CoreData database on app startup, so that the data is can be displayed even before the
data has been downloaded and the database content has been updated.
In the future, once there are hundreds or more Level 2
files available, the app will need to become selective about
which clubs to preload and how often to refresh the Level2
data.
Level 2 example (click to expand)
Here is an example of the format of a Level 2
list for a photo club. This example contains only one member:
{
"club":
{
"idPlus": {
"town": "Eindhoven",
"fullName": "Fotogroep de Gender",
"nickName": "FG deGender"
},
"optional": {
"coordinates": {
"latitude": 51.42398,
"longitude": 5.45010
},
"website": "https://www.fcdegender.nl",
"wikipedia": "https://nl.wikipedia.org/wiki/Gender_(beek)",
"level2URL": "https://www.example.com/deGender.level2.json",
"remark": [
{
"language": "EN",
"value": "Note that Fotogroep de Gender is abbreviated fcdegender.nl for historical reasons."
}
],
"nlSpecific": {
"fotobondNumber": 1620
}
}
},
"members": [
{
"name": {
"givenName": "Peter",
"infixName": "van den",
"familyName": "Hamer"
},
"optional": {
"roles": {
"isChairman": false,
"isViceChairman": false,
"isTreasurer": false,
"isSecretary": false,
"isAdmin": true
},
"status": {
"isDeceased": false,
"isFormerMember": false,
"isHonoraryMember": false,
"isMentor": false,
"isPropectiveMember": false
},
"birthday": "9999-10-18",
"website": "https://glass.photo/vdhamer",
"photographerImage": "http://www.vdhamer.com/wp-content/uploads/2022/07/cropped-2006_Norway_276_SSharp1_4.jpg",
"featuredImage": "http://www.vdhamer.com/wp-content/uploads/2023/11/PeterVanDenHamer.jpg",
"level3URL": "https://www.example.com/FG_deGender/Peter_van_den_Hamer.level3.json",
"keywords": [ "Landscape", "Travel", "Minimal" ]
}
}
]
}
Mandatory Level 2 fields (click to expand)
Optional Level 2 fields (click to expand)
Note that the
birthday
,website
,isDeceased
,photographerImage
, andkeywords
fields are technically special because they describe the photographer - and not the photographer in the context of a particular club membership. Usually this doesn't matter, but it can show up if the photographer is associated with multiple clubs, each with its own level2.json file (for example, a former photo club and the current photo club). Conceivably these multiple files may not agree on the value ofbirthday
,website
orisDeceased
. The app currently will use the last answer it encountered. This problem should occur infrequently, but a workaround is to only fill in these fields in one of the level2.json files. In the future, we could add rules to determine what to do if there are multiple different values for these fields (e.g. membership trumps former membership). If multiple clubs supplykeywords
for the same photographer, the lists are automatically merged for use by the app. The respectiveLevel 2
data files are left as is. Example: John is a member of Club A (keywords K1 and K2) and Club B (keywords K2 and K3) and Club C (no keywords provided). Result: all three clubs show keywords K1, K2 and K3.
Level 3
provides links to the online images in member portfolios.
Fotogroep Waalre in the Netherlands is an example of Level 3
club: you can view their portfolios via the Portfolios screen.
Because a club with, for example, 20 members will have hundreds of images, we have a way to automate generate portfolios
using Lightroom (instructions on how this works will be provided later).
Level 3 example (click to expand)
To doMandatory Level 3 fields (click to expand)
To doOptional Level 3 fields (click to expand)
To doIf you simly want to install the binary version of the app, just install it from Apple's app store (link).
Details (click to expand)
- the Swift programming language
- Apple's SwiftUI user interface framework
- Apple's Core Data framework for persistent storage ("database")
- Adobe Lightroom Classic maintaining the portfolios (so far Fotogroep Waalre only)
- a low cost JuiceBox Pro JavaScript plugin for exporting from Adobe Lightroom (so far Fotogroep Waalre only)
- GitHub's SwiftyJSON package for accessing JSON content via paths (dictionaries that recursively contain dictionaries)
To install the source code locally, it is easiest to use GitHub’s Open with Xcode
feature.
Details (click to expand)
Developers who are comfortable running git
from the command line should manage on their own.
Xcode covers the installation of the binary on a physical device or on an Xcode iPhone/iPad simulator.
Details (click to expand)
During the build you may be prompted to provide a developer license (personal or commercial) when you want to install the app on a physical device. This is a standard Apple iOS policy rather than something specific to this app.
Starting with iOS 16.0 you will also need to configure physical devices to allow them to run apps
that have not been distributed via the Apple App Store. This configuration requires enabling
Developer Mode
on the device using Settings
> Privacy & Security
> Developer Mode
.
Again, this is a standard Apple iOS policy. This doesn't apply to MacOS.
If you update to a newer build of the app, all app data stored in the device's internal data storage will remain available.
Details (click to expand)
If you instead choose to remove and reinstall the app, the locally stored database content will be lost. This is how iOS works. Fortunately, this has no real implications for the user as the data storage doesn't presently contain any relevant user data. So essentially you can regard the database as a cache: it lets the app launch quicker without waiting for content that has alread been fetched during a previous session.
Details (click to expand)
If the data structure has changed from one version to a later version, Core Data will automatically perform a so-called schema migration. If you remove and reinstall the app, the Core Data database is lost, but this isn't an issue as the database so far doesn't contain any user data. Schema migration is a standard feature of Apple's Core Data framework, although the app does its bit so that Core Data can track, for example, renamed struct types or renamed properties.
Bug fixes and new features are welcome.
Before investing effort in designing, coding testing, and refining features, it is best to first describe
the idea or functional change within a new or existing GitHub Issue
.
That allows for some upfront discussion and prevents wasted effort due to overlapping initiatives.
You can submit an Issue
with a tag like ”enhancement" or “bug” without commiting to make the code changes yourself.
Essentially that is an idea, bug, or feature request, rather than an offer to help.
Details(click to expand)
Possible contributions include adding features, code improvements, ideas on architecture and interface specifications, and possibly even a dedicated backend server.
Details(click to expand)
Contributions that do not involve coding include beta testing, thoughtful and detailed feature requests, translations, and icon design improvements.
The app uses a SwiftUI-based MVVM architecture pattern.
- the
model
's data is stored in lightweight structs rather than in classes. It also implies that any changes to the model's data automatically trigger the required updates to - the SwiftUI's struct-based
Views
, while - the intermediate class-based
ViewModel
layer translates between theModel
andView
layers. - Model contains the data model. It contains the current version of the database model as well as older versions as separate files. This form of versioning is un-Git-like and is still used to support install-time schema migration.
- View only
contains SwiftUI views, which are at the Swift level structs that adhere to SwiftUI's View
protocol
. - ViewModel includes the code that populates and updates the database content ("model"). This layer is currently implemented per photo club, and stored a subdirectory per club.
Details (click to expand)
The use of a SwiftUI-based MVVM architecture implies that
Each of the layers has its own directory (found at the linked locations):
- the lists of images per portfolio are not stored in the database yet. These images are also not cached. Image caching is a roadmap item.
- the image thumbnail per portfolio is stored in the database as a URL. The actual file is not stored locally or cached yet. Thumbnail caching is a roadmap item.
- members who are removed from the online membership list are not automatically deleted from the database. This requires a bit more administration, because these individuals don't show up iterates through the online membership list. This is simply because those names/records are not on the online list anymore!
- in the case of Fotogoep Waalre, some member data is not yet available online in a machine-readable form and is thus added programmatically instead. This is done in this file. This hardcoded data include the member's formal roles (e.g. chairman, treasurer).
- Photo club data is minimal (name, town/country, GPS, website), but is currently still hardcoded.
Details (click to expand)
The model's data is loaded and updated via the internet, and is stored in an on-device database. Internally the database is SQLite, but that is invisible because it is wrapped inside Apple's Core Data framework.
Because the data in the app's local database is available online, the app could have chosen to fetch that data over the network each time the app is launched. By using a database, however, the app launches faster: on startup, the app can already display the content of the on-device database.
This implies showing the state of the data as it was at the end of the previous session. That data might be a bit outdated, but should be accurate enough to start off with.
To handle any data updates, asynchrous calls fetch fresher data over the network.
And the MVVM architecture uses this to update the user interface Views
as soon as the requested data arrives.
So occasionally, maybe one or two seconds after the app launches, the user may see the Portfolios
screen update.
This can happen, for example if a club's online member list changed since the previous session.
To be precise, the above is the target architecture. Right now there are still a few gaps - but because it usually works well enough, a user typically won't notice:
Some of these gaps are addressed below.
- max one
localizedRemark
attached to anorganization
(club, museum) and - multiple
localizedKeyword
s attached (indirectly viaKeyword
) to aphotographer
.#### LocalizedRemark
Data Model Entities (click to expand)
The diagram shows the entities managed by the app's internal Core Data database. The entities (rounded boxes) are tables and arrows are relationships in the underlying SQLite database.
Note that the tables are fully "normalized" in the relational database sense. This means that redundancy in all stored data is minimized via referencing.
Optional properties in the database with names like Organization.town_
have a corresponding computed
property that is non-optional named Organization.town
. This allows Organization.town
to always return
a value such as "Unknown town" instead of nil
.
Details (click to expand)
Organization
supports both photo clubs and museums. Almost all properties apply to both.
The relationship to OrganizationType
is used to distinguish between clubs and museums.
We could conceivably add photography festivals
as well.
An Organization s uniquely identified by its name
and its town
.
Its town
string is part of the identification ("uniqueness constraint" in an rDBMS) to distinguish photo clubs in different towns that happen to have the same name.
An Organization
has a rough address (town
) and latitude_
and longitude_
(together coordinates
).
The coordinates are not considered optional, but they could be missing in the JSON data. You will find the stray map pin in the ocean off Africa (at coordinates (0,0)).
The coordinates for a club indicate where the club meets or holds expositions (we don't distinguish, they tend to be identical or near each other).
The coordinates are used to position markers on the maps.
The coordinates are also used to translate town
names to localizedTown_
and localizedCountry_
.
This works by asking an online mapping service to convert the coordinates
into a textual address (using the device settings).
So if your device is set to English, you might see "The Hague" and "London", while the Dutch would see "Den Haag" and "Londen".
In fact, if your device is set to any language supported by the device (say Japanese) and you are looking at a Japanese location, Town and Country will be shown in Japanese.
Details (click to expand)
Some basic information about a Photographer
(name, date of birth, personal website, ...) is
related to the Photographer
as an individual, rather to the Photographer's
membership of any
specific PhotoClub
. This club-independent information is stored in the individual's Photographer
struct/record.
Details (click to expand)
Every PhotoClub
has (zero or more) Members
who can have various roles (isChairman
, isAdmin
, ...)
representing the tasks they perform in the photo club. A Member
may have multiple roles within one
PhotoClub
(e.g., members is both isSecretary
and isAdmin
).
Members also have a status, the implicit default being isCurrent
membership.
Explicit status values include isFormer
, isAspiring
, isHonorary
and isMentor
.
Portfolio
represents the work of one Photographer
in the context of one PhotoClub
.
A Portfolio
contains Images
(the list is not stored in the database yet).
An Image
can show up in multiple Portfolios
if the Photographer
presented the same photo within
multiple PhotoClubs
.
Member
and Portfolio
can be considered synonyms from a modeling perspective:
we create exactly one Portfolio
for each PhotoClub
that a Photographer
became a Member
of.
And every Member
of a PhotoClub
has exactly one Portfolio
- even if it still contains zero images -
because this is needed to store information about this membership.
This one-to-one relationship between Member
and Portfolio
allows them to be
modelled using once concept (aka table) instead of two. We named that MemberPortfolio
.
Details (click to expand)
This is a tiny table used to hold the supported types of Organization
records.
It could be used someday to drive a picker in a data editing tool.
For now, it ensures that each Organization
belong to exactly one of the supported OrganizationTypes
.
And it could be used to generate statistics about how man Organizations
per OrganizationType
are supported.
Details (click to expand)
The Language
table is a small utility table to support multiple languages used in the app's data.
It's properties hold the ISO 2 or 3-letter code of the language and a readable name.
The set of languages used in the app's data can differ from the set of languages supported by the app's user interface code: Localization of code is enabled at build-time using mechanisms provided in Xcode. Localization of database strings is handled at run-time using mechanisms defined in the app's code.
So by storing translations in the database, the set of supported Languages
used in the data is open-ended.
For example, a museum in Portugal may have a descriptive remark in both English and Portuguese,
even when the app's user interface currently has no support for Portuguese.
This allows the app to display Portuguese text for the local museum whenever the user set Portuguese as the preferred language
while the app's user interface will be displayed in English.
Currently there are two features in the app that display Strings from the database and thus require localization support:
Details (click to expand)
The LocalizedRemark
table holds short descriptions about an Organization
in zero or more Languages
.
Remarks are optional, but we recommend providing them.
An Organization
record can be linked to 0, 1, 2 or more Languages
regardless of whether the app fully supports that language.
The actual text shown in the user interface is provided in the LocalizedRemark
table.
Details (click to expand)
The Keyword
table holds predefined strings that can used as tags for Photographers
. Examples: black and white
, landscape
, portrait
.
Like Xcodes string catalogs, the item has a string identifier which can then be translated for every supported Language
.
At the moment, it hasn't been decided yet how the content of the Keyboard
table is submitted.
This is related to error checking of the PhotographerKeyword
table (see description there).
The data can come from an extra section in the root.level1.json
file or a new level0.json
file containing reference data.
Details (click to expand)
The LocalizedKeyword
table holds the strings representing Keywords
in any specific Language
.
Details (click to expand)
The PhotographerKeyword
table links a standardize (reusable) Keyword
to a Photographer
.
It is thus a many-to-many relationship without any additional attributes.
Note that Keyword
s per Photographer
are provided per Club (Level2.json
) but are stored at the Photographer
level.
Example: John is or was a member of both ClubA and ClubB.
This means there are two independent Level2.json
files providing information about John which can hold different sets of Keywords
.
The app will store the union of both sets in the PhotographerKeyword
table.
Do we allow a Level2.json
file to define new keywords for the Keyword
table rather than just allow the
file to link Photographers
to pre-existing Keywords
? To prevent polution of the Keyword
and PhotographerKeyword
tables,
we decided to only accept keywords that already exist in the Keywords
table.
This means a typo in a keyword identifier in the Level2.json
file (e.g. "landscape" vs "landscapes") means the entry is ignored.
It also means that a deliberately new keyword encountered in the Level2.json
file is ignored, until it gets added to the Keyboard
table.
While being deliberately restrictive, this is quite powerful: a club may list a new keyword (PhotographerKeyword
) to a Photographer
.
It will be in the Level2.json
file, but won't get loaded by the app. The club can then lobby to get this keyword accepted. When accepted
and added to the Keyword
table, the ignored entry will immediately be used.
Actually it might be nice to someday have a feature (same app? separate app?) that simultaneously detects typos ("landscap")
in Level 1 keyword references and suggest wanted extensions to the Keywords
list ("astrophotography").
- Level 1: central list of photo clubs
- Level 2: local lists of photo club members
- Level 3: local image portfolios per club member
Details (click to expand)
Details (click to expand)
The app currently uses a software module per club. This means a club can
concievably store their online list of members (MemberPortfolios
) in any format.
It is then up to the software module to convert it to the app's internal data representation.
Similarly, a club could store their list of Images
per member (MemberPortfolio
) in any
conceivable format as long as the software module does the conversion.
Thus, the software module per photo club loads membership and portfolio data across the network. The data will likely be stored on the club’s website somewhere, presumable in a simple file format. That data is then loaded into the in-app database, but also used to updated the database. This updating is done (on a background thread) whenever the app lauches, and thus takes care of changed membership lists as well as changed image portfolios.
For Photo Club Waalre, the membership list is read from an HTML table on a page on the club’s website. HTML is messy to parse, but also serves as a web page for the club's website.
In the case of Photo Club Waalre, the membership list is password protected in Wordpress and the app bypasses that password using a long key and the Wordpress Post Password Token plugin. The GitHub version uses a (redacted) copy of the membership list in order to show real data. Details about these details can be found above.
The image lists or portfolios
use a more robust and easier to maintain approach:
for Photo Club Waalre, portfolios are read from XML files generated by an Adobe Lightroom
Web plug-in called JuiceBox-Pro.
Thus portfolios are created and maintained within a Lightroom Classic catalog as a set of
Lightroom collections. A portfolio can be uploaded or updated to the webserver using the Upload (ftp) button
of Lightroom's Web module. This triggers JuiceBox-Pro to generate an XML index file for the portfolio
and to upload the actual images to the server. All required settings (e.g. copyright,
choice of directory) only need to be configured once per portfolio (=member).
Details (click to expand)
A major design goal for the near future is to provide a clean, standardized interface to retrieve data per photo club. This data is then loaded into into the in-app CoreData database. It is also needed to keep the CoreData database up to date whenever clubs, members or images are added. The old approach is essentially a plug-in design with an adaptor per photo club.
The new approach replaces this by a standardizable data interface to avoid having to modify the source code to add (or modify/remove) clubs, members or images. The basic idea here is to store the required information in a hierarchical, distributed way. This allows the app to load the information in a three step process:
The app loads a list of photo clubs from a fixed location (URL). Because the file is kept external to the actual app, the list can be updated without requiring an app software update. The file is in a fixed JSON syntax and contains a list of supported photo clubs.
As a bonus, the list can also contain information about photography museums. The properties of clubs and museums largely overlap, but a photo club can notably include the location (URL) of a level2.json data source while a museum cannot.
The list of images (per club member) is fetched only when a portfolio is selected for viewing. There is thus no need to prefetch the entire 3-level tree (Level 1 / Level 2 / Level 3). Again, this index needs to be in a fixed format, and thus will possibly require an editing tool to guard the syntax. Currently this tool already exists: index and files are exported from Lightroom using a Web plug-in. Depending on local preference, this level can be managed by a club volunteer, or distributed across the individual club members. In the latter case, a portfolio can be updated whenever a member wants. In the former (and more formal) case, the club can have some kind of approval or rating system in place.
- create
NSManagedObjectContext
of type Background. A CoreData feature.- create thread using myContext.perform(). A CoreData feature using an OS feature.
- perform database operations from within the thread while passing this CoreData context. A CoreData feature.
- commit data to database using
myContext.save()
. A CoreData feature. - do any other required operations ended by additional
myContext.save()
. A CoreData feature.
- end usage of the thread. The thread will disappear. A Swift feature.
- create thread using myContext.perform(). A CoreData feature using an OS feature.
- the
myContext
object disappears when it is no longer used. A Swift feature. - create thread. An OS/language feature.
- start transaction. An SQL feature.
- perform SQL operations from within a thread. This is implicitly within the transaction context. An SQL feature.
- end transaction (commit or rollback). SQL feature.
- optionally start a next transaction (begin transaction > SQL operations > commit transaction)
- start transaction. An SQL feature.
- end thread. An OS/language feature.
Details (click to expand)
Details (click to expand)
Membership lists are loaded into Core Data using a dedicated background thread per photo club. So if, for example, 10 clubs are loaded, there will be a main thread for SwiftUI, a few predefined lower priority threads, plus 10 temporary background threads (one per club). Each background thread reads optional data stored inside the app itself, and then reads optional online data. A club's background thread disappears as soon as the club’s membership data is fully loaded.
These threads start immediately once the app is launched (in Foto_Club_Hub_Waalre_App.swift
).
This means that background loading of membership data already starts while the Prelude View is displayed.
Details (click to expand)
It also means that slow background threads might complete after the list of members is displayed in the Portfolio View. This may cause an update of the membership lists in the Portfolio View. This will be rarely noticed because the Portfolio View displays data from the Core Data database, and thus usually arleady contains data persisted from a preceding run. But you might see updates found within the online data or updates when the app is run for the first time.
Details (click to expand)
Each thread is associated with a Core Data NSManagedObjectContext]
.
In fact, the thread is started using myContext.perform()
.
The trick to using Core Data in a multi-threaded app is to ensure that all database fetches/inserts/updates
are performed using the Core Data NSManagedObjectContext
while running the associated thread. Schematically:
The magic happens within myContext.save()
.
During the myContext.save()
any changes are committed to the database
so that other threads can now see those changes and the changes are persistently stored.
Note that myContext.save()
can throw an exception - especially if there are
inconsistencies such as data merge conflicts, or violations of database constraints.
Details (click to expand)
A Core Data NSManagedObjectContext
can be seen as a counterpart to an SQL transactions.
Distributed under the MIT License. See LICENSE.txt
for more information.
Peter van den Hamer - [email protected]
Project Link: https://github.com/vdhamer/Photo-Club-Hub
- The opening Prelude screen uses a photo of a colorful building by Greetje van Son.
- The interactive Roadmap screen uses the AvdLee/Roadmap package. The screen is currently disabled because the backend provider of Roadmap stopped supporting it.
- The diagram with Core Data entities was generated using the Core Data Model Editor tool by Stéphane Millet.
- JSON parsing uses the SwiftyJSON/SwiftyJSON package.