Skip to content

iOS app for showcasing and viewing portfolios with curated fine-art photographic images.

Notifications You must be signed in to change notification settings

vdhamer/Photo-Club-Hub

Repository files navigation

Version Contributors Forks Stargazers Issues Discussions MIT License

Screenshots of 3 screens

Table of Contents

About the Project

The App

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.

Photo Clubs

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.

    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:

    • 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
    • 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

    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:

    • photographer Bill

      • photo club Clickers (hosted on www.PhotoClubclickers.com)
        • photos by Bill as a member of the Clickers
    • 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
    • photographer Sean

      • photo club Zoomers (hosted on www.PhotoClubZoomers.com)
        • photos by Sean as a member of the Zoomers

    For comparison, traditional personal websites stress the photographer's images, without any reference to clubs:

    • website for Bill (hosted on www.BillIsAwesome.com)

      • photo gallery with portraits
        • portrait photos by Bill
      • photo gallery with landscapes
        • landscape photos by Bill
    • website for John (hosted on www.JohnIsAwesomeToo.com)

      • photo gallery with macros
        • macro photos by John
      • :

    Implications

      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).

(back to top)

The User Interface Screens

Usage of the various screens in the user interface:

    The Portfolios Screen

    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)).

    Portfolios Screen

    The Clubs and Museums Screen

    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.

    Clubs and Museums Screen

    The Images Screen

    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 Screen

    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

    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.

    Preferences Screen

    The Readme Screen

    The Readme screen contains background information on the app and info on app usage.

    Readme Screen

    The Who's Who Screen

    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.

    Who's Who Screen

    The Prelude Screen

    The Prelude screen shows an opening animation. Clicking outside the central image brings you to the central Portfolios screen.

    Prelude 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.

(back to top)

Features

    Multi-club Support

    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:

    • 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)

    Website Generation

    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.

    Searchable Lists

    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.

    • In the Portfolios screen, a search scans photographers' full names. Searching on Jan might return Jan Stege, Ariejan van Twisk and Jos Jansen. If you need to search on club names, go to the Clubs and Museums screen.
    • In the Clubs and Museums screen, searches try to match against the organization names and towns. Searching on Ber might match FFC Shot71 (Berlicum) and Museum für Fotografie (Berlin) and The Victoria & Albert Museum (London). Note that the town is the location specified in the root.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 on Jan might return Jan Stege, Ariejan van Twisk and Jos Jansen.

    Design detail: Search Bar filtering is done in the app's user interface and not by the CoreData database.

    Photographer Keywords (in progress)

    Photographers can be associated with keywords describing what kind of photography they are mainly known for. Examples: "Black & White" or "Landscape". Because these keywords can serve to Search for photographers with similar interests, they keywords are standardized (e.g. consistently use "Black & White" rather than partly using "B&W" or "Black and White").

    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.

    Photo Museums

    The maps showing the location of photo clubs also show the locations of selected photo museums.

    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.

    Pull down to Refresh

    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.

    Fancy scrolling

    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.

(back to top)

Adding Photo Clubs to the App

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.

    Levels

    To add a club to the app, the app distinguishes 3 hierarchical layers of information:

    • 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.

    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:

    • Level 0 holds some basic configuration 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.

    Levels and Screens

    Screenshots of 3 screens

    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. Keywords and Languages (in progress)

    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)

      • keywords lists the keywords that can be linked to one or more photographers 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, because isoCode 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 photographer keywords and organization remarks.
        • 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.
      Optional Level 0 fields (click to expand)

      • usage (within a keyword) 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.

    Level 1. Adding Clubs

    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)

      • clubs and museums 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 and museums determine the OrganizationType (club or museum) of each Organization object. This in turn determines which marker type is shown on maps.
      • 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 the coordinates. This co-called localizedTown may contain the same string as town, or be a translation of town, or may even hold larger or smaller administrative entity. The file's town field is used to ensure that there is a unique ID for a club or museum. So Fotoclub Lucifer in Vessem would be considered unrelated to a Fotoclub Lucifer in Eersel. The town field also serves to document the record in the root.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 its coordinates.
          • Similarly, the user interface can display a computed localizedCountry name that is automatically generated using the provided coordinates. The Level 1 data thus does not need or include a country attribute. This is convenient because country names commonly get translated into local languages (Italia, Italy, İtalya, etc.).
      • town and fullName 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 obsolete Organization 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
      • nickName is a short version of fullName 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).

      Optional Level 1 fields (click to expand)

      • 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 the Level 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. The remark 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.

    Level 2. Adding Members

    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)

      • club has the same structure as a single club record from the root.level1.json file. It serves to label the Level2 file so you can tell which club it belongs to.
        • idPlus and its 3 fields (town, fullName, and nickName) are all required.
        • town and fullName must exactly match the corresponding fields in the root.level1.json file.
      • members is a list with the club's current and optionally former members.

      Optional Level 2 fields (click to expand)

      • Optional fields (that are ignored)
        • the level2URL field can be included, but it's value does not overrule the level2URL value in root.level1.json for safety reasons.
      • Optional fields (that are used)
        • a club's wikipedia, fotobondNumber, coordinates, website, and localizedRemarks fields overrule the corresponding root.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 and familyName 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 given role is not mentioned, a default value of false is assumed. Many members have an empty or even absent roles section. Some members may have multiple roles (e.g., secretary and admin).
        • the status entries indicate a member's status in the club. If a given status is not mentioned, a default value of false is assumed. Many members have an empty or even absent status section. Some members may have multiple special statuses (e.g., former and honorary).
        • 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 state former 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 of isFormerMember. 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 to true) 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 (like 9999) 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 the Portfolios screen and the Who'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 at Level 2 is used to define the applicable keywords. It doesn't define how they are displayed because multi-language versions of keywords are defined in Level 0. Example: the input identifier "Landscape" that can be associated with a given photographer in the Level 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 a Level2.json file does not occur in the list of keywords in the Level0.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").

      Note that the birthday, website, isDeceased, photographerImage, and keywords 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 of birthday, website or isDeceased. 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 supply keywords for the same photographer, the lists are automatically merged for use by the app. The respective Level 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. Adding Images

    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 do
      Mandatory Level 3 fields (click to expand)

      To do
      Optional Level 3 fields (click to expand)

      To do

(back to top)

Installation

If you simly want to install the binary version of the app, just install it from Apple's app store (link).

Built-With

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)

Cloning the Repository

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.

Code Signing

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.

Updating the App

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.

Schema Migration

    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.

(back to top)

Contributing

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.

Developer contributions

    Details(click to expand)

    Possible contributions include adding features, code improvements, ideas on architecture and interface specifications, and possibly even a dedicated backend server.

Other contributions

    Details(click to expand)

    Contributions that do not involve coding include beta testing, thoughtful and detailed feature requests, translations, and icon design improvements.

(back to top)

The App Architecture

The app uses a SwiftUI-based MVVM architecture pattern.

MVVM Layers

    Details (click to expand)

    The use of a SwiftUI-based MVVM architecture implies that

    • 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 the Model and View layers.

    Each of the layers has its own directory (found at the linked locations):

    • 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.

Role of the Database

    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:

    1. 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.
    2. 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.
    3. 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!
    4. 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).
    5. Photo club data is minimal (name, town/country, GPS, website), but is currently still hardcoded.

    Some of these gaps are addressed below.

The Data Model

Data model

    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.

    Organization

      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.

    Photographer

      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.

    MemberPortfolio

      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.

    OrganizationType

      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.

    Language

      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:

      1. max one localizedRemark attached to an organization (club, museum) and
      2. multiple localizedKeywords attached (indirectly via Keyword) to a photographer.

        #### LocalizedRemark

    LocalizedRemark

      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.

    Keyword

      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.

    LocalizedKeyword

      Details (click to expand)

      The LocalizedKeyword table holds the strings representing Keywords in any specific Language.

    PhotographerKeyword

      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 Keywords 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").

How Data is Loaded

    Details (click to expand)

    The Old Approach

      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).

    The New Approach

      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:

      1. Level 1: central list of photo clubs

      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.

      1. Level 2: local lists of photo club members

      Each `Level 2` list defines the current (and potentially former) members of a single club. For each member, a URL is stored pointing to the final list level (portfolio per member). `Level 2` lists also include the URL of an image used as thumbnail for that member. `Level 2` lists can be stored and managed on the club's own server. The file needs to be in a JSON format to allow the app to interpret it correctly. A future editing tool (app or web-based) would help ensure syntactic and schema consistency.

      1. Level 3: local image portfolios per club member

      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.

When Data is Loaded

    Details (click to expand)

    Background Threads

      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.

    SwiftUI View Updates

      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.

    Core Data Contexts

      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:

      • 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.
      • the myContext object disappears when it is no longer used. A Swift feature.

      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.

    Comparison to SQL Transactions

      Details (click to expand)

      A Core Data NSManagedObjectContext can be seen as a counterpart to an SQL transactions.

      • 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)
      • end thread. An OS/language feature.

(back to top)

Administrative

License

Distributed under the MIT License. See LICENSE.txt for more information.

Contact

Peter van den Hamer - [email protected]

Project Link: https://github.com/vdhamer/Photo-Club-Hub

Acknowledgments

  • 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.

(back to top)