PinApp is a free open-source iOS app for saving visited places and showing travel statistics. It uses pins on a map and fills visited countries with color. One of the main features is importing locations from photo coordinates from Iphoto. The import filters photos so that many photos from the same place don't create a lot of overlaping pins in the same place.
Similar apps are available in the App Store, but many have Significant limits and paid subscriptions. PinApp sllows to create an unlimited number of pins and can turn all photos from the photo library into pins at once.
The app is structured for gradual development, so services are built around the main models, using which new features can be added easily.
- Show saved locations as pins on a map.
- Filling countries with color after a pin is added.
- Create, edit, search, filter, and delete pins.
- Use the Point, City, and Mountain location types.
- View statistics for countries, continents, and location types.
- Import photo coordinates from the device photo library.
- Choose a default pin color, a map fill color, and a map style.
The app has four tabs:
-
Map: shows pins, visited countries, and the current user location. Long press on the map to create a pin.
-
Pins: shows all saved pins as a list. Search and filters help to find a location by title, country, tag, or type.
-
Statistics: shows travel progress by countries and continents, as well as totals for each location type.
-
Settings: map appearance settings and photo coordinate import button.
Requirements:
- A Mac that can run a current version of Xcode with iOS 26.5 support (or later).
- An iPhone running iOS 26.5 or later, or an iPad or an iOS Simulator.
- Download and install Xcode from the Mac App Store.
- Download this repository.
- Open PinApp.xcodeproj in Xcode.
- In the Xcode toolbar, choose an iPhone Simulator or connect an iPhone by cable and select it as the run device.
- If Xcode asks for signing information or something similar, it is normal and it means that you need to set some usual settings, that apple requires. Here you can find all neaded information. Briefly, open the PinApp target, select "Signing & Capabilities", choose your Apple development team, and let Xcode create a unique bundle identifier if needed.
- Press the Run button.
- If you are running the app on a physical device you may need to turn on the developer mode and confirm that you trust this developer in the iPhone settings. Instruction
- When the app asks for permissions, allow location access to show your current position and allow photo library access before importing photos.
- Country polygons in Countries.geojson are based on Natural Earth GeoJSON country boundaries.
- Country names, ISO codes, and regional data in Countries.json are based on ISO 3166 Countries with Regional Codes.
- The country list was processed with ChatGPT by OpenAI to match the format used by PinApp.
PinApp
├── App App entry point and dependency setup
├── Core
│ ├── Domain Models, value objects, and enums
│ ├── Infrastructure SwiftData, MapKit, Photos, geometry,
│ ├── Services App logic around the domain models
│ └── Storage Database setup and repository protocols
├── Features SwiftUI screens for map, pins, statistics, settings, and import
├── Resources GeoJSON, country seed data, assets, localised strings
└── Shared Reusable UI components and extensions
AppDependencies creates repositories and services, then passes them to the views. SwiftData stores user data locally. MapKit renders pins and country polygons. UpdateService notifies the map, pin list, and statistics screens after data changes.
Location is the central model. Every pin has a uuid, title, coordinate, country identifier, LocationType, date, color, visit count, note(optional), tags (optional), and photo identifiers (they are not used anywhere at the moment, I just dont want to change db in the future:).
Additional main models extend Location when a type needs shared information. At present, these are City and Mountain:
- City stores a city name, its centre coordinate, and the number of pins connected to it. CityVisitData stores visit-specific data such as the address. So the idea is, that general information about the city will not be saved multiple times.
- Mountain stores a mountain name, height, coordinate, and number of pins connected to it. MountainVisitData stores visit-specific data such as achieved height.
- Country stores the country name, ISO identifier (for example 'DE' for Germany), continent, and number of pins in the country.
- Photo is not used now, I created it to not change the db in the future. – PhotoMetadata supports photo library import.
For a complete guide to extending this model system, see Adding a location type.
Processing of photographic data runs only on the device, so it is never being uploaded.
The app asks for full photo library access and reads every photos metadata. A photo can create a pin only when it has geographic metadata. The import then applies the following checks in order:
- Photos without coordinates are skipped.
- A photo is skipped when it was made too close in time to the previously accepted photo. The current interval is at least 60 seconds.
- A photo is skipped when its coordinate is too close to any existing pin, including a pin created earlier in the same import. The current minimal distance is abount 5 km.
- Every accepted photo creates a Point (neither a city nor a mountain) through CreationService, so country counting, storage, and UI updates use the same flow as a manually created pin.
To compare coordinates, the app calculates their angular distance on a sphere. The two positions on the Earth surface are represented as vectors from the Earth centre. Their vector projections and the cosine theorem give the angle
Here,
Although earth is not a perfect sphere (Its radius is roughly 6357–6378 km), using one average radius creates only a small distance error that depends on the place on Earth. At a 5 km threshold, the error is at most about 11 metres. This is acceptable because the calculation is used only to prevent pins from overlapping, not to measure an exact distance.
This guide uses the existing Mountain type as the example. To add a new type, repeat the same pattern with your own names and fields. For example, replace Mountain with lake.
Create the shared model in Core/Domain/Models/. In our case Mountain.swift. It contains data that may be shared by several pins, such as mountainName, height, mountainCoordinate, and mountainPinCount.
Create the visit-specific payload in Core/Domain/Models/. In our case MountainVisitData.swift. This data belongs to one Location, for example achievedHeight. The payload must conform to Codable, because it is stored as encoded type data.
Add the new cases to both enums:
- Core/Domain/Enums/LocationType.swift needs a case with the visit payload, such as
case mountain(MountainVisitData). - Core/Domain/Enums/LocationTypeSelection.swift needs the matching simple case, such as
case mountain.
Add fields required by the editor to Core/Domain/Models/AllValues.swift. The mountain fields are mountainName, height, mountainCoordinate, mountainPinCount, and achievedHeight.
Create a SwiftData model in Core/Infrastructure/SwiftData/SwiftDataModels/ю In our case SwiftDataMountain.swift.
Then create all parts of the repository layer:
- Core/Storage/Repository protocols/MountainRepository.swift defines fetch, save, and delete operations.
- Core/Infrastructure/SwiftData/Repositories/SwiftDataMountainRepository.swift implements those operations and converts between Mountain and SwiftDataMountain.
- Core/Services/MountainService.swift provides model-specific logic, including plusPin(for:) and minusPin(for:).
Register the SwiftData model in Core/Storage/Database/Database.swift, inside ModelContainer. Register the repository and service in App/AppDependencies.swift, then pass the service to every service that needs it.
Finally, update Core/Infrastructure/SwiftData/Repositories/SwiftDataLocationRepository.swift. Add the new case in makeSwiftDataLocation(from:) so the type is stored as the correct LocationTypeSelection. The encoded LocationType payload in typeData will then preserve the visit-specific fields.
In Core/Services/CreationService.swift:
- Add the new service as a dependency and property.
- Add a new branch to
create(from:). - Create a dedicated function following
createMountain(from:newLocation:oldLocation:isNew:countryID:). - In that function, find or create the shared model, increase its pin count for a new pin, save it, and assign the related LocationType payload to
newLocation.type. - Update
removeOldObject(from:)so a pin that changes from this type to another type decreases the old shared model count.
In Core/Services/LocationService.swift:
- Add the type to
saveLocation(_:)and round its identity coordinate consistently. - Add it to
delete(id:)and call the matchingminusPin(for:)method. - Add the new service to the init and AppDependencies call site.
These steps keep shared-object counts correct during creation, editing, type changes, and deletion.
Update Features/Pins/PinState.swift:
- Add initial
AllValuesfields in init. - Add state needed for type-specific text fields and automatic filling, if required.
- Add a case to
loadLocation(_:). - Create a loader following
loadMountain(at:)to fetch the shared model. - Update
synchronizeTypeSpecificTextFields(), automatic filling, and validation where the new fields need special handling.
In Features/Pins/PinViewElements.swift:
- Add the type to the
typesarray used byPinTypeFields. - Add a case to the type selector switch.
- Create a form view following
PinMountainFieldsfor the new fields. - Add a readable type title in the helper switch used by the selector.
Features/Pins/PinView.swift already observes state.allValues.type, so it will refresh the type-specific fields after these changes.
Update Features/Pins/PinListView.swift:
- Add the type to the initial
selectedTypesset. - Add a filter row and update the all-types count in
filterDescription. - Add the type to the mapping that creates
PinListRowvalues. - Add an icon and a readable name in
PinListRowView.
Update the statistics flow:
- Add a count property to Core/Domain/Models/StatisticsValues.swift.
- Set it to 0 in Core/Services/StatisticsService.calculate().
- Increase it in
calculateLocationStatistics(locations:statistics:). - Add an icon and key path to Features/Statistics/StatisticsLocationTypes.swift.
Check all of the following before considering the feature complete:
- Create the type manually and confirm that it appears on the map and in the pin list.
- Edit its fields and confirm that the shared object and visit-specific data remain correct.
- Change a pin from the new type to Point or another type, then check that the old shared-object count decreases.
- Delete the pin and check that the shared object is removed when its count reaches zero.
- Use the Pins filter and confirm that the new type has the correct name and icon.
- Open Statistics and confirm that its total changes.
- Restart the app and confirm that the type and all its fields are still present.