├── .gitignore ├── Images ├── dark.jpg └── light.jpg ├── LICENSE ├── Pinboarding.xcodeproj ├── project.pbxproj └── xcshareddata │ └── xcschemes │ └── Pinboarding.xcscheme ├── Pinboarding ├── Alignments │ └── TwoColumnsAlignment.swift ├── Assets │ ├── Asset.swift │ └── Assets.xcassets │ │ ├── AccentColor.colorset │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ ├── 1024.png │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256-1.png │ │ ├── 256.png │ │ ├── 32-1.png │ │ ├── 32.png │ │ ├── 512-1.png │ │ ├── 512.png │ │ ├── 64.png │ │ └── Contents.json │ │ └── Contents.json ├── Commands │ └── BookmarkCommands.swift ├── Extensions │ └── NSSortDescriptor+App.swift ├── Factories │ └── ViewModelFactory.swift ├── PinboardingApp.swift ├── PinboardingAppEnvironment.swift ├── Preview Content │ ├── Preview Assets.xcassets │ │ └── Contents.json │ ├── Preview+AppEnvironment.swift │ ├── Preview+BookmarkViewModel.swift │ ├── Preview+Dependencies.swift │ ├── Preview+NWPathMonitorPathPublishing.swift │ ├── Preview+Networking.swift │ ├── Preview+Persistence.swift │ ├── Preview+SearchStore.swift │ ├── Preview+Settings.swift │ ├── Preview+TokenStore.swift │ └── Preview.swift ├── Property Wrappers │ └── SecureStorage.swift ├── Publishers │ └── NWPathMonitor.swift ├── Repository │ ├── Extensions │ │ ├── Booleans+Repository.swift │ │ └── Post+Repository.swift │ ├── Network │ │ ├── NetworkActivityEvent.swift │ │ ├── NetworkService.swift │ │ └── NetworkServiceError.swift │ ├── Persistence │ │ ├── Core Data Models │ │ │ ├── Bookmark+CoreDataClass.swift │ │ │ ├── Bookmark+CoreDataProperties.swift │ │ │ ├── Tag+CoreDataClass.swift │ │ │ └── Tag+CoreDataProperties.swift │ │ ├── Core Data │ │ │ └── Pinboarding.xcdatamodeld │ │ │ │ └── Pinboarding.xcdatamodel │ │ │ │ └── contents │ │ └── PersistenceService.swift │ └── PinboardRepository.swift ├── Stores │ ├── Search │ │ └── SearchStore.swift │ ├── Settings │ │ ├── SettingsStore.swift │ │ └── SettingsStoreChange.swift │ └── Token │ │ ├── SecureStore.swift │ │ └── TokenStoreProtocol.swift ├── Supporting Files │ ├── Info.plist │ └── Pinboarding.entitlements └── Views │ ├── Add Bookmark │ ├── AddBookmarkView.swift │ └── AddBookmarkViewModel.swift │ ├── Add Button │ └── AddView.swift │ ├── Bookmark Action Popover │ ├── BookmarkActionPopoverView.swift │ └── BookmarkActionPopoverViewModel.swift │ ├── Bookmark │ ├── BookmarkView.swift │ └── BookmarkViewModel.swift │ ├── Bookmarks List │ ├── BookmarksListView.swift │ └── BookmarksListViewModel.swift │ ├── Bookmarks │ └── BookmarksView.swift │ ├── Main │ ├── MainView.swift │ └── MainViewModel.swift │ ├── Micro.blog Button │ ├── MicroBlogButton.swift │ └── MicroBlogButtonViewModel.swift │ ├── Offline Button │ ├── OfflineView.swift │ └── OfflineViewModel.swift │ ├── Predicting TextField │ └── PredictingTextField.swift │ ├── Private │ └── PrivateView.swift │ ├── QRCode Button │ └── QRCodeButton.swift │ ├── QRCode │ └── QRCodeView.swift │ ├── Refresh │ ├── RefreshView.swift │ └── RefreshViewModel.swift │ ├── Safari Button │ └── SafariButton.swift │ ├── Search Text │ └── SearchTextView.swift │ ├── SearchField │ ├── SearchBarViewModel.swift │ └── SearchField.swift │ ├── Settings General │ ├── GeneralView.swift │ └── GeneralViewModel.swift │ ├── Settings Login │ ├── LoginView.swift │ └── LoginViewModel.swift │ ├── Settings │ └── SettingsView.swift │ ├── Share Button │ └── ShareButton.swift │ ├── Sharing Service Picker │ └── SharingServicePicker.swift │ ├── Sidebar Item │ ├── SidebarItemView.swift │ └── SidebarPrimaryItem.swift │ ├── Sidebar My Bookmarks Section │ ├── MyBookmarksSectionView.swift │ └── MyBookmarksSectionViewModel.swift │ ├── Sidebar Tags Section │ └── TagsSectionView.swift │ └── Sidebar │ ├── SidebarView.swift │ └── SidebarViewModel.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # xcode 2 | *.pbxuser 3 | *.perspective 4 | *.perspectivev3 5 | *.mode1v3 6 | *.mode2v3 7 | xcuserdata 8 | build/* 9 | DerivedData/ 10 | *.xcworkspace/ 11 | 12 | # osx 13 | .DS_Store 14 | profile 15 | 16 | # vscode 17 | .vscode/* 18 | 19 | # Bundler 20 | .bundle 21 | 22 | # Carthage 23 | Carthage 24 | 25 | # CocoaPods 26 | Pods/ 27 | -------------------------------------------------------------------------------- /Images/dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otaviocc/Pinboarding/f46e95936ad6bba41b0e00d138e5833f5b928a3f/Images/dark.jpg -------------------------------------------------------------------------------- /Images/light.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otaviocc/Pinboarding/f46e95936ad6bba41b0e00d138e5833f5b928a3f/Images/light.jpg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Otavio Cordeiro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pinboarding.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | AE00EA07258AD5B1002D9558 /* PinboardRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE00EA06258AD5B1002D9558 /* PinboardRepository.swift */; }; 11 | AE039704258E5B3900D744FB /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE039703258E5B3900D744FB /* LoginViewModel.swift */; }; 12 | AE0F3A5E258A07CA00227428 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0F3A5C258A07CA00227428 /* BookmarksView.swift */; }; 13 | AE0F3B12258A0F9200227428 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0F3B10258A0F9200227428 /* SidebarView.swift */; }; 14 | AE0F3B13258A0F9200227428 /* SidebarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0F3B11258A0F9200227428 /* SidebarViewModel.swift */; }; 15 | AE0F3B18258A339C00227428 /* SidebarItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0F3B16258A339C00227428 /* SidebarItemView.swift */; }; 16 | AE0F3B19258A339C00227428 /* SidebarPrimaryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0F3B17258A339C00227428 /* SidebarPrimaryItem.swift */; }; 17 | AE10FAEA2594D54500C79B82 /* Tag+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE10FAE62594D54500C79B82 /* Tag+CoreDataClass.swift */; }; 18 | AE10FAEB2594D54500C79B82 /* Tag+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE10FAE72594D54500C79B82 /* Tag+CoreDataProperties.swift */; }; 19 | AE10FAEC2594D54500C79B82 /* Bookmark+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE10FAE82594D54500C79B82 /* Bookmark+CoreDataProperties.swift */; }; 20 | AE10FAED2594D54500C79B82 /* Bookmark+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE10FAE92594D54500C79B82 /* Bookmark+CoreDataClass.swift */; }; 21 | AE12D7832597DDCB007A9EFB /* BookmarksListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE12D7822597DDCB007A9EFB /* BookmarksListViewModel.swift */; }; 22 | AE2E0AA525AB71530077CBDC /* BookmarkCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2E0AA425AB71530077CBDC /* BookmarkCommands.swift */; }; 23 | AE2F588D258EB84D00D19421 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2F588C258EB84D00D19421 /* SettingsStore.swift */; }; 24 | AE317AF92588FD9D00B7420A /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE317AF72588FD9D00B7420A /* SettingsView.swift */; }; 25 | AE3FC50E25E0680E00A91B3A /* NWPathMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3FC50D25E0680E00A91B3A /* NWPathMonitor.swift */; }; 26 | AE5781F8258DE42F00A524F4 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE5781F6258DE42F00A524F4 /* LoginView.swift */; }; 27 | AE628CC6258FA2D300F91800 /* SettingsStoreChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE628CC5258FA2D300F91800 /* SettingsStoreChange.swift */; }; 28 | AE6D05CE25BC920B00BEDDD4 /* SecureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE6D05CD25BC920B00BEDDD4 /* SecureStorage.swift */; }; 29 | AE78871E261B256700FBCC38 /* Preview+AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE78871D261B256700FBCC38 /* Preview+AppEnvironment.swift */; }; 30 | AE793004258675DB00A68947 /* PinboardingApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE793003258675DB00A68947 /* PinboardingApp.swift */; }; 31 | AE793006258675DB00A68947 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE793005258675DB00A68947 /* MainView.swift */; }; 32 | AE793008258675DC00A68947 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AE793007258675DC00A68947 /* Assets.xcassets */; }; 33 | AE79300B258675DC00A68947 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AE79300A258675DC00A68947 /* Preview Assets.xcassets */; }; 34 | AE7EE6BA25A9B3C0004EDEB9 /* TwoColumnsAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7EE6B925A9B3C0004EDEB9 /* TwoColumnsAlignment.swift */; }; 35 | AE83841025979D1100A4C5A7 /* Preview+Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE83840F25979D1100A4C5A7 /* Preview+Settings.swift */; }; 36 | AE8384182597B5AE00A4C5A7 /* BookmarksListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE8384172597B5AE00A4C5A7 /* BookmarksListView.swift */; }; 37 | AE8F4DD925BD93A3007E6053 /* Preview+TokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE8F4DD825BD93A3007E6053 /* Preview+TokenStore.swift */; }; 38 | AE8F4DDD25BD9687007E6053 /* TokenStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE8F4DDC25BD9687007E6053 /* TokenStoreProtocol.swift */; }; 39 | AE9541EB259AB01A00B1AA15 /* NetworkActivityEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE9541EA259AB01A00B1AA15 /* NetworkActivityEvent.swift */; }; 40 | AE9541EE259AC30600B1AA15 /* Preview+Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE9541ED259AC30600B1AA15 /* Preview+Networking.swift */; }; 41 | AE9541F3259B2D1E00B1AA15 /* TagsSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE9541F2259B2D1E00B1AA15 /* TagsSectionView.swift */; }; 42 | AE9541F7259B2D8800B1AA15 /* MyBookmarksSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE9541F6259B2D8800B1AA15 /* MyBookmarksSectionView.swift */; }; 43 | AE9541FA259B2DD500B1AA15 /* MyBookmarksSectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE9541F9259B2DD500B1AA15 /* MyBookmarksSectionViewModel.swift */; }; 44 | AE9F993E25A15F75001D2889 /* PrivateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE9F993D25A15F75001D2889 /* PrivateView.swift */; }; 45 | AE9F994525A1AC12001D2889 /* RefreshView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE9F994325A1AC12001D2889 /* RefreshView.swift */; }; 46 | AE9F994625A1AC12001D2889 /* RefreshViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE9F994425A1AC12001D2889 /* RefreshViewModel.swift */; }; 47 | AEA3DA94259B360600C4DB0F /* SharingServicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA3DA93259B360600C4DB0F /* SharingServicePicker.swift */; }; 48 | AEA5340425A4E23200AF2481 /* SearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA5340325A4E23200AF2481 /* SearchField.swift */; }; 49 | AEAB957A25A51FD900F4B7D3 /* SearchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEAB957925A51FD900F4B7D3 /* SearchStore.swift */; }; 50 | AEBFB5062589D9FF001521A2 /* BookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEBFB5042589D9FF001521A2 /* BookmarkView.swift */; }; 51 | AEBFB5072589D9FF001521A2 /* BookmarkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEBFB5052589D9FF001521A2 /* BookmarkViewModel.swift */; }; 52 | AEBFB68A25940F4D001626AA /* PinboardingAppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEBFB68925940F4D001626AA /* PinboardingAppEnvironment.swift */; }; 53 | AEBFB699259413C8001626AA /* NSSortDescriptor+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEBFB698259413C8001626AA /* NSSortDescriptor+App.swift */; }; 54 | AEC4A94F259B9F09000C65A8 /* SafariButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC4A94E259B9F09000C65A8 /* SafariButton.swift */; }; 55 | AEC4A953259B9F38000C65A8 /* ShareButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC4A952259B9F38000C65A8 /* ShareButton.swift */; }; 56 | AEC622892591DA6F003FDE66 /* PersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC622882591DA6F003FDE66 /* PersistenceService.swift */; }; 57 | AEC6228D2591DB03003FDE66 /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC6228C2591DB03003FDE66 /* NetworkService.swift */; }; 58 | AEC6229B259222B4003FDE66 /* Post+Repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC6229A259222B4003FDE66 /* Post+Repository.swift */; }; 59 | AEC72E0E259780FF008C7FF3 /* Preview+Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC72E0D259780FF008C7FF3 /* Preview+Persistence.swift */; }; 60 | AEC72E112597890E008C7FF3 /* Preview.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC72E102597890E008C7FF3 /* Preview.swift */; }; 61 | AECC8C0725F8E16C008568EE /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AECC8C0625F8E16C008568EE /* QRCodeView.swift */; }; 62 | AECC8C0B25F9460A008568EE /* QRCodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AECC8C0A25F9460A008568EE /* QRCodeButton.swift */; }; 63 | AECE16F32591BD640024DC88 /* Pinboarding.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = AECE16F12591BD640024DC88 /* Pinboarding.xcdatamodeld */; }; 64 | AED5883A25FA2F9100E31B33 /* Asset.swift in Sources */ = {isa = PBXBuildFile; fileRef = AED5883925FA2F9100E31B33 /* Asset.swift */; }; 65 | AED88E17264E9E9100EE0CB3 /* PredictingTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = AED88E16264E9E9100EE0CB3 /* PredictingTextField.swift */; }; 66 | AEE78D9A258BAF17001CA0EA /* Booleans+Repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE78D99258BAF17001CA0EA /* Booleans+Repository.swift */; }; 67 | AEED4D7025BB7CFB00756570 /* SecureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEED4D6F25BB7CFB00756570 /* SecureStore.swift */; }; 68 | AEF0DF09258EC8A8000F4BFF /* GeneralView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF0DF07258EC8A8000F4BFF /* GeneralView.swift */; }; 69 | AEF0DF11258ECB8D000F4BFF /* GeneralViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF0DF10258ECB8D000F4BFF /* GeneralViewModel.swift */; }; 70 | AEF4F4782589529200FF8F45 /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF4F4762589529200FF8F45 /* AddBookmarkView.swift */; }; 71 | AEF4F4792589529200FF8F45 /* AddBookmarkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF4F4772589529200FF8F45 /* AddBookmarkViewModel.swift */; }; 72 | AEF8A7B925C154A300E081B5 /* Preview+SearchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF8A7B825C154A300E081B5 /* Preview+SearchStore.swift */; }; 73 | B52AFFAF28560FF10074CF65 /* AddView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52AFFAE28560FF10074CF65 /* AddView.swift */; }; 74 | B5308B1E2841DE87006A9B11 /* ViewModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5308B1D2841DE87006A9B11 /* ViewModelFactory.swift */; }; 75 | B58526EB2855F16C00853428 /* OfflineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58526EA2855F16C00853428 /* OfflineViewModel.swift */; }; 76 | B58526ED2855F17600853428 /* OfflineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58526EC2855F17600853428 /* OfflineView.swift */; }; 77 | B5CE3A522838D2000014FE73 /* NetworkServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CE3A512838D2000014FE73 /* NetworkServiceError.swift */; }; 78 | B5F2203528413859003A667E /* MicroContainer in Frameworks */ = {isa = PBXBuildFile; productRef = B5F2203428413859003A667E /* MicroContainer */; }; 79 | E211258128891B8100ED217A /* SearchTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E211258028891B8100ED217A /* SearchTextView.swift */; }; 80 | E214C0E128995CF100954004 /* MicroBlogButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E214C0E028995CF100954004 /* MicroBlogButton.swift */; }; 81 | E214C0E3289970DC00954004 /* MicroBlogButtonViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E214C0E2289970DC00954004 /* MicroBlogButtonViewModel.swift */; }; 82 | E245D1AA2848B8970085DF60 /* MicroPinboard in Frameworks */ = {isa = PBXBuildFile; productRef = E245D1A92848B8970085DF60 /* MicroPinboard */; }; 83 | E2BB493D2897D90900937C3D /* BookmarkActionPopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BB493B2897D90900937C3D /* BookmarkActionPopoverView.swift */; }; 84 | E2BB493E2897D90900937C3D /* BookmarkActionPopoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BB493C2897D90900937C3D /* BookmarkActionPopoverViewModel.swift */; }; 85 | E2BB49402897F67500937C3D /* Preview+BookmarkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2BB493F2897F67500937C3D /* Preview+BookmarkViewModel.swift */; }; 86 | EA392DA1286E4B1700F1DF39 /* SearchBarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA392DA0286E4B1700F1DF39 /* SearchBarViewModel.swift */; }; 87 | EAD3F678285F1B6D008C4332 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD3F677285F1B6D008C4332 /* MainViewModel.swift */; }; 88 | EAD3F67A285F233E008C4332 /* Preview+Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD3F679285F233E008C4332 /* Preview+Dependencies.swift */; }; 89 | EAD3F67C285F26E7008C4332 /* Preview+NWPathMonitorPathPublishing.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD3F67B285F26E7008C4332 /* Preview+NWPathMonitorPathPublishing.swift */; }; 90 | /* End PBXBuildFile section */ 91 | 92 | /* Begin PBXFileReference section */ 93 | AE00EA06258AD5B1002D9558 /* PinboardRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinboardRepository.swift; sourceTree = ""; }; 94 | AE039703258E5B3900D744FB /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; 95 | AE0F3A5C258A07CA00227428 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = ""; }; 96 | AE0F3B10258A0F9200227428 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; 97 | AE0F3B11258A0F9200227428 /* SidebarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarViewModel.swift; sourceTree = ""; }; 98 | AE0F3B16258A339C00227428 /* SidebarItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarItemView.swift; sourceTree = ""; }; 99 | AE0F3B17258A339C00227428 /* SidebarPrimaryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPrimaryItem.swift; sourceTree = ""; }; 100 | AE10FAE62594D54500C79B82 /* Tag+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Tag+CoreDataClass.swift"; sourceTree = ""; }; 101 | AE10FAE72594D54500C79B82 /* Tag+CoreDataProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Tag+CoreDataProperties.swift"; sourceTree = ""; }; 102 | AE10FAE82594D54500C79B82 /* Bookmark+CoreDataProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bookmark+CoreDataProperties.swift"; sourceTree = ""; }; 103 | AE10FAE92594D54500C79B82 /* Bookmark+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bookmark+CoreDataClass.swift"; sourceTree = ""; }; 104 | AE12D7822597DDCB007A9EFB /* BookmarksListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksListViewModel.swift; sourceTree = ""; }; 105 | AE2E0AA425AB71530077CBDC /* BookmarkCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkCommands.swift; sourceTree = ""; }; 106 | AE2F588C258EB84D00D19421 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = ""; }; 107 | AE317AF72588FD9D00B7420A /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 108 | AE3FC50D25E0680E00A91B3A /* NWPathMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWPathMonitor.swift; sourceTree = ""; }; 109 | AE5781F6258DE42F00A524F4 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 110 | AE628CC5258FA2D300F91800 /* SettingsStoreChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStoreChange.swift; sourceTree = ""; }; 111 | AE6D05CD25BC920B00BEDDD4 /* SecureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorage.swift; sourceTree = ""; }; 112 | AE78871D261B256700FBCC38 /* Preview+AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preview+AppEnvironment.swift"; sourceTree = ""; }; 113 | AE793000258675DB00A68947 /* Pinboarding.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Pinboarding.app; sourceTree = BUILT_PRODUCTS_DIR; }; 114 | AE793003258675DB00A68947 /* PinboardingApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinboardingApp.swift; sourceTree = ""; }; 115 | AE793005258675DB00A68947 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; 116 | AE793007258675DC00A68947 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 117 | AE79300A258675DC00A68947 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 118 | AE79300C258675DC00A68947 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 119 | AE79300D258675DC00A68947 /* Pinboarding.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Pinboarding.entitlements; sourceTree = ""; }; 120 | AE7EE6B925A9B3C0004EDEB9 /* TwoColumnsAlignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwoColumnsAlignment.swift; sourceTree = ""; }; 121 | AE83840F25979D1100A4C5A7 /* Preview+Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preview+Settings.swift"; sourceTree = ""; }; 122 | AE8384172597B5AE00A4C5A7 /* BookmarksListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksListView.swift; sourceTree = ""; }; 123 | AE8F4DD825BD93A3007E6053 /* Preview+TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preview+TokenStore.swift"; sourceTree = ""; }; 124 | AE8F4DDC25BD9687007E6053 /* TokenStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenStoreProtocol.swift; sourceTree = ""; }; 125 | AE9541EA259AB01A00B1AA15 /* NetworkActivityEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkActivityEvent.swift; sourceTree = ""; }; 126 | AE9541ED259AC30600B1AA15 /* Preview+Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preview+Networking.swift"; sourceTree = ""; }; 127 | AE9541F2259B2D1E00B1AA15 /* TagsSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsSectionView.swift; sourceTree = ""; }; 128 | AE9541F6259B2D8800B1AA15 /* MyBookmarksSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyBookmarksSectionView.swift; sourceTree = ""; }; 129 | AE9541F9259B2DD500B1AA15 /* MyBookmarksSectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyBookmarksSectionViewModel.swift; sourceTree = ""; }; 130 | AE9F993D25A15F75001D2889 /* PrivateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateView.swift; sourceTree = ""; }; 131 | AE9F994325A1AC12001D2889 /* RefreshView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshView.swift; sourceTree = ""; }; 132 | AE9F994425A1AC12001D2889 /* RefreshViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshViewModel.swift; sourceTree = ""; }; 133 | AEA3DA93259B360600C4DB0F /* SharingServicePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingServicePicker.swift; sourceTree = ""; }; 134 | AEA5340325A4E23200AF2481 /* SearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchField.swift; sourceTree = ""; }; 135 | AEAB957925A51FD900F4B7D3 /* SearchStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchStore.swift; sourceTree = ""; }; 136 | AEBFB5042589D9FF001521A2 /* BookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkView.swift; sourceTree = ""; }; 137 | AEBFB5052589D9FF001521A2 /* BookmarkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkViewModel.swift; sourceTree = ""; }; 138 | AEBFB68925940F4D001626AA /* PinboardingAppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinboardingAppEnvironment.swift; sourceTree = ""; }; 139 | AEBFB698259413C8001626AA /* NSSortDescriptor+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSSortDescriptor+App.swift"; sourceTree = ""; }; 140 | AEC4A94E259B9F09000C65A8 /* SafariButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariButton.swift; sourceTree = ""; }; 141 | AEC4A952259B9F38000C65A8 /* ShareButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareButton.swift; sourceTree = ""; }; 142 | AEC622882591DA6F003FDE66 /* PersistenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceService.swift; sourceTree = ""; }; 143 | AEC6228C2591DB03003FDE66 /* NetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; 144 | AEC6229A259222B4003FDE66 /* Post+Repository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post+Repository.swift"; sourceTree = ""; }; 145 | AEC72E0D259780FF008C7FF3 /* Preview+Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preview+Persistence.swift"; sourceTree = ""; }; 146 | AEC72E102597890E008C7FF3 /* Preview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preview.swift; sourceTree = ""; }; 147 | AECC8C0625F8E16C008568EE /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = ""; }; 148 | AECC8C0A25F9460A008568EE /* QRCodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeButton.swift; sourceTree = ""; }; 149 | AECE16F22591BD640024DC88 /* Pinboarding.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Pinboarding.xcdatamodel; sourceTree = ""; }; 150 | AED5883925FA2F9100E31B33 /* Asset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Asset.swift; sourceTree = ""; }; 151 | AED88E16264E9E9100EE0CB3 /* PredictingTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictingTextField.swift; sourceTree = ""; }; 152 | AEE78D99258BAF17001CA0EA /* Booleans+Repository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Booleans+Repository.swift"; sourceTree = ""; }; 153 | AEED4D6F25BB7CFB00756570 /* SecureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStore.swift; sourceTree = ""; }; 154 | AEF0DF07258EC8A8000F4BFF /* GeneralView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralView.swift; sourceTree = ""; }; 155 | AEF0DF10258ECB8D000F4BFF /* GeneralViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneralViewModel.swift; sourceTree = ""; }; 156 | AEF4F4762589529200FF8F45 /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = ""; }; 157 | AEF4F4772589529200FF8F45 /* AddBookmarkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkViewModel.swift; sourceTree = ""; }; 158 | AEF8A7B825C154A300E081B5 /* Preview+SearchStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preview+SearchStore.swift"; sourceTree = ""; }; 159 | B52AFFAE28560FF10074CF65 /* AddView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddView.swift; sourceTree = ""; }; 160 | B5308B1D2841DE87006A9B11 /* ViewModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelFactory.swift; sourceTree = ""; }; 161 | B58526EA2855F16C00853428 /* OfflineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineViewModel.swift; sourceTree = ""; }; 162 | B58526EC2855F17600853428 /* OfflineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineView.swift; sourceTree = ""; }; 163 | B5CE3A512838D2000014FE73 /* NetworkServiceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkServiceError.swift; sourceTree = ""; }; 164 | E211258028891B8100ED217A /* SearchTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTextView.swift; sourceTree = ""; }; 165 | E214C0E028995CF100954004 /* MicroBlogButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicroBlogButton.swift; sourceTree = ""; }; 166 | E214C0E2289970DC00954004 /* MicroBlogButtonViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicroBlogButtonViewModel.swift; sourceTree = ""; }; 167 | E2BB493B2897D90900937C3D /* BookmarkActionPopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkActionPopoverView.swift; sourceTree = ""; }; 168 | E2BB493C2897D90900937C3D /* BookmarkActionPopoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkActionPopoverViewModel.swift; sourceTree = ""; }; 169 | E2BB493F2897F67500937C3D /* Preview+BookmarkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preview+BookmarkViewModel.swift"; sourceTree = ""; }; 170 | EA392DA0286E4B1700F1DF39 /* SearchBarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarViewModel.swift; sourceTree = ""; }; 171 | EAD3F677285F1B6D008C4332 /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = ""; }; 172 | EAD3F679285F233E008C4332 /* Preview+Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preview+Dependencies.swift"; sourceTree = ""; }; 173 | EAD3F67B285F26E7008C4332 /* Preview+NWPathMonitorPathPublishing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preview+NWPathMonitorPathPublishing.swift"; sourceTree = ""; }; 174 | /* End PBXFileReference section */ 175 | 176 | /* Begin PBXFrameworksBuildPhase section */ 177 | AE792FFD258675DB00A68947 /* Frameworks */ = { 178 | isa = PBXFrameworksBuildPhase; 179 | buildActionMask = 2147483647; 180 | files = ( 181 | E245D1AA2848B8970085DF60 /* MicroPinboard in Frameworks */, 182 | B5F2203528413859003A667E /* MicroContainer in Frameworks */, 183 | ); 184 | runOnlyForDeploymentPostprocessing = 0; 185 | }; 186 | /* End PBXFrameworksBuildPhase section */ 187 | 188 | /* Begin PBXGroup section */ 189 | AE00EA04258AD573002D9558 /* Repository */ = { 190 | isa = PBXGroup; 191 | children = ( 192 | AE00EA06258AD5B1002D9558 /* PinboardRepository.swift */, 193 | AE00EA10258AE948002D9558 /* Extensions */, 194 | AEC6228B2591DAD3003FDE66 /* Network */, 195 | AECE16F02591BD3F0024DC88 /* Persistence */, 196 | ); 197 | path = Repository; 198 | sourceTree = ""; 199 | }; 200 | AE00EA10258AE948002D9558 /* Extensions */ = { 201 | isa = PBXGroup; 202 | children = ( 203 | AEE78D99258BAF17001CA0EA /* Booleans+Repository.swift */, 204 | AEC6229A259222B4003FDE66 /* Post+Repository.swift */, 205 | ); 206 | path = Extensions; 207 | sourceTree = ""; 208 | }; 209 | AE1303BE2587E3DA0083EEFB /* Frameworks */ = { 210 | isa = PBXGroup; 211 | children = ( 212 | ); 213 | name = Frameworks; 214 | sourceTree = ""; 215 | }; 216 | AE2E0AA325AB71320077CBDC /* Commands */ = { 217 | isa = PBXGroup; 218 | children = ( 219 | AE2E0AA425AB71530077CBDC /* BookmarkCommands.swift */, 220 | ); 221 | path = Commands; 222 | sourceTree = ""; 223 | }; 224 | AE2F588B258EB73900D19421 /* Stores */ = { 225 | isa = PBXGroup; 226 | children = ( 227 | AEEBE55525E94062009A0404 /* Search */, 228 | AEEBE55625E9406C009A0404 /* Settings */, 229 | AEEBE55725E94077009A0404 /* Token */, 230 | ); 231 | path = Stores; 232 | sourceTree = ""; 233 | }; 234 | AE3675FF258AAF6700F57A3C /* Add Bookmark */ = { 235 | isa = PBXGroup; 236 | children = ( 237 | AEF4F4762589529200FF8F45 /* AddBookmarkView.swift */, 238 | AEF4F4772589529200FF8F45 /* AddBookmarkViewModel.swift */, 239 | ); 240 | path = "Add Bookmark"; 241 | sourceTree = ""; 242 | }; 243 | AE367600258AAF7600F57A3C /* Settings */ = { 244 | isa = PBXGroup; 245 | children = ( 246 | AE317AF72588FD9D00B7420A /* SettingsView.swift */, 247 | ); 248 | path = Settings; 249 | sourceTree = ""; 250 | }; 251 | AE367601258AAF7E00F57A3C /* Sidebar Item */ = { 252 | isa = PBXGroup; 253 | children = ( 254 | AE0F3B16258A339C00227428 /* SidebarItemView.swift */, 255 | AE0F3B17258A339C00227428 /* SidebarPrimaryItem.swift */, 256 | ); 257 | path = "Sidebar Item"; 258 | sourceTree = ""; 259 | }; 260 | AE367602258AAF8600F57A3C /* Sidebar */ = { 261 | isa = PBXGroup; 262 | children = ( 263 | AE0F3B10258A0F9200227428 /* SidebarView.swift */, 264 | AE0F3B11258A0F9200227428 /* SidebarViewModel.swift */, 265 | ); 266 | path = Sidebar; 267 | sourceTree = ""; 268 | }; 269 | AE367604258AAF8F00F57A3C /* Bookmarks */ = { 270 | isa = PBXGroup; 271 | children = ( 272 | AE0F3A5C258A07CA00227428 /* BookmarksView.swift */, 273 | ); 274 | path = Bookmarks; 275 | sourceTree = ""; 276 | }; 277 | AE367606258AAF9B00F57A3C /* Bookmark */ = { 278 | isa = PBXGroup; 279 | children = ( 280 | AEBFB5042589D9FF001521A2 /* BookmarkView.swift */, 281 | AEBFB5052589D9FF001521A2 /* BookmarkViewModel.swift */, 282 | ); 283 | path = Bookmark; 284 | sourceTree = ""; 285 | }; 286 | AE3FC50C25E067E300A91B3A /* Publishers */ = { 287 | isa = PBXGroup; 288 | children = ( 289 | AE3FC50D25E0680E00A91B3A /* NWPathMonitor.swift */, 290 | ); 291 | path = Publishers; 292 | sourceTree = ""; 293 | }; 294 | AE494F40258ABE5200CFECB6 /* Main */ = { 295 | isa = PBXGroup; 296 | children = ( 297 | AE793005258675DB00A68947 /* MainView.swift */, 298 | EAD3F677285F1B6D008C4332 /* MainViewModel.swift */, 299 | ); 300 | path = Main; 301 | sourceTree = ""; 302 | }; 303 | AE5781FB258DE4DE00A524F4 /* Settings Login */ = { 304 | isa = PBXGroup; 305 | children = ( 306 | AE5781F6258DE42F00A524F4 /* LoginView.swift */, 307 | AE039703258E5B3900D744FB /* LoginViewModel.swift */, 308 | ); 309 | path = "Settings Login"; 310 | sourceTree = ""; 311 | }; 312 | AE6D05CC25BC91B600BEDDD4 /* Property Wrappers */ = { 313 | isa = PBXGroup; 314 | children = ( 315 | AE6D05CD25BC920B00BEDDD4 /* SecureStorage.swift */, 316 | ); 317 | path = "Property Wrappers"; 318 | sourceTree = ""; 319 | }; 320 | AE792FF7258675DB00A68947 = { 321 | isa = PBXGroup; 322 | children = ( 323 | AE793002258675DB00A68947 /* Pinboarding */, 324 | AE793001258675DB00A68947 /* Products */, 325 | AE1303BE2587E3DA0083EEFB /* Frameworks */, 326 | ); 327 | sourceTree = ""; 328 | }; 329 | AE793001258675DB00A68947 /* Products */ = { 330 | isa = PBXGroup; 331 | children = ( 332 | AE793000258675DB00A68947 /* Pinboarding.app */, 333 | ); 334 | name = Products; 335 | sourceTree = ""; 336 | }; 337 | AE793002258675DB00A68947 /* Pinboarding */ = { 338 | isa = PBXGroup; 339 | children = ( 340 | AE793003258675DB00A68947 /* PinboardingApp.swift */, 341 | AEBFB68925940F4D001626AA /* PinboardingAppEnvironment.swift */, 342 | AE7EE6B825A9B3AA004EDEB9 /* Alignments */, 343 | AE7930162586838700A68947 /* Assets */, 344 | AE2E0AA325AB71320077CBDC /* Commands */, 345 | AEBFB697259413B1001626AA /* Extensions */, 346 | B5308B1C2841DE76006A9B11 /* Factories */, 347 | AE79301B2586841700A68947 /* Preview Content */, 348 | AE6D05CC25BC91B600BEDDD4 /* Property Wrappers */, 349 | AE3FC50C25E067E300A91B3A /* Publishers */, 350 | AE00EA04258AD573002D9558 /* Repository */, 351 | AE2F588B258EB73900D19421 /* Stores */, 352 | AE793017258683C800A68947 /* Supporting Files */, 353 | AE7930152586837900A68947 /* Views */, 354 | ); 355 | path = Pinboarding; 356 | sourceTree = ""; 357 | }; 358 | AE7930152586837900A68947 /* Views */ = { 359 | isa = PBXGroup; 360 | children = ( 361 | AE3675FF258AAF6700F57A3C /* Add Bookmark */, 362 | B52AFFAD28560FE80074CF65 /* Add Button */, 363 | AE367606258AAF9B00F57A3C /* Bookmark */, 364 | E2BB493A2897D8F500937C3D /* Bookmark Action Popover */, 365 | AE367604258AAF8F00F57A3C /* Bookmarks */, 366 | AE83841A2597B63400A4C5A7 /* Bookmarks List */, 367 | AE494F40258ABE5200CFECB6 /* Main */, 368 | E214C0DF28995CCF00954004 /* Micro.blog Button */, 369 | B58526E92855F15200853428 /* Offline Button */, 370 | AED88E15264E9E6000EE0CB3 /* Predicting TextField */, 371 | AE9F993C25A15F58001D2889 /* Private */, 372 | AECC8C0525F8E158008568EE /* QRCode */, 373 | AECC8C0925F945D8008568EE /* QRCode Button */, 374 | AE9F994225A1ABD9001D2889 /* Refresh */, 375 | AEC4A94D259B9EF7000C65A8 /* Safari Button */, 376 | E211257F28891B7100ED217A /* Search Text */, 377 | AEA5340225A4E20B00AF2481 /* SearchField */, 378 | AE367600258AAF7600F57A3C /* Settings */, 379 | AEF0DF05258EC88B000F4BFF /* Settings General */, 380 | AE5781FB258DE4DE00A524F4 /* Settings Login */, 381 | AEC4A951259B9F1B000C65A8 /* Share Button */, 382 | AEA3DA92259B35F100C4DB0F /* Sharing Service Picker */, 383 | AE367602258AAF8600F57A3C /* Sidebar */, 384 | AE367601258AAF7E00F57A3C /* Sidebar Item */, 385 | AE9541F5259B2D6C00B1AA15 /* Sidebar My Bookmarks Section */, 386 | AE9541F1259B2D0C00B1AA15 /* Sidebar Tags Section */, 387 | ); 388 | path = Views; 389 | sourceTree = ""; 390 | }; 391 | AE7930162586838700A68947 /* Assets */ = { 392 | isa = PBXGroup; 393 | children = ( 394 | AED5883925FA2F9100E31B33 /* Asset.swift */, 395 | AE793007258675DC00A68947 /* Assets.xcassets */, 396 | ); 397 | path = Assets; 398 | sourceTree = ""; 399 | }; 400 | AE793017258683C800A68947 /* Supporting Files */ = { 401 | isa = PBXGroup; 402 | children = ( 403 | AE79300C258675DC00A68947 /* Info.plist */, 404 | AE79300D258675DC00A68947 /* Pinboarding.entitlements */, 405 | ); 406 | path = "Supporting Files"; 407 | sourceTree = ""; 408 | }; 409 | AE79301B2586841700A68947 /* Preview Content */ = { 410 | isa = PBXGroup; 411 | children = ( 412 | AEC72E102597890E008C7FF3 /* Preview.swift */, 413 | AE78871D261B256700FBCC38 /* Preview+AppEnvironment.swift */, 414 | E2BB493F2897F67500937C3D /* Preview+BookmarkViewModel.swift */, 415 | EAD3F679285F233E008C4332 /* Preview+Dependencies.swift */, 416 | AE9541ED259AC30600B1AA15 /* Preview+Networking.swift */, 417 | EAD3F67B285F26E7008C4332 /* Preview+NWPathMonitorPathPublishing.swift */, 418 | AEC72E0D259780FF008C7FF3 /* Preview+Persistence.swift */, 419 | AEF8A7B825C154A300E081B5 /* Preview+SearchStore.swift */, 420 | AE83840F25979D1100A4C5A7 /* Preview+Settings.swift */, 421 | AE8F4DD825BD93A3007E6053 /* Preview+TokenStore.swift */, 422 | AE79300A258675DC00A68947 /* Preview Assets.xcassets */, 423 | ); 424 | path = "Preview Content"; 425 | sourceTree = ""; 426 | }; 427 | AE7EE6B825A9B3AA004EDEB9 /* Alignments */ = { 428 | isa = PBXGroup; 429 | children = ( 430 | AE7EE6B925A9B3C0004EDEB9 /* TwoColumnsAlignment.swift */, 431 | ); 432 | path = Alignments; 433 | sourceTree = ""; 434 | }; 435 | AE83841A2597B63400A4C5A7 /* Bookmarks List */ = { 436 | isa = PBXGroup; 437 | children = ( 438 | AE8384172597B5AE00A4C5A7 /* BookmarksListView.swift */, 439 | AE12D7822597DDCB007A9EFB /* BookmarksListViewModel.swift */, 440 | ); 441 | path = "Bookmarks List"; 442 | sourceTree = ""; 443 | }; 444 | AE9541F1259B2D0C00B1AA15 /* Sidebar Tags Section */ = { 445 | isa = PBXGroup; 446 | children = ( 447 | AE9541F2259B2D1E00B1AA15 /* TagsSectionView.swift */, 448 | ); 449 | path = "Sidebar Tags Section"; 450 | sourceTree = ""; 451 | }; 452 | AE9541F5259B2D6C00B1AA15 /* Sidebar My Bookmarks Section */ = { 453 | isa = PBXGroup; 454 | children = ( 455 | AE9541F6259B2D8800B1AA15 /* MyBookmarksSectionView.swift */, 456 | AE9541F9259B2DD500B1AA15 /* MyBookmarksSectionViewModel.swift */, 457 | ); 458 | path = "Sidebar My Bookmarks Section"; 459 | sourceTree = ""; 460 | }; 461 | AE9F993C25A15F58001D2889 /* Private */ = { 462 | isa = PBXGroup; 463 | children = ( 464 | AE9F993D25A15F75001D2889 /* PrivateView.swift */, 465 | ); 466 | path = Private; 467 | sourceTree = ""; 468 | }; 469 | AE9F994225A1ABD9001D2889 /* Refresh */ = { 470 | isa = PBXGroup; 471 | children = ( 472 | AE9F994325A1AC12001D2889 /* RefreshView.swift */, 473 | AE9F994425A1AC12001D2889 /* RefreshViewModel.swift */, 474 | ); 475 | path = Refresh; 476 | sourceTree = ""; 477 | }; 478 | AEA3DA92259B35F100C4DB0F /* Sharing Service Picker */ = { 479 | isa = PBXGroup; 480 | children = ( 481 | AEA3DA93259B360600C4DB0F /* SharingServicePicker.swift */, 482 | ); 483 | path = "Sharing Service Picker"; 484 | sourceTree = ""; 485 | }; 486 | AEA5340225A4E20B00AF2481 /* SearchField */ = { 487 | isa = PBXGroup; 488 | children = ( 489 | AEA5340325A4E23200AF2481 /* SearchField.swift */, 490 | EA392DA0286E4B1700F1DF39 /* SearchBarViewModel.swift */, 491 | ); 492 | path = SearchField; 493 | sourceTree = ""; 494 | }; 495 | AEBFB697259413B1001626AA /* Extensions */ = { 496 | isa = PBXGroup; 497 | children = ( 498 | AEBFB698259413C8001626AA /* NSSortDescriptor+App.swift */, 499 | ); 500 | path = Extensions; 501 | sourceTree = ""; 502 | }; 503 | AEC4A94D259B9EF7000C65A8 /* Safari Button */ = { 504 | isa = PBXGroup; 505 | children = ( 506 | AEC4A94E259B9F09000C65A8 /* SafariButton.swift */, 507 | ); 508 | path = "Safari Button"; 509 | sourceTree = ""; 510 | }; 511 | AEC4A951259B9F1B000C65A8 /* Share Button */ = { 512 | isa = PBXGroup; 513 | children = ( 514 | AEC4A952259B9F38000C65A8 /* ShareButton.swift */, 515 | ); 516 | path = "Share Button"; 517 | sourceTree = ""; 518 | }; 519 | AEC6228B2591DAD3003FDE66 /* Network */ = { 520 | isa = PBXGroup; 521 | children = ( 522 | AEC6228C2591DB03003FDE66 /* NetworkService.swift */, 523 | AE9541EA259AB01A00B1AA15 /* NetworkActivityEvent.swift */, 524 | B5CE3A512838D2000014FE73 /* NetworkServiceError.swift */, 525 | ); 526 | path = Network; 527 | sourceTree = ""; 528 | }; 529 | AECC8C0525F8E158008568EE /* QRCode */ = { 530 | isa = PBXGroup; 531 | children = ( 532 | AECC8C0625F8E16C008568EE /* QRCodeView.swift */, 533 | ); 534 | path = QRCode; 535 | sourceTree = ""; 536 | }; 537 | AECC8C0925F945D8008568EE /* QRCode Button */ = { 538 | isa = PBXGroup; 539 | children = ( 540 | AECC8C0A25F9460A008568EE /* QRCodeButton.swift */, 541 | ); 542 | path = "QRCode Button"; 543 | sourceTree = ""; 544 | }; 545 | AECE16F02591BD3F0024DC88 /* Persistence */ = { 546 | isa = PBXGroup; 547 | children = ( 548 | AEC622882591DA6F003FDE66 /* PersistenceService.swift */, 549 | AECE16FC2591BEAA0024DC88 /* Core Data */, 550 | AECE16FD2591BEB10024DC88 /* Core Data Models */, 551 | ); 552 | path = Persistence; 553 | sourceTree = ""; 554 | }; 555 | AECE16FC2591BEAA0024DC88 /* Core Data */ = { 556 | isa = PBXGroup; 557 | children = ( 558 | AECE16F12591BD640024DC88 /* Pinboarding.xcdatamodeld */, 559 | ); 560 | path = "Core Data"; 561 | sourceTree = ""; 562 | }; 563 | AECE16FD2591BEB10024DC88 /* Core Data Models */ = { 564 | isa = PBXGroup; 565 | children = ( 566 | AE10FAE92594D54500C79B82 /* Bookmark+CoreDataClass.swift */, 567 | AE10FAE82594D54500C79B82 /* Bookmark+CoreDataProperties.swift */, 568 | AE10FAE62594D54500C79B82 /* Tag+CoreDataClass.swift */, 569 | AE10FAE72594D54500C79B82 /* Tag+CoreDataProperties.swift */, 570 | ); 571 | path = "Core Data Models"; 572 | sourceTree = ""; 573 | }; 574 | AED88E15264E9E6000EE0CB3 /* Predicting TextField */ = { 575 | isa = PBXGroup; 576 | children = ( 577 | AED88E16264E9E9100EE0CB3 /* PredictingTextField.swift */, 578 | ); 579 | path = "Predicting TextField"; 580 | sourceTree = ""; 581 | }; 582 | AEEBE55525E94062009A0404 /* Search */ = { 583 | isa = PBXGroup; 584 | children = ( 585 | AEAB957925A51FD900F4B7D3 /* SearchStore.swift */, 586 | ); 587 | path = Search; 588 | sourceTree = ""; 589 | }; 590 | AEEBE55625E9406C009A0404 /* Settings */ = { 591 | isa = PBXGroup; 592 | children = ( 593 | AE2F588C258EB84D00D19421 /* SettingsStore.swift */, 594 | AE628CC5258FA2D300F91800 /* SettingsStoreChange.swift */, 595 | ); 596 | path = Settings; 597 | sourceTree = ""; 598 | }; 599 | AEEBE55725E94077009A0404 /* Token */ = { 600 | isa = PBXGroup; 601 | children = ( 602 | AEED4D6F25BB7CFB00756570 /* SecureStore.swift */, 603 | AE8F4DDC25BD9687007E6053 /* TokenStoreProtocol.swift */, 604 | ); 605 | path = Token; 606 | sourceTree = ""; 607 | }; 608 | AEF0DF05258EC88B000F4BFF /* Settings General */ = { 609 | isa = PBXGroup; 610 | children = ( 611 | AEF0DF07258EC8A8000F4BFF /* GeneralView.swift */, 612 | AEF0DF10258ECB8D000F4BFF /* GeneralViewModel.swift */, 613 | ); 614 | path = "Settings General"; 615 | sourceTree = ""; 616 | }; 617 | B52AFFAD28560FE80074CF65 /* Add Button */ = { 618 | isa = PBXGroup; 619 | children = ( 620 | B52AFFAE28560FF10074CF65 /* AddView.swift */, 621 | ); 622 | path = "Add Button"; 623 | sourceTree = ""; 624 | }; 625 | B5308B1C2841DE76006A9B11 /* Factories */ = { 626 | isa = PBXGroup; 627 | children = ( 628 | B5308B1D2841DE87006A9B11 /* ViewModelFactory.swift */, 629 | ); 630 | path = Factories; 631 | sourceTree = ""; 632 | }; 633 | B58526E92855F15200853428 /* Offline Button */ = { 634 | isa = PBXGroup; 635 | children = ( 636 | B58526EC2855F17600853428 /* OfflineView.swift */, 637 | B58526EA2855F16C00853428 /* OfflineViewModel.swift */, 638 | ); 639 | path = "Offline Button"; 640 | sourceTree = ""; 641 | }; 642 | E211257F28891B7100ED217A /* Search Text */ = { 643 | isa = PBXGroup; 644 | children = ( 645 | E211258028891B8100ED217A /* SearchTextView.swift */, 646 | ); 647 | path = "Search Text"; 648 | sourceTree = ""; 649 | }; 650 | E214C0DF28995CCF00954004 /* Micro.blog Button */ = { 651 | isa = PBXGroup; 652 | children = ( 653 | E214C0E028995CF100954004 /* MicroBlogButton.swift */, 654 | E214C0E2289970DC00954004 /* MicroBlogButtonViewModel.swift */, 655 | ); 656 | path = "Micro.blog Button"; 657 | sourceTree = ""; 658 | }; 659 | E2BB493A2897D8F500937C3D /* Bookmark Action Popover */ = { 660 | isa = PBXGroup; 661 | children = ( 662 | E2BB493B2897D90900937C3D /* BookmarkActionPopoverView.swift */, 663 | E2BB493C2897D90900937C3D /* BookmarkActionPopoverViewModel.swift */, 664 | ); 665 | path = "Bookmark Action Popover"; 666 | sourceTree = ""; 667 | }; 668 | /* End PBXGroup section */ 669 | 670 | /* Begin PBXNativeTarget section */ 671 | AE792FFF258675DB00A68947 /* Pinboarding */ = { 672 | isa = PBXNativeTarget; 673 | buildConfigurationList = AE793010258675DC00A68947 /* Build configuration list for PBXNativeTarget "Pinboarding" */; 674 | buildPhases = ( 675 | AE792FFC258675DB00A68947 /* Sources */, 676 | AE792FFD258675DB00A68947 /* Frameworks */, 677 | AE792FFE258675DB00A68947 /* Resources */, 678 | ); 679 | buildRules = ( 680 | ); 681 | dependencies = ( 682 | ); 683 | name = Pinboarding; 684 | packageProductDependencies = ( 685 | B5F2203428413859003A667E /* MicroContainer */, 686 | E245D1A92848B8970085DF60 /* MicroPinboard */, 687 | ); 688 | productName = Pinboarding; 689 | productReference = AE793000258675DB00A68947 /* Pinboarding.app */; 690 | productType = "com.apple.product-type.application"; 691 | }; 692 | /* End PBXNativeTarget section */ 693 | 694 | /* Begin PBXProject section */ 695 | AE792FF8258675DB00A68947 /* Project object */ = { 696 | isa = PBXProject; 697 | attributes = { 698 | LastSwiftUpdateCheck = 1230; 699 | LastUpgradeCheck = 1330; 700 | TargetAttributes = { 701 | AE792FFF258675DB00A68947 = { 702 | CreatedOnToolsVersion = 12.3; 703 | }; 704 | }; 705 | }; 706 | buildConfigurationList = AE792FFB258675DB00A68947 /* Build configuration list for PBXProject "Pinboarding" */; 707 | compatibilityVersion = "Xcode 9.3"; 708 | developmentRegion = en; 709 | hasScannedForEncodings = 0; 710 | knownRegions = ( 711 | en, 712 | Base, 713 | ); 714 | mainGroup = AE792FF7258675DB00A68947; 715 | packageReferences = ( 716 | B5F2203328413859003A667E /* XCRemoteSwiftPackageReference "MicroContainer" */, 717 | E245D1A82848B8970085DF60 /* XCRemoteSwiftPackageReference "MicroPinboardAPI" */, 718 | ); 719 | productRefGroup = AE793001258675DB00A68947 /* Products */; 720 | projectDirPath = ""; 721 | projectRoot = ""; 722 | targets = ( 723 | AE792FFF258675DB00A68947 /* Pinboarding */, 724 | ); 725 | }; 726 | /* End PBXProject section */ 727 | 728 | /* Begin PBXResourcesBuildPhase section */ 729 | AE792FFE258675DB00A68947 /* Resources */ = { 730 | isa = PBXResourcesBuildPhase; 731 | buildActionMask = 2147483647; 732 | files = ( 733 | AE79300B258675DC00A68947 /* Preview Assets.xcassets in Resources */, 734 | AE793008258675DC00A68947 /* Assets.xcassets in Resources */, 735 | ); 736 | runOnlyForDeploymentPostprocessing = 0; 737 | }; 738 | /* End PBXResourcesBuildPhase section */ 739 | 740 | /* Begin PBXSourcesBuildPhase section */ 741 | AE792FFC258675DB00A68947 /* Sources */ = { 742 | isa = PBXSourcesBuildPhase; 743 | buildActionMask = 2147483647; 744 | files = ( 745 | B58526EB2855F16C00853428 /* OfflineViewModel.swift in Sources */, 746 | AE6D05CE25BC920B00BEDDD4 /* SecureStorage.swift in Sources */, 747 | EA392DA1286E4B1700F1DF39 /* SearchBarViewModel.swift in Sources */, 748 | AEC6228D2591DB03003FDE66 /* NetworkService.swift in Sources */, 749 | EAD3F678285F1B6D008C4332 /* MainViewModel.swift in Sources */, 750 | AE0F3B12258A0F9200227428 /* SidebarView.swift in Sources */, 751 | AE039704258E5B3900D744FB /* LoginViewModel.swift in Sources */, 752 | AE00EA07258AD5B1002D9558 /* PinboardRepository.swift in Sources */, 753 | AE9541FA259B2DD500B1AA15 /* MyBookmarksSectionViewModel.swift in Sources */, 754 | E214C0E128995CF100954004 /* MicroBlogButton.swift in Sources */, 755 | AE2E0AA525AB71530077CBDC /* BookmarkCommands.swift in Sources */, 756 | AE12D7832597DDCB007A9EFB /* BookmarksListViewModel.swift in Sources */, 757 | E2BB49402897F67500937C3D /* Preview+BookmarkViewModel.swift in Sources */, 758 | AEA5340425A4E23200AF2481 /* SearchField.swift in Sources */, 759 | B58526ED2855F17600853428 /* OfflineView.swift in Sources */, 760 | AE10FAEC2594D54500C79B82 /* Bookmark+CoreDataProperties.swift in Sources */, 761 | AE10FAEA2594D54500C79B82 /* Tag+CoreDataClass.swift in Sources */, 762 | AE317AF92588FD9D00B7420A /* SettingsView.swift in Sources */, 763 | AE10FAED2594D54500C79B82 /* Bookmark+CoreDataClass.swift in Sources */, 764 | E214C0E3289970DC00954004 /* MicroBlogButtonViewModel.swift in Sources */, 765 | AEED4D7025BB7CFB00756570 /* SecureStore.swift in Sources */, 766 | AE9541EE259AC30600B1AA15 /* Preview+Networking.swift in Sources */, 767 | AEBFB5062589D9FF001521A2 /* BookmarkView.swift in Sources */, 768 | E211258128891B8100ED217A /* SearchTextView.swift in Sources */, 769 | AE8F4DD925BD93A3007E6053 /* Preview+TokenStore.swift in Sources */, 770 | AEAB957A25A51FD900F4B7D3 /* SearchStore.swift in Sources */, 771 | AE0F3B18258A339C00227428 /* SidebarItemView.swift in Sources */, 772 | AEC72E112597890E008C7FF3 /* Preview.swift in Sources */, 773 | AE793006258675DB00A68947 /* MainView.swift in Sources */, 774 | B5308B1E2841DE87006A9B11 /* ViewModelFactory.swift in Sources */, 775 | AEF0DF11258ECB8D000F4BFF /* GeneralViewModel.swift in Sources */, 776 | E2BB493D2897D90900937C3D /* BookmarkActionPopoverView.swift in Sources */, 777 | AECC8C0B25F9460A008568EE /* QRCodeButton.swift in Sources */, 778 | AE78871E261B256700FBCC38 /* Preview+AppEnvironment.swift in Sources */, 779 | AE9541F7259B2D8800B1AA15 /* MyBookmarksSectionView.swift in Sources */, 780 | AEF4F4782589529200FF8F45 /* AddBookmarkView.swift in Sources */, 781 | AEA3DA94259B360600C4DB0F /* SharingServicePicker.swift in Sources */, 782 | AEBFB5072589D9FF001521A2 /* BookmarkViewModel.swift in Sources */, 783 | B5CE3A522838D2000014FE73 /* NetworkServiceError.swift in Sources */, 784 | AE9541F3259B2D1E00B1AA15 /* TagsSectionView.swift in Sources */, 785 | AEE78D9A258BAF17001CA0EA /* Booleans+Repository.swift in Sources */, 786 | AEC6229B259222B4003FDE66 /* Post+Repository.swift in Sources */, 787 | AE7EE6BA25A9B3C0004EDEB9 /* TwoColumnsAlignment.swift in Sources */, 788 | AEF8A7B925C154A300E081B5 /* Preview+SearchStore.swift in Sources */, 789 | AE793004258675DB00A68947 /* PinboardingApp.swift in Sources */, 790 | EAD3F67C285F26E7008C4332 /* Preview+NWPathMonitorPathPublishing.swift in Sources */, 791 | AE0F3B13258A0F9200227428 /* SidebarViewModel.swift in Sources */, 792 | AE8384182597B5AE00A4C5A7 /* BookmarksListView.swift in Sources */, 793 | E2BB493E2897D90900937C3D /* BookmarkActionPopoverViewModel.swift in Sources */, 794 | AEC72E0E259780FF008C7FF3 /* Preview+Persistence.swift in Sources */, 795 | AE3FC50E25E0680E00A91B3A /* NWPathMonitor.swift in Sources */, 796 | AECE16F32591BD640024DC88 /* Pinboarding.xcdatamodeld in Sources */, 797 | AE9F994625A1AC12001D2889 /* RefreshViewModel.swift in Sources */, 798 | AE8F4DDD25BD9687007E6053 /* TokenStoreProtocol.swift in Sources */, 799 | AE9F993E25A15F75001D2889 /* PrivateView.swift in Sources */, 800 | AE9F994525A1AC12001D2889 /* RefreshView.swift in Sources */, 801 | AE0F3A5E258A07CA00227428 /* BookmarksView.swift in Sources */, 802 | AE10FAEB2594D54500C79B82 /* Tag+CoreDataProperties.swift in Sources */, 803 | AED5883A25FA2F9100E31B33 /* Asset.swift in Sources */, 804 | B52AFFAF28560FF10074CF65 /* AddView.swift in Sources */, 805 | AE5781F8258DE42F00A524F4 /* LoginView.swift in Sources */, 806 | AEC622892591DA6F003FDE66 /* PersistenceService.swift in Sources */, 807 | AEBFB68A25940F4D001626AA /* PinboardingAppEnvironment.swift in Sources */, 808 | AE83841025979D1100A4C5A7 /* Preview+Settings.swift in Sources */, 809 | AED88E17264E9E9100EE0CB3 /* PredictingTextField.swift in Sources */, 810 | EAD3F67A285F233E008C4332 /* Preview+Dependencies.swift in Sources */, 811 | AE0F3B19258A339C00227428 /* SidebarPrimaryItem.swift in Sources */, 812 | AEF0DF09258EC8A8000F4BFF /* GeneralView.swift in Sources */, 813 | AE9541EB259AB01A00B1AA15 /* NetworkActivityEvent.swift in Sources */, 814 | AEC4A94F259B9F09000C65A8 /* SafariButton.swift in Sources */, 815 | AEC4A953259B9F38000C65A8 /* ShareButton.swift in Sources */, 816 | AEF4F4792589529200FF8F45 /* AddBookmarkViewModel.swift in Sources */, 817 | AEBFB699259413C8001626AA /* NSSortDescriptor+App.swift in Sources */, 818 | AE628CC6258FA2D300F91800 /* SettingsStoreChange.swift in Sources */, 819 | AECC8C0725F8E16C008568EE /* QRCodeView.swift in Sources */, 820 | AE2F588D258EB84D00D19421 /* SettingsStore.swift in Sources */, 821 | ); 822 | runOnlyForDeploymentPostprocessing = 0; 823 | }; 824 | /* End PBXSourcesBuildPhase section */ 825 | 826 | /* Begin XCBuildConfiguration section */ 827 | AE79300E258675DC00A68947 /* Debug */ = { 828 | isa = XCBuildConfiguration; 829 | buildSettings = { 830 | ALWAYS_SEARCH_USER_PATHS = NO; 831 | CLANG_ANALYZER_NONNULL = YES; 832 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 833 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 834 | CLANG_CXX_LIBRARY = "libc++"; 835 | CLANG_ENABLE_MODULES = YES; 836 | CLANG_ENABLE_OBJC_ARC = YES; 837 | CLANG_ENABLE_OBJC_WEAK = YES; 838 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 839 | CLANG_WARN_BOOL_CONVERSION = YES; 840 | CLANG_WARN_COMMA = YES; 841 | CLANG_WARN_CONSTANT_CONVERSION = YES; 842 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 843 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 844 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 845 | CLANG_WARN_EMPTY_BODY = YES; 846 | CLANG_WARN_ENUM_CONVERSION = YES; 847 | CLANG_WARN_INFINITE_RECURSION = YES; 848 | CLANG_WARN_INT_CONVERSION = YES; 849 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 850 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 851 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 852 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 853 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 854 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 855 | CLANG_WARN_STRICT_PROTOTYPES = YES; 856 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 857 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 858 | CLANG_WARN_UNREACHABLE_CODE = YES; 859 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 860 | COPY_PHASE_STRIP = NO; 861 | DEBUG_INFORMATION_FORMAT = dwarf; 862 | ENABLE_STRICT_OBJC_MSGSEND = YES; 863 | ENABLE_TESTABILITY = YES; 864 | GCC_C_LANGUAGE_STANDARD = gnu11; 865 | GCC_DYNAMIC_NO_PIC = NO; 866 | GCC_NO_COMMON_BLOCKS = YES; 867 | GCC_OPTIMIZATION_LEVEL = 0; 868 | GCC_PREPROCESSOR_DEFINITIONS = ( 869 | "DEBUG=1", 870 | "$(inherited)", 871 | ); 872 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 873 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 874 | GCC_WARN_UNDECLARED_SELECTOR = YES; 875 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 876 | GCC_WARN_UNUSED_FUNCTION = YES; 877 | GCC_WARN_UNUSED_VARIABLE = YES; 878 | MACOSX_DEPLOYMENT_TARGET = 12.0; 879 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 880 | MTL_FAST_MATH = YES; 881 | ONLY_ACTIVE_ARCH = YES; 882 | SDKROOT = macosx; 883 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 884 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 885 | }; 886 | name = Debug; 887 | }; 888 | AE79300F258675DC00A68947 /* Release */ = { 889 | isa = XCBuildConfiguration; 890 | buildSettings = { 891 | ALWAYS_SEARCH_USER_PATHS = NO; 892 | CLANG_ANALYZER_NONNULL = YES; 893 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 894 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 895 | CLANG_CXX_LIBRARY = "libc++"; 896 | CLANG_ENABLE_MODULES = YES; 897 | CLANG_ENABLE_OBJC_ARC = YES; 898 | CLANG_ENABLE_OBJC_WEAK = YES; 899 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 900 | CLANG_WARN_BOOL_CONVERSION = YES; 901 | CLANG_WARN_COMMA = YES; 902 | CLANG_WARN_CONSTANT_CONVERSION = YES; 903 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 904 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 905 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 906 | CLANG_WARN_EMPTY_BODY = YES; 907 | CLANG_WARN_ENUM_CONVERSION = YES; 908 | CLANG_WARN_INFINITE_RECURSION = YES; 909 | CLANG_WARN_INT_CONVERSION = YES; 910 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 911 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 912 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 913 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 914 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 915 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 916 | CLANG_WARN_STRICT_PROTOTYPES = YES; 917 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 918 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 919 | CLANG_WARN_UNREACHABLE_CODE = YES; 920 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 921 | COPY_PHASE_STRIP = NO; 922 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 923 | ENABLE_NS_ASSERTIONS = NO; 924 | ENABLE_STRICT_OBJC_MSGSEND = YES; 925 | GCC_C_LANGUAGE_STANDARD = gnu11; 926 | GCC_NO_COMMON_BLOCKS = YES; 927 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 928 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 929 | GCC_WARN_UNDECLARED_SELECTOR = YES; 930 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 931 | GCC_WARN_UNUSED_FUNCTION = YES; 932 | GCC_WARN_UNUSED_VARIABLE = YES; 933 | MACOSX_DEPLOYMENT_TARGET = 12.0; 934 | MTL_ENABLE_DEBUG_INFO = NO; 935 | MTL_FAST_MATH = YES; 936 | SDKROOT = macosx; 937 | SWIFT_COMPILATION_MODE = wholemodule; 938 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 939 | }; 940 | name = Release; 941 | }; 942 | AE793011258675DC00A68947 /* Debug */ = { 943 | isa = XCBuildConfiguration; 944 | buildSettings = { 945 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 946 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 947 | CODE_SIGN_ENTITLEMENTS = "Pinboarding/Supporting Files/Pinboarding.entitlements"; 948 | CODE_SIGN_IDENTITY = "Apple Development"; 949 | CODE_SIGN_STYLE = Automatic; 950 | COMBINE_HIDPI_IMAGES = YES; 951 | DEVELOPMENT_ASSET_PATHS = "Pinboarding/Preview\\ Content"; 952 | DEVELOPMENT_TEAM = JU92V989UQ; 953 | ENABLE_HARDENED_RUNTIME = YES; 954 | ENABLE_PREVIEWS = YES; 955 | INFOPLIST_FILE = "Pinboarding/Supporting Files/Info.plist"; 956 | LD_RUNPATH_SEARCH_PATHS = ( 957 | "$(inherited)", 958 | "@executable_path/../Frameworks", 959 | ); 960 | MACOSX_DEPLOYMENT_TARGET = 12.0; 961 | MARKETING_VERSION = 1.2; 962 | PRODUCT_BUNDLE_IDENTIFIER = com.otaviocc.Pinboarding; 963 | PRODUCT_NAME = "$(TARGET_NAME)"; 964 | SWIFT_VERSION = 5.0; 965 | }; 966 | name = Debug; 967 | }; 968 | AE793012258675DC00A68947 /* Release */ = { 969 | isa = XCBuildConfiguration; 970 | buildSettings = { 971 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 972 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 973 | CODE_SIGN_ENTITLEMENTS = "Pinboarding/Supporting Files/Pinboarding.entitlements"; 974 | CODE_SIGN_IDENTITY = "Apple Development"; 975 | CODE_SIGN_STYLE = Automatic; 976 | COMBINE_HIDPI_IMAGES = YES; 977 | DEVELOPMENT_ASSET_PATHS = "Pinboarding/Preview\\ Content"; 978 | DEVELOPMENT_TEAM = JU92V989UQ; 979 | ENABLE_HARDENED_RUNTIME = YES; 980 | ENABLE_PREVIEWS = YES; 981 | INFOPLIST_FILE = "Pinboarding/Supporting Files/Info.plist"; 982 | LD_RUNPATH_SEARCH_PATHS = ( 983 | "$(inherited)", 984 | "@executable_path/../Frameworks", 985 | ); 986 | MACOSX_DEPLOYMENT_TARGET = 12.0; 987 | MARKETING_VERSION = 1.2; 988 | PRODUCT_BUNDLE_IDENTIFIER = com.otaviocc.Pinboarding; 989 | PRODUCT_NAME = "$(TARGET_NAME)"; 990 | SWIFT_VERSION = 5.0; 991 | }; 992 | name = Release; 993 | }; 994 | /* End XCBuildConfiguration section */ 995 | 996 | /* Begin XCConfigurationList section */ 997 | AE792FFB258675DB00A68947 /* Build configuration list for PBXProject "Pinboarding" */ = { 998 | isa = XCConfigurationList; 999 | buildConfigurations = ( 1000 | AE79300E258675DC00A68947 /* Debug */, 1001 | AE79300F258675DC00A68947 /* Release */, 1002 | ); 1003 | defaultConfigurationIsVisible = 0; 1004 | defaultConfigurationName = Release; 1005 | }; 1006 | AE793010258675DC00A68947 /* Build configuration list for PBXNativeTarget "Pinboarding" */ = { 1007 | isa = XCConfigurationList; 1008 | buildConfigurations = ( 1009 | AE793011258675DC00A68947 /* Debug */, 1010 | AE793012258675DC00A68947 /* Release */, 1011 | ); 1012 | defaultConfigurationIsVisible = 0; 1013 | defaultConfigurationName = Release; 1014 | }; 1015 | /* End XCConfigurationList section */ 1016 | 1017 | /* Begin XCRemoteSwiftPackageReference section */ 1018 | B5F2203328413859003A667E /* XCRemoteSwiftPackageReference "MicroContainer" */ = { 1019 | isa = XCRemoteSwiftPackageReference; 1020 | repositoryURL = "https://github.com/otaviocc/MicroContainer"; 1021 | requirement = { 1022 | kind = upToNextMajorVersion; 1023 | minimumVersion = 0.0.1; 1024 | }; 1025 | }; 1026 | E245D1A82848B8970085DF60 /* XCRemoteSwiftPackageReference "MicroPinboardAPI" */ = { 1027 | isa = XCRemoteSwiftPackageReference; 1028 | repositoryURL = "https://github.com/otaviocc/MicroPinboardAPI.git"; 1029 | requirement = { 1030 | kind = upToNextMajorVersion; 1031 | minimumVersion = 2.0.0; 1032 | }; 1033 | }; 1034 | /* End XCRemoteSwiftPackageReference section */ 1035 | 1036 | /* Begin XCSwiftPackageProductDependency section */ 1037 | B5F2203428413859003A667E /* MicroContainer */ = { 1038 | isa = XCSwiftPackageProductDependency; 1039 | package = B5F2203328413859003A667E /* XCRemoteSwiftPackageReference "MicroContainer" */; 1040 | productName = MicroContainer; 1041 | }; 1042 | E245D1A92848B8970085DF60 /* MicroPinboard */ = { 1043 | isa = XCSwiftPackageProductDependency; 1044 | package = E245D1A82848B8970085DF60 /* XCRemoteSwiftPackageReference "MicroPinboardAPI" */; 1045 | productName = MicroPinboard; 1046 | }; 1047 | /* End XCSwiftPackageProductDependency section */ 1048 | 1049 | /* Begin XCVersionGroup section */ 1050 | AECE16F12591BD640024DC88 /* Pinboarding.xcdatamodeld */ = { 1051 | isa = XCVersionGroup; 1052 | children = ( 1053 | AECE16F22591BD640024DC88 /* Pinboarding.xcdatamodel */, 1054 | ); 1055 | currentVersion = AECE16F22591BD640024DC88 /* Pinboarding.xcdatamodel */; 1056 | path = Pinboarding.xcdatamodeld; 1057 | sourceTree = ""; 1058 | versionGroupType = wrapper.xcdatamodel; 1059 | }; 1060 | /* End XCVersionGroup section */ 1061 | }; 1062 | rootObject = AE792FF8258675DB00A68947 /* Project object */; 1063 | } 1064 | -------------------------------------------------------------------------------- /Pinboarding.xcodeproj/xcshareddata/xcschemes/Pinboarding.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 57 | 58 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /Pinboarding/Alignments/TwoColumnsAlignment.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct TwoColumnsAlignment: AlignmentID { 4 | 5 | /// Default value for building a table with two columns, 6 | /// where the left one is aligned to right and the right 7 | /// one, to the left. 8 | static func defaultValue( 9 | in context: ViewDimensions 10 | ) -> CGFloat { 11 | context[.leading] 12 | } 13 | } 14 | 15 | extension HorizontalAlignment { 16 | 17 | /// The two-column horizontal alignment. 18 | static let twoColumns: HorizontalAlignment = 19 | HorizontalAlignment(TwoColumnsAlignment.self) 20 | } 21 | 22 | extension View { 23 | 24 | /// View modifier to align the right column to the 25 | /// left side. 26 | func rightColumnAlignmentGuide( 27 | ) -> some View { 28 | alignmentGuide(.twoColumns) { $0[.leading] } 29 | } 30 | } 31 | 32 | // MARK: - PreviewProvider 33 | 34 | struct SettingsAlignment_Previews: PreviewProvider { 35 | 36 | static var previews: some View { 37 | Group { 38 | VStack(alignment: .twoColumns, spacing: 8) { 39 | HStack { 40 | Text("Country") 41 | .font(.title) 42 | .fontWeight(.bold) 43 | Text("Population") 44 | .font(.title) 45 | .fontWeight(.bold) 46 | .rightColumnAlignmentGuide() 47 | } 48 | HStack { 49 | Text("China") 50 | Text("1,406,124,800") 51 | .rightColumnAlignmentGuide() 52 | } 53 | HStack { 54 | Text("India") 55 | Text("1,371,890,046") 56 | .rightColumnAlignmentGuide() 57 | } 58 | HStack { 59 | Text("United States") 60 | Text("330,962,156") 61 | .rightColumnAlignmentGuide() 62 | } 63 | HStack { 64 | Text("Indonesia") 65 | Text("269,603,400") 66 | .rightColumnAlignmentGuide() 67 | } 68 | } 69 | .padding() 70 | .preferredColorScheme(.dark) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Pinboarding/Assets/Asset.swift: -------------------------------------------------------------------------------- 1 | enum Asset { 2 | 3 | enum Lock { 4 | static let closed = "lock" 5 | static let open = "lock.open" 6 | } 7 | 8 | enum QRCode { 9 | static let icon = "qrcode" 10 | static let placeholder = "square.dashed" 11 | } 12 | 13 | enum Action { 14 | static let add = "plus" 15 | static let refresh = "arrow.clockwise" 16 | static let share = "square.and.arrow.up" 17 | static let open = "safari" 18 | static let clear = "xmark.circle.fill" 19 | static let bookmark = "bookmark" 20 | } 21 | 22 | enum Bookmark { 23 | static let all = "bookmark" 24 | static let `public` = "person.2" 25 | static let `private` = "lock" 26 | static let unread = "envelope.badge" 27 | } 28 | 29 | enum Connection { 30 | static let online = "cloud" 31 | static let offline = "exclamationmark.triangle" 32 | } 33 | 34 | enum Tag { 35 | static let icon = "tag" 36 | } 37 | 38 | enum Warning { 39 | static let exclamation = "exclamationmark.triangle" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "universal", 6 | "reference" : "systemIndigoColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otaviocc/Pinboarding/f46e95936ad6bba41b0e00d138e5833f5b928a3f/Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otaviocc/Pinboarding/f46e95936ad6bba41b0e00d138e5833f5b928a3f/Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otaviocc/Pinboarding/f46e95936ad6bba41b0e00d138e5833f5b928a3f/Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/256-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otaviocc/Pinboarding/f46e95936ad6bba41b0e00d138e5833f5b928a3f/Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/256-1.png -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otaviocc/Pinboarding/f46e95936ad6bba41b0e00d138e5833f5b928a3f/Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/32-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otaviocc/Pinboarding/f46e95936ad6bba41b0e00d138e5833f5b928a3f/Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/32-1.png -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otaviocc/Pinboarding/f46e95936ad6bba41b0e00d138e5833f5b928a3f/Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/512-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otaviocc/Pinboarding/f46e95936ad6bba41b0e00d138e5833f5b928a3f/Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/512-1.png -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otaviocc/Pinboarding/f46e95936ad6bba41b0e00d138e5833f5b928a3f/Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otaviocc/Pinboarding/f46e95936ad6bba41b0e00d138e5833f5b928a3f/Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "32-1.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "256-1.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "512-1.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Pinboarding/Assets/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Pinboarding/Commands/BookmarkCommands.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct BookmarkCommands: Commands { 4 | 5 | // MARK: - Properties 6 | 7 | private let repository: PinboardRepositoryProtocol 8 | 9 | // MARK: - Life cycle 10 | 11 | init( 12 | repository: PinboardRepositoryProtocol 13 | ) { 14 | self.repository = repository 15 | } 16 | 17 | // MARK: - Public 18 | 19 | var body: some Commands { 20 | CommandGroup(replacing: .newItem) { } 21 | 22 | CommandMenu("Bookmarks") { 23 | Button("Refresh Bookmarks") { 24 | Task { 25 | await repository.forceRefreshBookmarks() 26 | } 27 | } 28 | .keyboardShortcut("r") 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Pinboarding/Extensions/NSSortDescriptor+App.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension NSSortDescriptor { 4 | 5 | /// Sorts by time in descending direction, 6 | /// used to sort bookmarks. 7 | static func makeSortByTimeDescending( 8 | ) -> NSSortDescriptor { 9 | .init( 10 | key: "time", 11 | ascending: false 12 | ) 13 | } 14 | 15 | /// Sorts by name in ascending direction, 16 | /// used to sort tags on the sidebar. 17 | static func makeSortByNameAscending( 18 | ) -> NSSortDescriptor { 19 | .init( 20 | key: "name", 21 | ascending: true 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Pinboarding/Factories/ViewModelFactory.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import MicroContainer 4 | import Network 5 | 6 | final class ViewModelFactory: ObservableObject { 7 | 8 | private let container: DependencyContainer 9 | 10 | init( 11 | container: DependencyContainer 12 | ) { 13 | self.container = container 14 | } 15 | 16 | func makeMainViewModel( 17 | ) -> MainViewModel { 18 | MainViewModel() 19 | } 20 | 21 | func makeAddBookmarkViewModel( 22 | ) -> AddBookmarkViewModel { 23 | AddBookmarkViewModel( 24 | repository: container.resolve(), 25 | settingsStore: container.resolve() 26 | ) 27 | } 28 | 29 | func makeRefreshViewModel( 30 | ) -> RefreshViewModel { 31 | RefreshViewModel( 32 | repository: container.resolve() 33 | ) 34 | } 35 | 36 | func makeGeneralViewModel( 37 | ) -> GeneralViewModel { 38 | GeneralViewModel( 39 | settingsStore: container.resolve() 40 | ) 41 | } 42 | 43 | func makeBookmarkViewModel( 44 | bookmark: Bookmark 45 | ) -> BookmarkViewModel { 46 | BookmarkViewModel( 47 | bookmark: bookmark, 48 | settingsStore: container.resolve() 49 | ) 50 | } 51 | 52 | func makeBookmarkActionPopoverViewModel( 53 | isPrivate: Bool, 54 | title: String, 55 | url: URL 56 | ) -> BookmarkActionPopoverViewModel { 57 | BookmarkActionPopoverViewModel( 58 | isPrivate: isPrivate, 59 | title: title, 60 | url: url, 61 | settingsStore: container.resolve() 62 | ) 63 | } 64 | 65 | func makeMicroBlogButtonViewModel( 66 | url: URL 67 | ) -> MicroBlogButtonViewModel { 68 | MicroBlogButtonViewModel( 69 | url: url 70 | ) 71 | } 72 | 73 | func makeMyBookmarksSectionViewModel( 74 | ) -> MyBookmarksSectionViewModel { 75 | MyBookmarksSectionViewModel() 76 | } 77 | 78 | func makeLoginViewModel( 79 | ) -> LoginViewModel { 80 | LoginViewModel( 81 | tokenStore: container.resolve() 82 | ) 83 | } 84 | 85 | func makeOfflineViewModel( 86 | ) -> OfflineViewModel { 87 | let publisher: NWPathMonitorPathPublishing = container.resolve() 88 | 89 | return OfflineViewModel( 90 | pathMonitorPublisher: publisher.pathPublisher() 91 | ) 92 | } 93 | 94 | func makeSearchBarViewModel( 95 | ) -> SearchTextViewModel { 96 | SearchTextViewModel( 97 | searchStore: container.resolve() 98 | ) 99 | } 100 | 101 | func makeSidebarViewModel( 102 | ) -> SidebarViewModel { 103 | let searchStore: SearchStore = container.resolve() 104 | let repository: PinboardRepositoryProtocol = container.resolve() 105 | let publisher = repository.networkActivityPublisher() 106 | 107 | return SidebarViewModel( 108 | networkActivityPublisher: publisher, 109 | searchStore: searchStore 110 | ) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Pinboarding/PinboardingApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Foundation 3 | 4 | @main struct PinboardingApp: App { 5 | 6 | // MARK: - Properties 7 | 8 | private let environment = PinboardingAppEnvironment() 9 | 10 | // MARK: - Public 11 | 12 | var body: some Scene { 13 | WindowGroup { 14 | MainView( 15 | viewModel: environment.viewModelFactory.makeMainViewModel() 16 | ) 17 | .environmentObject(environment.viewModelFactory) 18 | .environmentObject(environment.searchStore) 19 | .environment( 20 | \.managedObjectContext, 21 | environment.persistenceService.container.viewContext 22 | ) 23 | } 24 | .commands { 25 | SidebarCommands() 26 | 27 | BookmarkCommands( 28 | repository: environment.repository 29 | ) 30 | } 31 | 32 | Settings { 33 | SettingsView() 34 | .environmentObject(environment.viewModelFactory) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Pinboarding/PinboardingAppEnvironment.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import MicroClient 3 | import MicroPinboard 4 | import MicroContainer 5 | import Network 6 | 7 | final class PinboardingAppEnvironment { 8 | 9 | // MARK: - Properties 10 | 11 | private let container = DependencyContainer() 12 | 13 | var viewModelFactory: ViewModelFactory { container.resolve() } 14 | var repository: PinboardRepositoryProtocol { container.resolve() } 15 | var searchStore: SearchStore { container.resolve() } 16 | var persistenceService: PersistenceServiceProtocol { container.resolve() } 17 | 18 | // MARK: - Life cycle 19 | 20 | init( 21 | ) { 22 | container.register( 23 | type: SearchStore.self, 24 | allocation: .static 25 | ) { _ in 26 | SearchStore() 27 | } 28 | 29 | container.register( 30 | type: SettingsStore.self, 31 | allocation: .static 32 | ) { _ in 33 | SettingsStore( 34 | userDefaults: .standard 35 | ) 36 | } 37 | 38 | container.register( 39 | type: TokenStoreProtocol.self, 40 | allocation: .static 41 | ) { _ in 42 | SecureStore() 43 | } 44 | 45 | container.register( 46 | type: PersistenceServiceProtocol.self, 47 | allocation: .static 48 | ) { _ in 49 | PersistenceService( 50 | inMemory: false 51 | ) 52 | } 53 | 54 | container.register( 55 | type: NetworkClientProtocol.self, 56 | allocation: .static 57 | ) { container in 58 | PinboardAPIFactory() 59 | .makePinboardAPIClient( 60 | userToken: { 61 | (container.resolve() as TokenStoreProtocol).authToken 62 | } 63 | ) 64 | } 65 | 66 | container.register( 67 | type: NetworkServiceProtocol.self, 68 | allocation: .static 69 | ) { container in 70 | NetworkService( 71 | settingsStore: container.resolve(), 72 | networkClient: container.resolve() 73 | ) 74 | } 75 | 76 | container.register( 77 | type: PinboardRepositoryProtocol.self, 78 | allocation: .static 79 | ) { container in 80 | PinboardRepository( 81 | networkService: container.resolve(), 82 | persistenceService: container.resolve() 83 | ) 84 | } 85 | 86 | container.register( 87 | type: NWPathMonitorPathPublishing.self, 88 | allocation: .static 89 | ) { _ in 90 | NWPathMonitor() 91 | } 92 | 93 | container.register( 94 | type: ViewModelFactory.self, 95 | allocation: .static 96 | ) { container in 97 | ViewModelFactory( 98 | container: container 99 | ) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Pinboarding/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Pinboarding/Preview Content/Preview+AppEnvironment.swift: -------------------------------------------------------------------------------- 1 | import MicroClient 2 | import MicroPinboard 3 | import MicroContainer 4 | import Network 5 | 6 | final class PreviewAppEnvironment { 7 | 8 | // MARK: - Properties 9 | 10 | private let container = DependencyContainer() 11 | 12 | var viewModelFactory: ViewModelFactory { container.resolve() } 13 | var repository: PinboardRepositoryProtocol { container.resolve() } 14 | var settingsStore: SettingsStore { container.resolve() } 15 | var tokenStore: TokenStoreProtocol { container.resolve() } 16 | var searchStore: SearchStore { container.resolve() } 17 | var persistenceService: PersistenceServiceProtocol { container.resolve() } 18 | 19 | // MARK: - Life cycle 20 | 21 | init( 22 | ) { 23 | container.register( 24 | type: SearchStore.self, 25 | allocation: .static 26 | ) { _ in 27 | Preview.makeSearchStore() 28 | } 29 | 30 | container.register( 31 | type: SettingsStore.self, 32 | allocation: .static 33 | ) { _ in 34 | Preview.makeSettingsStore() 35 | } 36 | 37 | container.register( 38 | type: TokenStoreProtocol.self, 39 | allocation: .static 40 | ) { _ in 41 | Preview.makeTokenStore( 42 | authToken: "token" 43 | ) 44 | } 45 | 46 | container.register( 47 | type: PersistenceServiceProtocol.self, 48 | allocation: .static 49 | ) { _ in 50 | Preview.makePersistenceController( 51 | populated: true 52 | ) 53 | } 54 | 55 | container.register( 56 | type: NetworkClientProtocol.self, 57 | allocation: .static 58 | ) { container in 59 | PinboardAPIFactory().makePinboardAPIClient( 60 | userToken: { 61 | (container.resolve() as TokenStoreProtocol).authToken 62 | } 63 | ) 64 | } 65 | 66 | container.register( 67 | type: NetworkServiceProtocol.self, 68 | allocation: .static 69 | ) { container in 70 | NetworkService( 71 | settingsStore: container.resolve(), 72 | networkClient: container.resolve() 73 | ) 74 | } 75 | 76 | container.register( 77 | type: PinboardRepositoryProtocol.self, 78 | allocation: .static 79 | ) { container in 80 | PinboardRepository( 81 | networkService: container.resolve(), 82 | persistenceService: container.resolve() 83 | ) 84 | } 85 | 86 | container.register( 87 | type: NWPathMonitorPathPublishing.self, 88 | allocation: .static 89 | ) { _ in 90 | Preview.makePathMonitorPublisher( 91 | isOnline: false 92 | ) 93 | } 94 | 95 | container.register( 96 | type: ViewModelFactory.self, 97 | allocation: .static 98 | ) { container in 99 | ViewModelFactory( 100 | container: container 101 | ) 102 | } 103 | } 104 | } 105 | 106 | let previewAppEnvironment = PreviewAppEnvironment() 107 | -------------------------------------------------------------------------------- /Pinboarding/Preview Content/Preview+BookmarkViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Preview { 4 | 5 | static func makeBookmarkViewModel( 6 | ) -> BookmarkViewModel { 7 | BookmarkViewModel( 8 | title: "Lorem Ipsum", 9 | description: "Nulla purus urna, fermentum eu tristique non, bibendum nec purus.", 10 | tags: "tag1, tag2, tag3, tag4", 11 | url: URL(string: "https://otaviocc.github.io")!, 12 | hostURL: "OTAVIO.CC", 13 | iconURL: nil, 14 | isPrivate: true, 15 | shouldShowWebsiteIcon: true 16 | ) 17 | } 18 | 19 | static func makeEmptyBookmarkViewModel( 20 | ) -> BookmarkViewModel { 21 | BookmarkViewModel( 22 | title: "Lorem Ipsum", 23 | description: "", 24 | tags: "tag1, tag2, tag3, tag4", 25 | url: URL(string: "https://otaviocc.github.io")!, 26 | hostURL: "OTAVIO.CC", 27 | iconURL: nil, 28 | isPrivate: true, 29 | shouldShowWebsiteIcon: false 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Pinboarding/Preview Content/Preview+Dependencies.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | 5 | func withPreviewDependencies( 6 | ) -> some View { 7 | self 8 | .environmentObject(previewAppEnvironment.viewModelFactory) 9 | .environmentObject(previewAppEnvironment.searchStore) 10 | .environment( 11 | \.managedObjectContext, 12 | previewAppEnvironment.persistenceService.container.viewContext 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Pinboarding/Preview Content/Preview+NWPathMonitorPathPublishing.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import Network 4 | 5 | extension Preview { 6 | 7 | // MARK: - Nested types 8 | 9 | final class PathMonitorPublisher: NWPathMonitorPathPublishing { 10 | 11 | private let isOnline: Bool 12 | 13 | init( 14 | isOnline: Bool 15 | ) { 16 | self.isOnline = isOnline 17 | } 18 | 19 | func pathPublisher( 20 | ) -> AnyPublisher { 21 | pathPublisher(queue: .main) 22 | } 23 | 24 | func pathPublisher( 25 | queue: DispatchQueue 26 | ) -> AnyPublisher { 27 | Just(isOnline ? .satisfied : .unsatisfied) 28 | .eraseToAnyPublisher() 29 | } 30 | } 31 | 32 | /// Creates a publisher which Publishes network status for 33 | /// Swift UI Previews. 34 | static func makePathMonitorPublisher( 35 | isOnline: Bool 36 | ) -> PathMonitorPublisher { 37 | PathMonitorPublisher( 38 | isOnline: isOnline 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Pinboarding/Preview Content/Preview+Networking.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Network 3 | 4 | extension Preview { 5 | 6 | /// Publishes network activities for 7 | /// Swift UI Previews. 8 | static func makeNetworkActivityPublisher( 9 | loading: Bool 10 | ) -> AnyPublisher { 11 | Just(loading ? .loading : .finishedLoading) 12 | .eraseToAnyPublisher() 13 | } 14 | 15 | /// Publishes network status for 16 | /// Swift UI Previews. 17 | static func makeNetworkStatusPublisher( 18 | isOnline: Bool 19 | ) -> AnyPublisher { 20 | Just(isOnline ? .satisfied : .unsatisfied) 21 | .eraseToAnyPublisher() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Pinboarding/Preview Content/Preview+Persistence.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CoreData 3 | import MicroPinboard 4 | 5 | extension Preview { 6 | 7 | /// In memory Persistence Controller for SwiftUI Previews. 8 | static func makePersistenceController( 9 | populated: Bool = false 10 | ) -> PersistenceService { 11 | let controller = PersistenceService( 12 | inMemory: true 13 | ) 14 | 15 | if populated { 16 | makeBookmarks( 17 | count: 3, 18 | in: controller.container.viewContext 19 | ) 20 | } 21 | 22 | return controller 23 | } 24 | 25 | /// Generates (and adds do Core Data) bookmarks 26 | /// for SwiftUI Previews. 27 | static func makeBookmarks( 28 | count: Int, 29 | in context: NSManagedObjectContext 30 | ) { 31 | for i in 0.. SearchStore { 6 | SearchStore() 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Pinboarding/Preview Content/Preview+Settings.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Preview { 4 | 5 | /// User Defaults Store for SwiftUI Previews. 6 | static func makeSettingsStore( 7 | isPrivate: Bool = true, 8 | isToRead: Bool = false, 9 | showMicroBlog: Bool = true 10 | ) -> SettingsStore { 11 | let store = SettingsStore( 12 | userDefaults: makePreviewUserDefaults() 13 | ) 14 | 15 | store.isPrivate = isPrivate 16 | store.isToRead = isToRead 17 | store.showMicroBlog = showMicroBlog 18 | 19 | return store 20 | } 21 | 22 | /// User Defaults for SwiftUI Previews. 23 | /// It has a different Suite Name so it doesn't 24 | /// conflict with the Standard one. 25 | static func makePreviewUserDefaults( 26 | ) -> UserDefaults { 27 | UserDefaults( 28 | suiteName: "Preview" 29 | )! 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Pinboarding/Preview Content/Preview+TokenStore.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | extension Preview { 4 | 5 | // MARK: - Nested types 6 | 7 | final class InMemoryTokenStore: TokenStoreProtocol, ObservableObject { 8 | var authToken: String? 9 | 10 | init( 11 | authToken: String? 12 | ) { 13 | self.authToken = authToken 14 | } 15 | } 16 | 17 | /// In memory Token Store for SwiftUI Previews, so 18 | /// that KeyChain isn't accesses for previews. 19 | static func makeTokenStore( 20 | authToken: String 21 | ) -> InMemoryTokenStore { 22 | InMemoryTokenStore( 23 | authToken: authToken 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Pinboarding/Preview Content/Preview.swift: -------------------------------------------------------------------------------- 1 | /// Top level fixture type. Meant to be 2 | /// extended to provide data to SwiftUI Previews. 3 | enum Preview { } 4 | -------------------------------------------------------------------------------- /Pinboarding/Property Wrappers/SecureStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @propertyWrapper 4 | struct SecureStorage { 5 | 6 | // MARK: - Nested types 7 | 8 | typealias ItemDeleter = (CFDictionary) -> OSStatus 9 | typealias ItemAdder = 10 | (CFDictionary, UnsafeMutablePointer?) -> OSStatus 11 | typealias ItemCopyMatcher = 12 | (CFDictionary, UnsafeMutablePointer?) -> OSStatus 13 | 14 | // MARK: - Properties 15 | 16 | var wrappedValue: String? { 17 | get { 18 | guard 19 | let data = load(key: key) 20 | else { 21 | return nil 22 | } 23 | 24 | return String(data: data, encoding: .utf8) 25 | } 26 | 27 | set { 28 | guard 29 | let value = newValue, 30 | let data = value.data(using: .utf8) 31 | else { 32 | delete(key) 33 | return 34 | } 35 | 36 | save(key: key, data: data) 37 | } 38 | } 39 | 40 | private let key: String 41 | private let itemDeleter: ItemDeleter 42 | private let itemAdder: ItemAdder 43 | private let itemCopyMatcher: ItemCopyMatcher 44 | 45 | // MARK: - Life cycle 46 | 47 | init( 48 | _ key: String, 49 | itemDeleter: @escaping ItemDeleter = SecItemDelete, 50 | itemAdder: @escaping ItemAdder = SecItemAdd, 51 | itemCopyMatcher: @escaping ItemCopyMatcher = SecItemCopyMatching 52 | ) { 53 | self.key = key 54 | self.itemDeleter = itemDeleter 55 | self.itemAdder = itemAdder 56 | self.itemCopyMatcher = itemCopyMatcher 57 | } 58 | 59 | // MARK: - Private 60 | 61 | @discardableResult 62 | private func save( 63 | key: String, 64 | data: Data 65 | ) -> OSStatus { 66 | let query = [ 67 | kSecClass as String: kSecClassGenericPassword, 68 | kSecAttrAccount as String: key, 69 | kSecValueData as String: data 70 | ] as [String: Any] 71 | 72 | _ = itemDeleter( 73 | query as CFDictionary 74 | ) 75 | 76 | return itemAdder( 77 | query as CFDictionary, 78 | nil 79 | ) 80 | } 81 | 82 | private func load( 83 | key: String 84 | ) -> Data? { 85 | let query = [ 86 | kSecClass as String: kSecClassGenericPassword, 87 | kSecAttrAccount as String: key, 88 | kSecReturnData as String: kCFBooleanTrue!, 89 | kSecMatchLimit as String: kSecMatchLimitOne 90 | ] as [String: Any] 91 | 92 | var data: AnyObject? 93 | let status = itemCopyMatcher( 94 | query as CFDictionary, 95 | &data 96 | ) 97 | 98 | if status == noErr { 99 | return data as? Data 100 | } else { 101 | return nil 102 | } 103 | } 104 | 105 | @discardableResult 106 | private func delete( 107 | _ key: String 108 | ) -> OSStatus { 109 | let query = [ 110 | kSecClass as String: kSecClassGenericPassword, 111 | kSecAttrAccount as String: key, 112 | ] as [String: Any] 113 | 114 | return itemDeleter( 115 | query as CFDictionary 116 | ) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Pinboarding/Publishers/NWPathMonitor.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Network 3 | 4 | protocol NWPathMonitorPathPublishing { 5 | func pathPublisher( 6 | ) -> AnyPublisher 7 | 8 | func pathPublisher( 9 | queue: DispatchQueue 10 | ) -> AnyPublisher 11 | } 12 | 13 | extension NWPathMonitor: NWPathMonitorPathPublishing { 14 | 15 | func pathPublisher( 16 | ) -> AnyPublisher { 17 | pathPublisher(queue: .global(qos: .background)) 18 | } 19 | 20 | public func pathPublisher( 21 | queue: DispatchQueue 22 | ) -> AnyPublisher { 23 | PathMonitorPublisher( 24 | monitor: self, 25 | queue: queue 26 | ) 27 | .eraseToAnyPublisher() 28 | } 29 | } 30 | 31 | extension NWPathMonitor { 32 | 33 | public struct PathMonitorPublisher: Publisher { 34 | 35 | // MARK: - Nested types 36 | 37 | public typealias Output = NWPath.Status 38 | public typealias Failure = Never 39 | 40 | // MARK: - Properties 41 | 42 | private let monitor: NWPathMonitor 43 | private let queue: DispatchQueue 44 | 45 | // MARK: - Life cycle 46 | 47 | fileprivate init( 48 | monitor: NWPathMonitor, 49 | queue: DispatchQueue 50 | ) { 51 | self.monitor = monitor 52 | self.queue = queue 53 | } 54 | 55 | // MARK: - Public 56 | 57 | public func receive( 58 | subscriber: S 59 | ) where S: Subscriber, S.Failure == Failure, S.Input == Output { 60 | let subscription = PathMonitorSubscription( 61 | subscriber: subscriber, 62 | monitor: monitor, 63 | queue: queue 64 | ) 65 | 66 | subscriber.receive( 67 | subscription: subscription 68 | ) 69 | } 70 | } 71 | 72 | private final class PathMonitorSubscription: Subscription where S.Input == NWPath.Status { 73 | 74 | // MARK: - Nested types 75 | 76 | private let subscriber: S 77 | private let monitor: NWPathMonitor 78 | private let queue: DispatchQueue 79 | 80 | // MARK: - Life cycle 81 | 82 | init( 83 | subscriber: S, 84 | monitor: NWPathMonitor, 85 | queue: DispatchQueue 86 | ) { 87 | self.subscriber = subscriber 88 | self.monitor = monitor 89 | self.queue = queue 90 | } 91 | 92 | // MARK: - Public 93 | 94 | func request( 95 | _ demand: Subscribers.Demand 96 | ) { 97 | guard 98 | demand == .unlimited, 99 | monitor.pathUpdateHandler == nil 100 | else { 101 | return 102 | } 103 | 104 | monitor.pathUpdateHandler = { path in 105 | _ = self.subscriber.receive(path.status) 106 | } 107 | 108 | monitor.start( 109 | queue: queue 110 | ) 111 | } 112 | 113 | func cancel() { 114 | monitor.cancel() 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Pinboarding/Repository/Extensions/Booleans+Repository.swift: -------------------------------------------------------------------------------- 1 | // Pinboard uses a literal yes/no as boolean. 2 | 3 | extension String { 4 | 5 | /// Returns `true` for "yes", and `false` for 6 | /// everything else. 7 | var booleanValue: Bool { 8 | self == "yes" ? true : false 9 | } 10 | } 11 | 12 | extension Bool { 13 | 14 | /// Returns "yes" for `true`, and `no` for 15 | /// everything else. 16 | var stringValue: String { 17 | self == true ? "yes" : "no" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Pinboarding/Repository/Extensions/Post+Repository.swift: -------------------------------------------------------------------------------- 1 | import CoreData 2 | import MicroPinboard 3 | 4 | extension Bookmark { 5 | 6 | /// Converts PostResponses to Bookmarks, adding 7 | /// them to Core Data. 8 | @discardableResult 9 | static func makeBookmark( 10 | from postResponse: PostResponse, 11 | in context: NSManagedObjectContext 12 | ) throws -> Bookmark { 13 | let bookmark = Bookmark( 14 | context: context 15 | ) 16 | 17 | bookmark.href = postResponse.href 18 | bookmark.title = postResponse.description 19 | bookmark.abstract = postResponse.extended 20 | bookmark.meta = postResponse.meta 21 | bookmark.time = postResponse.time 22 | bookmark.isShared = postResponse.shared.booleanValue 23 | bookmark.isToRead = postResponse.toread.booleanValue 24 | bookmark.id = postResponse.hash 25 | 26 | let tags = postResponse.tags 27 | .split(separator: " ") 28 | .map(String.init) 29 | .map { name -> Tag in 30 | let tag = Tag(context: context) 31 | tag.name = name.lowercased() 32 | return tag 33 | } 34 | bookmark.tags = NSSet(array: tags) 35 | 36 | try context.save() 37 | 38 | return bookmark 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Pinboarding/Repository/Network/NetworkActivityEvent.swift: -------------------------------------------------------------------------------- 1 | enum NetworkActivityEvent { 2 | case loading 3 | case finishedLoading 4 | } 5 | -------------------------------------------------------------------------------- /Pinboarding/Repository/Network/NetworkService.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import MicroClient 4 | import MicroPinboard 5 | 6 | protocol NetworkServiceProtocol { 7 | 8 | /// Publishes all bookmarks during recurring 9 | /// updates. 10 | func allBookmarksUpdatesPublisher( 11 | ) -> AnyPublisher<[PostResponse], Never> 12 | 13 | /// Publishes the network status to update the UI 14 | /// during update requests. 15 | func networkActivityPublisher( 16 | ) -> AnyPublisher 17 | 18 | /// Returns all saved bookmark. 19 | func allBookmarks( 20 | ) async throws -> [PostResponse] 21 | 22 | /// Adds a new bookmark. 23 | func addBookmark( 24 | url: URL, 25 | description: String, 26 | extended: String?, 27 | tags: String?, 28 | date: Date?, 29 | replace: String?, 30 | shared: String?, 31 | toread: String? 32 | ) async throws -> PostResponse 33 | } 34 | 35 | final class NetworkService: NetworkServiceProtocol { 36 | 37 | // MARK: - Properties 38 | 39 | private var cancellables = Set() 40 | private let networkClient: NetworkClientProtocol 41 | private let settingsStore: SettingsStore 42 | private let postResponseSubject = 43 | PassthroughSubject<[PostResponse], Never>() 44 | 45 | #if DEBUG 46 | let refreshInterval = 30.0 47 | #else 48 | let refreshInterval = 300.0 49 | #endif 50 | 51 | // MARK: - Life cycle 52 | 53 | init( 54 | settingsStore: SettingsStore, 55 | networkClient: NetworkClientProtocol 56 | ) { 57 | self.settingsStore = settingsStore 58 | self.networkClient = networkClient 59 | 60 | recentBookmarksPublisher() 61 | .receive(on: RunLoop.main) 62 | .flatMap(Just.init) 63 | .sink( 64 | receiveCompletion: { _ in }, 65 | receiveValue: { 66 | self.postResponseSubject.send($0) 67 | } 68 | ) 69 | .store(in: &cancellables) 70 | } 71 | 72 | // MARK: - Public 73 | 74 | func allBookmarksUpdatesPublisher( 75 | ) -> AnyPublisher<[PostResponse], Never> { 76 | postResponseSubject 77 | .eraseToAnyPublisher() 78 | } 79 | 80 | func networkActivityPublisher( 81 | ) -> AnyPublisher { 82 | networkClient.statusPublisher() 83 | .map { status in 84 | switch status { 85 | case .running: return .loading 86 | case .idle: return .finishedLoading 87 | } 88 | } 89 | .eraseToAnyPublisher() 90 | } 91 | 92 | func allBookmarks( 93 | ) async throws -> [PostResponse] { 94 | guard try await needsBookmarksUpdate() else { 95 | throw NetworkServiceError.noNeedToSync 96 | } 97 | 98 | let request = PostsAPIFactory.makeAllRequest() 99 | let response = try await networkClient.run(request) 100 | 101 | return response.value 102 | } 103 | 104 | func addBookmark( 105 | url: URL, 106 | description: String, 107 | extended: String? = nil, 108 | tags: String? = nil, 109 | date: Date? = nil, 110 | replace: String? = nil, 111 | shared: String? = nil, 112 | toread: String? = nil 113 | ) async throws -> PostResponse { 114 | _ = try await addNewBookmark( 115 | url: url, 116 | description: description, 117 | extended: extended, 118 | tags: tags, 119 | date: date, 120 | replace: replace, 121 | shared: shared, 122 | toread: toread 123 | ) 124 | 125 | return try await lastBookmark() 126 | } 127 | 128 | // MARK: - Private 129 | 130 | private func timerPublisher( 131 | ) -> AnyPublisher { 132 | Deferred { 133 | Just(Date()) 134 | } 135 | .append( 136 | Timer.TimerPublisher( 137 | interval: refreshInterval, 138 | runLoop: .main, 139 | mode: .common 140 | ) 141 | .autoconnect() 142 | ) 143 | .eraseToAnyPublisher() 144 | } 145 | 146 | /// Publishes all bookmarks every x minutes. 147 | private func recentBookmarksPublisher( 148 | ) -> AnyPublisher<[PostResponse], Error> { 149 | timerPublisher() 150 | .flatMap { _ in 151 | Future { promise in 152 | Task { 153 | do { 154 | let allBookmarks = try await self.allBookmarks() 155 | promise(.success(allBookmarks)) 156 | } catch { 157 | print("Recent bookmarks: \(error)") 158 | } 159 | } 160 | } 161 | } 162 | .eraseToAnyPublisher() 163 | } 164 | 165 | /// Returns the latest saved bookmark. 166 | private func lastBookmark( 167 | ) async throws -> PostResponse { 168 | let request = PostsAPIFactory.makeRecentRequest(count: 1) 169 | let response = try await networkClient.run(request) 170 | 171 | guard let bookmark = response.value.posts.first else { 172 | throw NetworkServiceError.missingBookmark 173 | } 174 | 175 | return bookmark 176 | } 177 | 178 | private func needsBookmarksUpdate( 179 | ) async throws -> Bool { 180 | let request = UpdateAPIFactory.makeUpdateRequest() 181 | let response = try await networkClient.run(request) 182 | 183 | guard settingsStore.lastSyncDate != response.value.updateTime else { 184 | return false 185 | } 186 | 187 | settingsStore.lastSyncDate = response.value.updateTime 188 | 189 | return true 190 | } 191 | 192 | private func addNewBookmark( 193 | url: URL, 194 | description: String, 195 | extended: String? = nil, 196 | tags: String? = nil, 197 | date: Date? = nil, 198 | replace: String? = nil, 199 | shared: String? = nil, 200 | toread: String? = nil 201 | ) async throws -> Bool { 202 | let request = PostsAPIFactory.makeAddRequest( 203 | url: url, 204 | description: description, 205 | extended: extended, 206 | tags: tags, 207 | date: date, 208 | replace: replace, 209 | shared: shared, 210 | toread: toread 211 | ) 212 | let response = try await networkClient.run(request) 213 | 214 | return response.value.resultCode == "done" 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /Pinboarding/Repository/Network/NetworkServiceError.swift: -------------------------------------------------------------------------------- 1 | enum NetworkServiceError: Error { 2 | case missingBookmark 3 | case noNeedToSync 4 | } 5 | -------------------------------------------------------------------------------- /Pinboarding/Repository/Persistence/Core Data Models/Bookmark+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | import CoreData 2 | 3 | @objc(Bookmark) public class Bookmark: NSManagedObject { } 4 | -------------------------------------------------------------------------------- /Pinboarding/Repository/Persistence/Core Data Models/Bookmark+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | import CoreData 2 | 3 | public extension Bookmark { 4 | 5 | // MARK: - Properties 6 | 7 | @NSManaged var abstract: String 8 | @NSManaged var href: String 9 | @NSManaged var id: String 10 | @NSManaged var meta: String 11 | @NSManaged var isShared: Bool 12 | @NSManaged var time: Date 13 | @NSManaged var title: String 14 | @NSManaged var isToRead: Bool 15 | @NSManaged var tags: NSSet 16 | 17 | // MARK: - Public 18 | 19 | @nonobjc 20 | static func fetchRequest( 21 | ) -> NSFetchRequest { 22 | NSFetchRequest( 23 | entityName: "Bookmark" 24 | ) 25 | } 26 | 27 | // MARK: - Relationships 28 | 29 | @objc(addTagsObject:) 30 | @NSManaged func addToTags( 31 | _ value: Tag 32 | ) 33 | 34 | @objc(removeTagsObject:) 35 | @NSManaged func removeFromTags( 36 | _ value: Tag 37 | ) 38 | 39 | @objc(addTags:) 40 | @NSManaged func addToTags( 41 | _ values: NSSet 42 | ) 43 | 44 | @objc(removeTags:) 45 | @NSManaged func removeFromTags( 46 | _ values: NSSet 47 | ) 48 | } 49 | 50 | extension Bookmark: Identifiable { } 51 | -------------------------------------------------------------------------------- /Pinboarding/Repository/Persistence/Core Data Models/Tag+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | import CoreData 2 | 3 | @objc(Tag) public class Tag: NSManagedObject { } 4 | -------------------------------------------------------------------------------- /Pinboarding/Repository/Persistence/Core Data Models/Tag+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | import CoreData 2 | 3 | public extension Tag { 4 | 5 | // MARK: - Properties 6 | 7 | @NSManaged var name: String 8 | @NSManaged var bookmarks: NSSet 9 | 10 | // MARK: - Public 11 | 12 | @nonobjc 13 | static func fetchRequest( 14 | ) -> NSFetchRequest { 15 | NSFetchRequest( 16 | entityName: "Tag" 17 | ) 18 | } 19 | 20 | // MARK: - Relationships 21 | 22 | @objc(addBookmarkObject:) 23 | @NSManaged func addToBookmarks( 24 | _ value: Bookmark 25 | ) 26 | 27 | @objc(removeBookmarkObject:) 28 | @NSManaged func removeFromBookmarks( 29 | _ value: Bookmark 30 | ) 31 | 32 | @objc(addBookmark:) 33 | @NSManaged func addToBookmarks( 34 | _ values: NSSet 35 | ) 36 | 37 | @objc(removeBookmark:) 38 | @NSManaged func removeFromBookmarks( 39 | _ values: NSSet 40 | ) 41 | } 42 | 43 | extension Tag: Identifiable { } 44 | -------------------------------------------------------------------------------- /Pinboarding/Repository/Persistence/Core Data/Pinboarding.xcdatamodeld/Pinboarding.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /Pinboarding/Repository/Persistence/PersistenceService.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CoreData 3 | import MicroPinboard 4 | 5 | protocol PersistenceServiceProtocol { 6 | 7 | var container: NSPersistentContainer { get } 8 | 9 | /// Adds a new post to Core Data as a Bookmark. 10 | func appendNewPost( 11 | _ post: PostResponse 12 | ) 13 | 14 | /// Adds all posts to Core Data as Bookmarks, 15 | /// removing from Core Data posts that are not 16 | /// in the payload, and unused tags. 17 | func addAllPosts( 18 | _ posts: [PostResponse] 19 | ) 20 | } 21 | 22 | final class PersistenceService: PersistenceServiceProtocol { 23 | 24 | // MARK: - Properties 25 | 26 | let container: NSPersistentContainer 27 | 28 | // MARK: - Life cycle 29 | 30 | init( 31 | inMemory: Bool = false 32 | ) { 33 | self.container = NSPersistentContainer( 34 | name: "Pinboarding" 35 | ) 36 | 37 | if inMemory { 38 | let description = NSPersistentStoreDescription() 39 | description.url = URL(fileURLWithPath: "/dev/null") 40 | self.container.persistentStoreDescriptions = [description] 41 | } 42 | 43 | self.container.loadPersistentStores { [container] _, _ in 44 | container.viewContext.mergePolicy = 45 | NSMergeByPropertyObjectTrumpMergePolicy 46 | } 47 | } 48 | 49 | // MARK: - Public 50 | 51 | func appendNewPost( 52 | _ post: PostResponse 53 | ) { 54 | do { 55 | try Bookmark.makeBookmark( 56 | from: post, 57 | in: container.viewContext 58 | ) 59 | } catch { 60 | print("Something happened: \(error)") 61 | } 62 | } 63 | 64 | func addAllPosts( 65 | _ posts: [PostResponse] 66 | ) { 67 | addNewBookmarks(posts) 68 | removeDeletedBookmarks(posts) 69 | removeUnusedTags() 70 | } 71 | 72 | // MARK: - Private 73 | 74 | /// Stores all posts on Core Data as Bookmarks. 75 | private func addNewBookmarks( 76 | _ posts: [PostResponse] 77 | ) { 78 | posts.forEach { post in 79 | do { 80 | try Bookmark.makeBookmark( 81 | from: post, 82 | in: container.viewContext 83 | ) 84 | } catch { 85 | print("Something happened: \(error)") 86 | } 87 | } 88 | } 89 | 90 | /// Remove bookmarks from Core Data if deleted from 91 | /// Pinboard. 92 | private func removeDeletedBookmarks( 93 | _ posts: [PostResponse] 94 | ) { 95 | let request = NSFetchRequest( 96 | entityName: "Bookmark" 97 | ) 98 | 99 | request.predicate = NSPredicate( 100 | format: "NOT id IN %@", 101 | posts.map(\.hash) 102 | ) 103 | 104 | removeManagedObject( 105 | fetchRequest: request 106 | ) 107 | } 108 | 109 | /// Remove tags without associated bookmarks. 110 | private func removeUnusedTags( 111 | ) { 112 | let request = NSFetchRequest( 113 | entityName: "Tag" 114 | ) 115 | 116 | request.predicate = NSPredicate( 117 | format: "bookmarks.@count == 0" 118 | ) 119 | 120 | removeManagedObject( 121 | fetchRequest: request 122 | ) 123 | } 124 | 125 | /// Perform the removal from Core Data. 126 | private func removeManagedObject( 127 | fetchRequest: NSFetchRequest 128 | ) where Object: NSManagedObject { 129 | do { 130 | let objectsToRemove = try container.viewContext.fetch( 131 | fetchRequest 132 | ) as [Object] 133 | 134 | for object in objectsToRemove { 135 | container.viewContext.delete(object) 136 | } 137 | 138 | try container.viewContext.save() 139 | } catch { 140 | print("Something happened: \(error)") 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Pinboarding/Repository/PinboardRepository.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import MicroPinboard 4 | 5 | protocol PinboardRepositoryProtocol { 6 | 7 | /// Adds a new bookmark. 8 | func addBookmark( 9 | url: URL, 10 | title: String, 11 | description: String?, 12 | tags: String?, 13 | date: Date?, 14 | replace: Bool, 15 | shared: Bool, 16 | toread: Bool 17 | ) async throws 18 | 19 | /// Forces an update without waiting for the next 20 | /// x minutes to pass. 21 | func forceRefreshBookmarks( 22 | ) async 23 | 24 | /// Publishes the network status to update the UI 25 | /// during update requests. 26 | func networkActivityPublisher( 27 | ) -> AnyPublisher 28 | } 29 | 30 | final class PinboardRepository: PinboardRepositoryProtocol { 31 | 32 | // MARK: - Properties 33 | 34 | private let persistenceService: PersistenceServiceProtocol 35 | private let networkService: NetworkServiceProtocol 36 | private var cancellables = Set() 37 | 38 | // MARK: - Life cycle 39 | 40 | init( 41 | networkService: NetworkServiceProtocol, 42 | persistenceService: PersistenceServiceProtocol 43 | ) { 44 | self.networkService = networkService 45 | self.persistenceService = persistenceService 46 | 47 | networkService 48 | .allBookmarksUpdatesPublisher() 49 | .sink { bookmarks in 50 | persistenceService.addAllPosts(bookmarks) 51 | } 52 | .store(in: &cancellables) 53 | } 54 | 55 | func addBookmark( 56 | url: URL, 57 | title: String, 58 | description: String? = nil, 59 | tags: String? = nil, 60 | date: Date? = nil, 61 | replace: Bool = false, 62 | shared: Bool = false, 63 | toread: Bool = false 64 | ) async throws { 65 | let bookmark = try await networkService.addBookmark( 66 | url: url, 67 | description: title, 68 | extended: description, 69 | tags: tags, 70 | date: date, 71 | replace: replace.stringValue, 72 | shared: shared.stringValue, 73 | toread: toread.stringValue 74 | ) 75 | 76 | persistenceService.appendNewPost(bookmark) 77 | } 78 | 79 | func forceRefreshBookmarks( 80 | ) async { 81 | guard let bookmarks = try? await networkService.allBookmarks() else { 82 | return 83 | } 84 | 85 | DispatchQueue.main.async { [persistenceService] in 86 | persistenceService.addAllPosts(bookmarks) 87 | } 88 | } 89 | 90 | func networkActivityPublisher( 91 | ) -> AnyPublisher { 92 | networkService.networkActivityPublisher() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Pinboarding/Stores/Search/SearchStore.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | final class SearchStore: ObservableObject { 5 | 6 | // MARK: - Properties 7 | 8 | /// Search Term Input. 9 | @Published var currentSearchTerm: String = "" 10 | 11 | /// Search Term Output (debounce + remove duplicates). 12 | @Published private(set) var searchTerm: String = "" 13 | 14 | private var cancellables = Set() 15 | 16 | // MARK: - Life cycle 17 | 18 | init( 19 | ) { 20 | self.searchTermPublisher() 21 | .assign(to: \.searchTerm, on: self) 22 | .store(in: &cancellables) 23 | } 24 | 25 | // MARK: - Private 26 | 27 | private func searchTermPublisher( 28 | ) -> AnyPublisher { 29 | $currentSearchTerm 30 | .debounce(for: 0.3, scheduler: RunLoop.main) 31 | .removeDuplicates() 32 | .eraseToAnyPublisher() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Pinboarding/Stores/Settings/SettingsStore.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | final class SettingsStore { 5 | 6 | // MARK: - Nested types 7 | 8 | private enum Key { 9 | static let isPrivate = "settingsIsPrivate" 10 | static let isToRead = "settingsIsToRead" 11 | static let showMicroBlog = "settingsShowMicroBlog" 12 | static let showWebsiteIcons = "settingsWebsiteIcons" 13 | static let lastSyncDate = "syncLastUpdate" 14 | } 15 | 16 | // MARK: - Properties 17 | 18 | private let userDefaults: UserDefaults 19 | private let changesSubject = 20 | PassthroughSubject() 21 | 22 | /// Flag used to store preferences for new bookmarks. 23 | /// Bookmarks can be either private or public. 24 | var isPrivate: Bool { 25 | get { userDefaults.bool(forKey: Key.isPrivate) } 26 | set { 27 | userDefaults.setValue( 28 | newValue, 29 | forKey: Key.isPrivate 30 | ) 31 | changesSubject.send( 32 | .isPrivate(isPrivate) 33 | ) 34 | } 35 | } 36 | 37 | /// Flag used to store preferences for reading option. 38 | /// Bookmarks can be set as "read later". 39 | var isToRead: Bool { 40 | get { userDefaults.bool(forKey: Key.isToRead) } 41 | set { 42 | userDefaults.setValue( 43 | newValue, 44 | forKey: Key.isToRead 45 | ) 46 | changesSubject.send( 47 | .isToRead(isToRead) 48 | ) 49 | } 50 | } 51 | 52 | /// Uses to store the latest date when bookmarks where synchronized. 53 | /// Pinboard API's asks to check the date before making additional 54 | /// requests to retrieve all bookmarks. 55 | var lastSyncDate: Date? { 56 | get { userDefaults.object(forKey: Key.lastSyncDate) as? Date } 57 | set { 58 | userDefaults.setValue( 59 | newValue, 60 | forKey: Key.lastSyncDate 61 | ) 62 | changesSubject.send( 63 | .lastSyncDate(lastSyncDate) 64 | ) 65 | } 66 | } 67 | 68 | /// Flag used to store preferences for Micro.blog integration. 69 | var showMicroBlog: Bool { 70 | get { userDefaults.bool(forKey: Key.showMicroBlog) } 71 | set { 72 | userDefaults.setValue( 73 | newValue, 74 | forKey: Key.showMicroBlog 75 | ) 76 | changesSubject.send( 77 | .showMicroBlog(showMicroBlog) 78 | ) 79 | } 80 | } 81 | 82 | /// Flag used to store preferences website icons. 83 | var showWebsiteIcons: Bool { 84 | get { userDefaults.bool(forKey: Key.showWebsiteIcons) } 85 | set { 86 | userDefaults.setValue( 87 | newValue, 88 | forKey: Key.showWebsiteIcons 89 | ) 90 | changesSubject.send( 91 | .showWebsiteIcons(showWebsiteIcons) 92 | ) 93 | } 94 | } 95 | 96 | // MARK: - Life cycle 97 | 98 | init( 99 | userDefaults: UserDefaults 100 | ) { 101 | self.userDefaults = userDefaults 102 | } 103 | 104 | // MARK: - Public 105 | 106 | /// Publishes changes made to the store. 107 | func changesPublisher( 108 | ) -> AnyPublisher { 109 | changesSubject 110 | .eraseToAnyPublisher() 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Pinboarding/Stores/Settings/SettingsStoreChange.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum SettingsStoreChange { 4 | case isPrivate(_ value: Bool) 5 | case isToRead(_ value: Bool) 6 | case lastSyncDate(_ date: Date?) 7 | case showMicroBlog(_ value: Bool) 8 | case showWebsiteIcons(_ value: Bool) 9 | } 10 | -------------------------------------------------------------------------------- /Pinboarding/Stores/Token/SecureStore.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | final class SecureStore: ObservableObject { 4 | 5 | // MARK: - Properties 6 | 7 | /// Auth token required to perform the network requests. 8 | @SecureStorage("authToken") var authToken: String? 9 | } 10 | 11 | extension SecureStore: TokenStoreProtocol {} 12 | -------------------------------------------------------------------------------- /Pinboarding/Stores/Token/TokenStoreProtocol.swift: -------------------------------------------------------------------------------- 1 | protocol TokenStoreProtocol { 2 | var authToken: String? { get set } 3 | } 4 | -------------------------------------------------------------------------------- /Pinboarding/Supporting Files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | 1 21 | LSMinimumSystemVersion 22 | $(MACOSX_DEPLOYMENT_TARGET) 23 | 24 | 25 | -------------------------------------------------------------------------------- /Pinboarding/Supporting Files/Pinboarding.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Pinboarding/Views/Add Bookmark/AddBookmarkView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AddBookmarkView: View { 4 | 5 | // MARK: - Properties 6 | 7 | @ObservedObject private var viewModel: AddBookmarkViewModel 8 | 9 | @Binding private var isPresented: Bool 10 | 11 | @Environment(\.managedObjectContext) 12 | private var viewContext 13 | 14 | @FetchRequest(entity: Tag.entity(), sortDescriptors: [.makeSortByNameAscending()]) 15 | private var tags: FetchedResults 16 | 17 | // MARK: - Life cycle 18 | 19 | init( 20 | viewModel: AddBookmarkViewModel, 21 | isPresented: Binding 22 | ) { 23 | self.viewModel = viewModel 24 | self._isPresented = isPresented 25 | } 26 | 27 | // MARK: - Public 28 | 29 | var body: some View { 30 | VStack(alignment: .leading, spacing: 8) { 31 | makeInputView() 32 | 33 | HStack(alignment: .top) { 34 | makeSuggestionsView() 35 | Spacer() 36 | makeTogglesView() 37 | } 38 | .padding([.top, .bottom]) 39 | 40 | makeButtonsView() 41 | } 42 | .padding() 43 | .onReceive(viewModel.dismissViewPublisher()) { _ in 44 | isPresented.toggle() 45 | } 46 | } 47 | 48 | // MARK: - Private 49 | 50 | private func makeInputView( 51 | ) -> some View { 52 | Group { 53 | Text("URL") 54 | TextField("", text: $viewModel.urlString) 55 | 56 | Text("Title") 57 | TextField("", text: $viewModel.title) 58 | 59 | Text("Description") 60 | TextEditor(text: $viewModel.description) 61 | .frame(height: 100) 62 | 63 | Text("Tags") 64 | PredictingTextField( 65 | reference: tags.map(\.name), 66 | text: $viewModel.tags, 67 | predictions: $viewModel.suggestions 68 | ) 69 | } 70 | } 71 | 72 | @ViewBuilder 73 | private func makeSuggestionsView( 74 | ) -> some View { 75 | if viewModel.hasSuggestions { 76 | Text(viewModel.suggestions.joined(separator: " ")) 77 | } 78 | } 79 | 80 | private func makeTogglesView( 81 | ) -> some View { 82 | VStack(alignment: .trailing) { 83 | Toggle("Private", isOn: $viewModel.isPrivate) 84 | .toggleStyle(SwitchToggleStyle()) 85 | 86 | Toggle("Read later", isOn: $viewModel.isToRead) 87 | .toggleStyle(SwitchToggleStyle()) 88 | } 89 | } 90 | 91 | private func makeButtonsView( 92 | ) -> some View { 93 | HStack { 94 | Button("Cancel") { 95 | isPresented.toggle() 96 | } 97 | Spacer() 98 | Button("Add bookmark") { 99 | Task { 100 | await viewModel.addBookmark() 101 | } 102 | } 103 | .disabled(!viewModel.isValid) 104 | } 105 | } 106 | } 107 | 108 | // MARK: - PreviewProvider 109 | 110 | struct AddBookmarkView_Previews: PreviewProvider { 111 | 112 | static var previews: some View { 113 | Group { 114 | AddBookmarkView( 115 | viewModel: AddBookmarkViewModel( 116 | repository: previewAppEnvironment.repository, 117 | settingsStore: previewAppEnvironment.settingsStore 118 | ), 119 | isPresented: .constant(false) 120 | ) 121 | .frame(width: 640) 122 | .preferredColorScheme(.light) 123 | 124 | AddBookmarkView( 125 | viewModel: AddBookmarkViewModel( 126 | repository: previewAppEnvironment.repository, 127 | settingsStore: previewAppEnvironment.settingsStore 128 | ), 129 | isPresented: .constant(false) 130 | ) 131 | .preferredColorScheme(.dark) 132 | } 133 | .frame(width: 640) 134 | .previewLayout(.sizeThatFits) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Pinboarding/Views/Add Bookmark/AddBookmarkViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | final class AddBookmarkViewModel: ObservableObject { 5 | 6 | // MARK: - Properties 7 | 8 | @Published var urlString: String = "" 9 | @Published var title: String = "" 10 | @Published var description: String = "" 11 | @Published var tags: String = "" 12 | @Published var isPrivate: Bool = false 13 | @Published var isToRead: Bool = false 14 | @Published var suggestions: [String] = [] 15 | 16 | @Published private(set) var isValid: Bool = false 17 | @Published private(set) var urlMessage: String = "" 18 | @Published private(set) var titleMessage: String = "" 19 | @Published private(set) var hasSuggestions: Bool = false 20 | 21 | private let repository: PinboardRepositoryProtocol 22 | private let settingsStore: SettingsStore 23 | private var cancellables = Set() 24 | private let dismissViewSubject = 25 | PassthroughSubject() 26 | 27 | // MARK: - Life cycle 28 | 29 | init( 30 | repository: PinboardRepositoryProtocol, 31 | settingsStore: SettingsStore 32 | ) { 33 | self.repository = repository 34 | self.settingsStore = settingsStore 35 | 36 | isURLValidPublisher() 37 | .receive(on: RunLoop.main) 38 | .map { isValid in 39 | isValid ? "" : "Invalid URL" 40 | } 41 | .assign(to: \.urlMessage, on: self) 42 | .store(in: &cancellables) 43 | 44 | isTitleValidPublisher() 45 | .receive(on: RunLoop.main) 46 | .map { isValid in 47 | isValid ? "" : "Invalid Title" 48 | } 49 | .assign(to: \.titleMessage, on: self) 50 | .store(in: &cancellables) 51 | 52 | isFormValidPublisher() 53 | .receive(on: RunLoop.main) 54 | .assign(to: \.isValid, on: self) 55 | .store(in: &cancellables) 56 | 57 | hasSuggestionsPublisher() 58 | .receive(on: RunLoop.main) 59 | .assign(to: \.hasSuggestions, on: self) 60 | .store(in: &cancellables) 61 | 62 | isPrivate = settingsStore.isPrivate 63 | isToRead = settingsStore.isToRead 64 | } 65 | 66 | // MARK: - Public 67 | 68 | func addBookmark() async { 69 | guard let url = URL(string: urlString) else { 70 | return 71 | } 72 | 73 | _ = try? await repository.addBookmark( 74 | url: url, 75 | title: title, 76 | description: description, 77 | tags: tags, 78 | date: nil, 79 | replace: false, 80 | shared: !isPrivate, 81 | toread: isToRead 82 | ) 83 | 84 | DispatchQueue.main.async { [dismissViewSubject] in 85 | dismissViewSubject.send(true) 86 | } 87 | } 88 | 89 | func dismissViewPublisher( 90 | ) -> AnyPublisher { 91 | dismissViewSubject 92 | .eraseToAnyPublisher() 93 | } 94 | 95 | // MARK: - Private 96 | 97 | private func isURLValidPublisher( 98 | ) -> AnyPublisher { 99 | $urlString 100 | .debounce(for: 0.3, scheduler: DispatchQueue.main) 101 | .removeDuplicates() 102 | .map { URL(string: $0) != nil } 103 | .eraseToAnyPublisher() 104 | } 105 | 106 | private func isTitleValidPublisher( 107 | ) -> AnyPublisher { 108 | $title 109 | .debounce(for: 0.3, scheduler: DispatchQueue.main) 110 | .removeDuplicates() 111 | .map { !$0.isEmpty } 112 | .eraseToAnyPublisher() 113 | } 114 | 115 | private func isFormValidPublisher( 116 | ) -> AnyPublisher { 117 | Publishers.CombineLatest(isURLValidPublisher(), isTitleValidPublisher()) 118 | .map { $0 && $1 } 119 | .eraseToAnyPublisher() 120 | } 121 | 122 | private func hasSuggestionsPublisher( 123 | ) -> AnyPublisher { 124 | $suggestions 125 | .removeDuplicates() 126 | .map { !$0.isEmpty } 127 | .eraseToAnyPublisher() 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Pinboarding/Views/Add Button/AddView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AddView: View { 4 | 5 | @Binding private var showAddBookmark: Bool 6 | 7 | init( 8 | showAddBookmark: Binding 9 | ) { 10 | self._showAddBookmark = showAddBookmark 11 | } 12 | 13 | var body: some View { 14 | Button( 15 | action: { showAddBookmark.toggle() }, 16 | label: { Image(systemName: Asset.Action.add) } 17 | ) 18 | .help("Add a new bookmark") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Pinboarding/Views/Bookmark Action Popover/BookmarkActionPopoverView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct BookmarkActionPopoverView: View { 4 | 5 | // MARK: - Properties 6 | 7 | @ObservedObject private var viewModel: BookmarkActionPopoverViewModel 8 | @EnvironmentObject private var viewModelFactory: ViewModelFactory 9 | 10 | // MARK: - Life cycle 11 | 12 | init( 13 | viewModel: BookmarkActionPopoverViewModel 14 | ) { 15 | self.viewModel = viewModel 16 | } 17 | 18 | // MARK: - Public 19 | 20 | var body: some View { 21 | HStack(alignment: .center, spacing: 8) { 22 | QRCodeButton( 23 | url: viewModel.url 24 | ) 25 | 26 | SafariButton( 27 | url: viewModel.url 28 | ) 29 | 30 | if viewModel.showMicroBlog { 31 | MicroBlogButton( 32 | viewModel: viewModelFactory.makeMicroBlogButtonViewModel( 33 | url: viewModel.url 34 | ) 35 | ) 36 | } 37 | 38 | ShareButton( 39 | title: viewModel.title, 40 | url: viewModel.url 41 | ) 42 | } 43 | .padding() 44 | } 45 | } 46 | 47 | // MARK: - PreviewProvider 48 | 49 | struct BookmarkActionPopoverView_Previews: PreviewProvider { 50 | static var previews: some View { 51 | Group { 52 | BookmarkActionPopoverView( 53 | viewModel: .init( 54 | isPrivate: true, 55 | title: "Some fake title", 56 | url: URL(string: "https://otavio.cc")!, 57 | settingsStore: Preview.makeSettingsStore() 58 | ) 59 | ) 60 | .preferredColorScheme(.light) 61 | .previewLayout(.sizeThatFits) 62 | 63 | BookmarkActionPopoverView( 64 | viewModel: .init( 65 | isPrivate: true, 66 | title: "Some fake title", 67 | url: URL(string: "https://otavio.cc")!, 68 | settingsStore: Preview.makeSettingsStore( 69 | showMicroBlog: false 70 | ) 71 | ) 72 | ) 73 | .preferredColorScheme(.dark) 74 | .previewLayout(.sizeThatFits) 75 | } 76 | .withPreviewDependencies() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Pinboarding/Views/Bookmark Action Popover/BookmarkActionPopoverViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | final class BookmarkActionPopoverViewModel: ObservableObject { 5 | 6 | // MARK: - Properties 7 | 8 | let isPrivate: Bool 9 | let title: String 10 | let url: URL 11 | let showMicroBlog: Bool 12 | 13 | // MARK: - Life cycle 14 | 15 | init( 16 | isPrivate: Bool, 17 | title: String, 18 | url: URL, 19 | settingsStore: SettingsStore 20 | ) { 21 | self.isPrivate = isPrivate 22 | self.title = title 23 | self.url = url 24 | self.showMicroBlog = settingsStore.showMicroBlog 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Pinboarding/Views/Bookmark/BookmarkView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct BookmarkView: View { 5 | 6 | // MARK: - Properties 7 | 8 | @EnvironmentObject private var viewModelFactory: ViewModelFactory 9 | @ObservedObject private var viewModel: BookmarkViewModel 10 | @State private var isPopoverPresented = false 11 | 12 | 13 | // MARK: - Life cycle 14 | 15 | init( 16 | viewModel: BookmarkViewModel 17 | ) { 18 | self.viewModel = viewModel 19 | } 20 | 21 | // MARK: - Public 22 | 23 | var body: some View { 24 | HStack(alignment: .top, spacing: 8) { 25 | if viewModel.shouldShowWebsiteIcon { 26 | makeWebsideIcon() 27 | } 28 | 29 | VStack(alignment: .leading, spacing: 4) { 30 | makeHeader() 31 | 32 | HStack(alignment: .top) { 33 | Text(viewModel.title) 34 | .font(.title2) 35 | .foregroundColor(.primary) 36 | .help(viewModel.url.absoluteString) 37 | } 38 | 39 | if !viewModel.description.isEmpty { 40 | Text(viewModel.description) 41 | .font(.body) 42 | .foregroundColor(.secondary) 43 | .lineLimit(3) 44 | .truncationMode(.tail) 45 | } 46 | 47 | Text(viewModel.tags) 48 | .font(.footnote) 49 | .foregroundColor(.accentColor) 50 | } 51 | } 52 | .onTapGesture { 53 | isPopoverPresented = true 54 | } 55 | .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { 56 | makeBookmarkActionView() 57 | } 58 | .padding(4) 59 | } 60 | 61 | // MARK: - Private 62 | 63 | @ViewBuilder 64 | func makeWebsideIcon( 65 | ) -> some View { 66 | AsyncImage(url: viewModel.iconURL) { phase in 67 | if let image = phase.image { 68 | image 69 | } else { 70 | Image(systemName: "link") 71 | } 72 | } 73 | .frame(width: 16, height: 16) 74 | } 75 | 76 | @ViewBuilder 77 | func makeBookmarkActionView( 78 | ) -> some View { 79 | BookmarkActionPopoverView( 80 | viewModel: viewModelFactory.makeBookmarkActionPopoverViewModel( 81 | isPrivate: viewModel.isPrivate, 82 | title: viewModel.title, 83 | url: viewModel.url 84 | ) 85 | ) 86 | } 87 | 88 | @ViewBuilder 89 | func makeHeader( 90 | ) -> some View { 91 | HStack(alignment: .center, spacing: 2) { 92 | Text(viewModel.hostURL) 93 | .font(.caption) 94 | .foregroundColor(.secondary) 95 | 96 | PrivateView( 97 | isPrivate: viewModel.isPrivate 98 | ) 99 | .font(.caption) 100 | .foregroundColor(.secondary) 101 | } 102 | } 103 | } 104 | 105 | // MARK: - PreviewProvider 106 | 107 | struct BookmarkView_Previews: PreviewProvider { 108 | 109 | static var previews: some View { 110 | Group { 111 | BookmarkView(viewModel: Preview.makeBookmarkViewModel()) 112 | .frame(width: 320) 113 | .preferredColorScheme(.light) 114 | 115 | BookmarkView(viewModel: Preview.makeBookmarkViewModel()) 116 | .frame(width: 320) 117 | .preferredColorScheme(.dark) 118 | } 119 | .previewLayout(.sizeThatFits) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Pinboarding/Views/Bookmark/BookmarkViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | final class BookmarkViewModel: ObservableObject { 5 | 6 | // MARK: - Properties 7 | 8 | @Published var shouldShowWebsiteIcon: Bool 9 | 10 | let title: String 11 | let description: String 12 | let tags: String 13 | let url: URL 14 | let hostURL: String 15 | let iconURL: URL? 16 | let isPrivate: Bool 17 | 18 | private var cancellable: AnyCancellable? 19 | 20 | // MARK: - Life cycle 21 | 22 | init( 23 | title: String, 24 | description: String, 25 | tags: String, 26 | url: URL, 27 | hostURL: String, 28 | iconURL: URL?, 29 | isPrivate: Bool, 30 | shouldShowWebsiteIcon: Bool 31 | ) { 32 | self.title = title 33 | self.description = description 34 | self.tags = tags 35 | self.url = url 36 | self.hostURL = hostURL 37 | self.iconURL = iconURL 38 | self.isPrivate = isPrivate 39 | self.shouldShowWebsiteIcon = shouldShowWebsiteIcon 40 | } 41 | 42 | init( 43 | bookmark: Bookmark, 44 | settingsStore: SettingsStore 45 | ) { 46 | self.title = bookmark.title 47 | self.description = bookmark.abstract 48 | self.isPrivate = !bookmark.isShared 49 | self.shouldShowWebsiteIcon = settingsStore.showWebsiteIcons 50 | 51 | self.url = URL(string: bookmark.href) 52 | ?? URL(string: "https://www.pinboard.in")! 53 | 54 | self.hostURL = url.host?.uppercased() ?? "" 55 | 56 | self.iconURL = URL( 57 | string: "https://www.google.com/s2/favicons?sz=16&domain=\(url.host!)" 58 | ) 59 | 60 | self.tags = bookmark.tags 61 | .compactMap { $0 as? Tag } 62 | .map(\.name) 63 | .joined(separator: ", ") 64 | 65 | self.cancellable = settingsStore 66 | .changesPublisher() 67 | .sink { [weak self] change in 68 | guard case let .showWebsiteIcons(value) = change else { 69 | return 70 | } 71 | 72 | self?.shouldShowWebsiteIcon = value 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Pinboarding/Views/Bookmarks List/BookmarksListView.swift: -------------------------------------------------------------------------------- 1 | import CoreData 2 | import SwiftUI 3 | 4 | struct BookmarksListView: View { 5 | 6 | // MARK: - Properties 7 | 8 | @EnvironmentObject private var viewModelFactory: ViewModelFactory 9 | @EnvironmentObject private var searchStore: SearchStore 10 | 11 | private var fetchRequest: FetchRequest 12 | 13 | // MARK: - Life cycle 14 | 15 | init( 16 | viewModel: BookmarksListViewModel 17 | ) { 18 | self.fetchRequest = FetchRequest( 19 | entity: Bookmark.entity(), 20 | sortDescriptors: [.makeSortByTimeDescending()], 21 | predicate: viewModel.predicate 22 | ) 23 | } 24 | 25 | // MARK: - Public 26 | 27 | var body: some View { 28 | let bookmarks = fetchRequest 29 | .wrappedValue 30 | .filter( 31 | matching( 32 | \.title, 33 | with: searchStore.searchTerm 34 | ) 35 | ) 36 | 37 | List(bookmarks, id: \.self) { bookmark in 38 | BookmarkView( 39 | viewModel: viewModelFactory.makeBookmarkViewModel( 40 | bookmark: bookmark 41 | ) 42 | ) 43 | } 44 | } 45 | } 46 | 47 | // MARK: - Private 48 | 49 | private func matching( 50 | _ path: KeyPath, 51 | with term: String 52 | ) -> (Bookmark) -> Bool { 53 | { bookmark in 54 | let title = bookmark[keyPath: path].lowercased() 55 | let searchTerm = term.lowercased() 56 | return title.contains(searchTerm) || term.isEmpty 57 | } 58 | } 59 | 60 | // MARK: - PreviewProvider 61 | 62 | struct BookmarksList_Previews: PreviewProvider { 63 | 64 | static var previews: some View { 65 | Group { 66 | BookmarksListView(viewModel: .tag(name: "tag1")) 67 | .frame(width: 320) 68 | .preferredColorScheme(.light) 69 | 70 | BookmarksListView(viewModel: .all) 71 | .frame(width: 320) 72 | .preferredColorScheme(.dark) 73 | } 74 | .withPreviewDependencies() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Pinboarding/Views/Bookmarks List/BookmarksListViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum BookmarksListViewModel { 4 | case all 5 | case `public` 6 | case `private` 7 | case unread 8 | case tag(name: String) 9 | 10 | // MARK: - Properties 11 | 12 | var predicate: NSPredicate? { 13 | switch self { 14 | case .all: 15 | return nil 16 | case .public: 17 | return NSPredicate( 18 | format: "isShared == true" 19 | ) 20 | case .private: 21 | return NSPredicate( 22 | format: "isShared == false" 23 | ) 24 | case .unread: 25 | return NSPredicate( 26 | format: "isToRead == true" 27 | ) 28 | case .tag(let tagName): 29 | return NSPredicate( 30 | format: "ANY tags.name = %@", 31 | tagName 32 | ) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Pinboarding/Views/Bookmarks/BookmarksView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct BookmarksView: View { 4 | 5 | // MARK: - Properties 6 | 7 | @Environment(\.managedObjectContext) 8 | private var viewContext 9 | 10 | private let viewModel: BookmarksListViewModel 11 | 12 | // MARK: - Life cycle 13 | 14 | init( 15 | viewModel: BookmarksListViewModel 16 | ) { 17 | self.viewModel = viewModel 18 | } 19 | 20 | // MARK: - Public 21 | 22 | var body: some View { 23 | BookmarksListView(viewModel: viewModel) 24 | } 25 | } 26 | 27 | // MARK: - PreviewProvider 28 | 29 | struct BookmarksView_Previews: PreviewProvider { 30 | 31 | static var previews: some View { 32 | Group { 33 | BookmarksView(viewModel: .all) 34 | .preferredColorScheme(.light) 35 | .frame(width: 320) 36 | BookmarksView(viewModel: .all) 37 | .preferredColorScheme(.dark) 38 | .frame(width: 320) 39 | } 40 | .withPreviewDependencies() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Pinboarding/Views/Main/MainView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | import MicroContainer 4 | 5 | struct MainView: View { 6 | 7 | // MARK: - Properties 8 | 9 | @EnvironmentObject private var viewModelFactory: ViewModelFactory 10 | @ObservedObject private var viewModel: MainViewModel 11 | @State private var searchExpanded = false 12 | 13 | // MARK: - Life cycle 14 | 15 | init( 16 | viewModel: MainViewModel 17 | ) { 18 | self.viewModel = viewModel 19 | } 20 | 21 | // MARK: - Public 22 | 23 | var body: some View { 24 | NavigationView { 25 | SidebarView( 26 | viewModel: viewModelFactory.makeSidebarViewModel() 27 | ) 28 | .frame(minWidth: 160, idealWidth: 160) 29 | 30 | BookmarksView( 31 | viewModel: .all 32 | ) 33 | .frame(minWidth: 320, idealWidth: 640) 34 | } 35 | .toolbar { 36 | ToolbarItemGroup { 37 | SearchBarView() 38 | } 39 | 40 | ToolbarItemGroup { 41 | RefreshView( 42 | viewModel: viewModelFactory.makeRefreshViewModel() 43 | ) 44 | 45 | AddView( 46 | showAddBookmark: $viewModel.showAddBookmark 47 | ) 48 | .sheet(isPresented: $viewModel.showAddBookmark) { 49 | AddBookmarkView( 50 | viewModel: viewModelFactory.makeAddBookmarkViewModel(), 51 | isPresented: $viewModel.showAddBookmark 52 | ) 53 | .frame(width: 640) 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | // MARK: - PreviewProvider 61 | 62 | struct MainView_Previews: PreviewProvider { 63 | 64 | static var previews: some View { 65 | Group { 66 | MainView(viewModel: .init()) 67 | .preferredColorScheme(.light) 68 | 69 | MainView(viewModel: .init()) 70 | .preferredColorScheme(.dark) 71 | } 72 | .withPreviewDependencies() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Pinboarding/Views/Main/MainViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | final class MainViewModel: ObservableObject { 5 | 6 | // MARK: - Properties 7 | 8 | @Published var showAddBookmark = false 9 | } 10 | -------------------------------------------------------------------------------- /Pinboarding/Views/Micro.blog Button/MicroBlogButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct MicroBlogButton: View { 4 | 5 | // MARK: - Properties 6 | 7 | @Environment(\.openURL) private var openURL 8 | 9 | private let viewModel: MicroBlogButtonViewModel 10 | 11 | // MARK: - Life cycle 12 | 13 | init( 14 | viewModel: MicroBlogButtonViewModel 15 | ) { 16 | self.viewModel = viewModel 17 | } 18 | 19 | // MARK: - Public 20 | 21 | var body: some View { 22 | Button( 23 | action: { openURL(viewModel.microblogURL) }, 24 | label: { 25 | HStack { 26 | Image(systemName: Asset.Action.bookmark) 27 | .font(.title3) 28 | .foregroundColor(.accentColor) 29 | 30 | Text("Bookmark on Micro.blog") 31 | } 32 | } 33 | ) 34 | .buttonStyle(PlainButtonStyle()) 35 | .help("Open on default browser") 36 | } 37 | } 38 | 39 | // MARK: - PreviewProvider 40 | 41 | struct MicroBlogButton_Previews: PreviewProvider { 42 | 43 | static var previews: some View { 44 | Group { 45 | MicroBlogButton( 46 | viewModel: .init( 47 | url: URL(string: "https://www.apple.com")! 48 | ) 49 | ) 50 | .preferredColorScheme(.light) 51 | 52 | MicroBlogButton( 53 | viewModel: .init( 54 | url: URL(string: "https://www.apple.com")! 55 | ) 56 | ) 57 | .preferredColorScheme(.dark) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Pinboarding/Views/Micro.blog Button/MicroBlogButtonViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class MicroBlogButtonViewModel { 4 | 5 | // MARK: - Properties 6 | 7 | let microblogURL: URL 8 | 9 | // MARK: - Life cycle 10 | 11 | init( 12 | url: URL 13 | ) { 14 | let fallbackURL = URL( 15 | string: "https://micro.blog/bookmark" 16 | )! 17 | 18 | self.microblogURL = url 19 | .absoluteString 20 | .addingPercentEncoding( 21 | withAllowedCharacters: .urlQueryAllowed 22 | ) 23 | .map { encodedURL in 24 | "https://micro.blog/bookmark?url=\(encodedURL)" 25 | } 26 | .flatMap { microblogURLString in 27 | URL(string: microblogURLString) 28 | } ?? fallbackURL 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Pinboarding/Views/Offline Button/OfflineView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct OfflineView: View { 4 | 5 | // MARK: - Properties 6 | 7 | @ObservedObject private var viewModel: OfflineViewModel 8 | 9 | // MARK: - Life cycle 10 | 11 | init( 12 | viewModel: OfflineViewModel 13 | ) { 14 | self.viewModel = viewModel 15 | } 16 | 17 | // MARK: - Public 18 | 19 | var body: some View { 20 | Button( 21 | action: { }, 22 | label: { 23 | Image(systemName: "circle") 24 | } 25 | ) 26 | .help(viewModel.iconTooltip) 27 | } 28 | } 29 | 30 | // MARK: - PreviewProvider 31 | 32 | struct OfflineView_Previews: PreviewProvider { 33 | 34 | static var previews: some View { 35 | let onlinePublisher = Preview.makeNetworkStatusPublisher( 36 | isOnline: true 37 | ) 38 | 39 | let offlinePublisher = Preview.makeNetworkStatusPublisher( 40 | isOnline: false 41 | ) 42 | 43 | Group { 44 | OfflineView( 45 | viewModel: .init( 46 | pathMonitorPublisher: onlinePublisher 47 | ) 48 | ) 49 | .preferredColorScheme(.dark) 50 | 51 | OfflineView( 52 | viewModel: .init( 53 | pathMonitorPublisher: offlinePublisher 54 | ) 55 | ) 56 | .preferredColorScheme(.light) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Pinboarding/Views/Offline Button/OfflineViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Network 3 | 4 | final class OfflineViewModel: ObservableObject { 5 | 6 | // MARK: - Properties 7 | 8 | @Published private(set) var isOnline = false 9 | 10 | var iconName: String { 11 | isOnline ? Asset.Connection.online: Asset.Connection.offline 12 | } 13 | 14 | var iconTooltip: String { 15 | isOnline ? "Online" : "Offline" 16 | } 17 | 18 | private var monitorCancellable: Cancellable? 19 | 20 | // MARK: - Life cycle 21 | 22 | init( 23 | pathMonitorPublisher: AnyPublisher 24 | ) { 25 | monitorCancellable = pathMonitorPublisher 26 | .receive(on: DispatchQueue.main) 27 | .map { $0 == .satisfied } 28 | .assign(to: \.isOnline, on: self) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Pinboarding/Views/Predicting TextField/PredictingTextField.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | struct PredictingTextField: View { 5 | 6 | // MARK: - Properties 7 | 8 | @Binding private var text: String 9 | @Binding private var predictions: [String] 10 | 11 | private let title: String 12 | private var reference: [String] = [] 13 | private var cancellables = Set() 14 | 15 | // MARK: - Life cycle 16 | 17 | /// Creates a text field with a text label generated from a title string 18 | /// 19 | /// - Parameters: 20 | /// - title: The title of the text view, describing its purpose. 21 | /// - reference: The list of words used to match against. 22 | /// - text: The text to display and edit. 23 | /// - predictions: The list of words matching the list of valid words. 24 | init( 25 | _ title: String = "", 26 | reference: [String], 27 | text: Binding, 28 | predictions: Binding<[String]> 29 | ) { 30 | self.title = title 31 | self.reference = reference 32 | self._text = text 33 | self._predictions = predictions 34 | 35 | matchingPredictionsPublisher() 36 | .receive(on: DispatchQueue.main) 37 | .assign(to: \.predictions, on: self) 38 | .store(in: &cancellables) 39 | } 40 | 41 | // MARK: - Public 42 | 43 | var body: some View { 44 | TextField(title, text: $text) 45 | } 46 | 47 | // MARK: - Private 48 | 49 | private func matchingPredictionsPublisher( 50 | ) -> AnyPublisher<[String], Never> { 51 | text 52 | .split(separator: " ") 53 | .last 54 | .publisher 55 | .map { lastWord in 56 | reference.filter { word in 57 | word.lowercased().contains(lastWord.lowercased()) 58 | } 59 | } 60 | .eraseToAnyPublisher() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Pinboarding/Views/Private/PrivateView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PrivateView: View { 4 | 5 | // MARK: - Properties 6 | 7 | private let isPrivate: Bool 8 | 9 | // MARK: - Life cycle 10 | 11 | init( 12 | isPrivate: Bool 13 | ) { 14 | self.isPrivate = isPrivate 15 | } 16 | 17 | // MARK: - Public 18 | 19 | var body: some View { 20 | Image( 21 | systemName: isPrivate ? Asset.Lock.closed : Asset.Lock.open 22 | ) 23 | .help("Bookmark visibility") 24 | } 25 | } 26 | 27 | // MARK: - LibraryContentProvider 28 | 29 | struct PrivateView_LibraryContent: LibraryContentProvider { 30 | 31 | var views: [LibraryItem] { 32 | LibraryItem( 33 | PrivateView( 34 | isPrivate: true 35 | ), 36 | title: "Provate Icon", 37 | category: .layout 38 | ) 39 | } 40 | } 41 | 42 | // MARK: - PreviewProvider 43 | 44 | struct PrivateView_Previews: PreviewProvider { 45 | 46 | static var previews: some View { 47 | Group { 48 | PrivateView( 49 | isPrivate: true 50 | ) 51 | .preferredColorScheme(.light) 52 | 53 | PrivateView( 54 | isPrivate: false 55 | ) 56 | .preferredColorScheme(.dark) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Pinboarding/Views/QRCode Button/QRCodeButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct QRCodeButton: View { 4 | 5 | // MARK: - Properties 6 | 7 | @State private var showPopover: Bool = false 8 | 9 | private let url: URL 10 | 11 | // MARK: - Life cycle 12 | 13 | init( 14 | url: URL 15 | ) { 16 | self.url = url 17 | } 18 | 19 | // MARK: - Public 20 | 21 | var body: some View { 22 | Button( 23 | action: { showPopover.toggle() }, 24 | label: { 25 | HStack { 26 | Image(systemName: Asset.QRCode.icon) 27 | .font(.title3) 28 | .foregroundColor(.accentColor) 29 | 30 | Text("QR Code") 31 | } 32 | } 33 | ) 34 | .buttonStyle(PlainButtonStyle()) 35 | .help("Show QRCode") 36 | .popover( 37 | isPresented: $showPopover, 38 | arrowEdge: .bottom 39 | ) { 40 | QRCodeView(string: url.absoluteString) 41 | .frame(width: 300, height: 300) 42 | } 43 | } 44 | } 45 | 46 | // MARK: - LibraryContentProvider 47 | 48 | struct QRCodeButton_LibraryContent: LibraryContentProvider { 49 | 50 | var views: [LibraryItem] { 51 | LibraryItem( 52 | QRCodeButton( 53 | url: URL(string: "https://www.apple.com")! 54 | ), 55 | title: "Show QR Code", 56 | category: .layout 57 | ) 58 | } 59 | } 60 | 61 | // MARK: - PreviewProvider 62 | 63 | struct QRCodeButton_Previews: PreviewProvider { 64 | 65 | static var previews: some View { 66 | Group { 67 | QRCodeButton( 68 | url: URL(string: "https://www.apple.com")! 69 | ) 70 | .preferredColorScheme(.light) 71 | 72 | QRCodeButton( 73 | url: URL(string: "https://www.apple.com")! 74 | ) 75 | .preferredColorScheme(.dark) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Pinboarding/Views/QRCode/QRCodeView.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import SwiftUI 3 | 4 | public struct QRCodeView: View { 5 | 6 | // MARK: - Properties 7 | 8 | private var string: String 9 | 10 | // MARK: - Life cycle 11 | 12 | public init( 13 | string: String 14 | ) { 15 | self.string = string 16 | } 17 | 18 | // MARK: - Public 19 | 20 | public var body: some View { 21 | Image.makeQRCode(from: string) 22 | .interpolation(.none) 23 | .resizable() 24 | .scaledToFit() 25 | } 26 | } 27 | 28 | // MARK: - Private 29 | 30 | private extension Image { 31 | 32 | static func makeQRCode( 33 | from string: String 34 | ) -> Image { 35 | let data = Data(string.utf8) 36 | let context = CIContext() 37 | 38 | let filter = CIFilter( 39 | name: "CIQRCodeGenerator" 40 | ) 41 | 42 | filter?.setValue( 43 | data, 44 | forKey: "inputMessage" 45 | ) 46 | 47 | let image = filter? 48 | .outputImage 49 | .flatMap { 50 | context.createCGImage( 51 | $0, 52 | from: $0.extent 53 | ) 54 | } 55 | .map { 56 | Image( 57 | $0, 58 | scale: 1, 59 | label: Text("QRCode") 60 | ) 61 | } 62 | 63 | return image ?? Image(systemName: Asset.QRCode.placeholder) 64 | } 65 | } 66 | 67 | // MARK: - LibraryContentProvider 68 | 69 | struct QRCodeView_LibraryContent: LibraryContentProvider { 70 | 71 | var views: [LibraryItem] { 72 | LibraryItem( 73 | QRCodeView( 74 | string: "Lorem Ipsum" 75 | ), 76 | title: "QRCode", 77 | category: .other 78 | ) 79 | } 80 | } 81 | 82 | // MARK: - PreviewProvider 83 | 84 | struct QRCodeView_Previews: PreviewProvider { 85 | 86 | static var previews: some View { 87 | QRCodeView(string: "Alguma coisa") 88 | .foregroundColor(.accentColor) 89 | .frame(width: 300, height: 300) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Pinboarding/Views/Refresh/RefreshView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct RefreshView: View { 4 | 5 | // MARK: - Properties 6 | 7 | @ObservedObject private var viewModel: RefreshViewModel 8 | 9 | // MARK: - Life cycle 10 | 11 | init( 12 | viewModel: RefreshViewModel 13 | ) { 14 | self.viewModel = viewModel 15 | } 16 | 17 | // MARK: - Public 18 | 19 | var body: some View { 20 | Button( 21 | action: { viewModel.refresh() }, 22 | label: { 23 | Image(systemName: Asset.Action.refresh) 24 | } 25 | ) 26 | .disabled(viewModel.isReloading) 27 | .help("Force refresh") 28 | } 29 | } 30 | 31 | // MARK: - PreviewProvider 32 | 33 | struct RefreshView_Previews: PreviewProvider { 34 | 35 | static var previews: some View { 36 | RefreshView( 37 | viewModel: RefreshViewModel( 38 | repository: previewAppEnvironment.repository 39 | ) 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Pinboarding/Views/Refresh/RefreshViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | final class RefreshViewModel: ObservableObject { 5 | 6 | // MARK: - Properties 7 | 8 | @Published private(set) var isReloading = false 9 | 10 | private let repository: PinboardRepositoryProtocol 11 | private var activityCancellable: AnyCancellable? 12 | 13 | // MARK: - Life cycle 14 | 15 | init( 16 | repository: PinboardRepositoryProtocol 17 | ) { 18 | self.repository = repository 19 | 20 | self.activityCancellable = repository.networkActivityPublisher() 21 | .receive(on: RunLoop.main) 22 | .map { $0 == .loading } 23 | .assign(to: \.isReloading, on: self) 24 | } 25 | 26 | // MARK: - Public 27 | 28 | func refresh( 29 | ) { 30 | Task { 31 | await repository.forceRefreshBookmarks() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Pinboarding/Views/Safari Button/SafariButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SafariButton: View { 4 | 5 | // MARK: - Properties 6 | 7 | @Environment(\.openURL) private var openURL 8 | 9 | private let url: URL 10 | 11 | // MARK: - Life cycle 12 | 13 | init( 14 | url: URL 15 | ) { 16 | self.url = url 17 | } 18 | 19 | // MARK: - Public 20 | 21 | var body: some View { 22 | Button( 23 | action: { openURL(url) }, 24 | label: { 25 | HStack { 26 | Image(systemName: Asset.Action.open) 27 | .font(.title3) 28 | .foregroundColor(.accentColor) 29 | 30 | Text("Open") 31 | } 32 | } 33 | ) 34 | .buttonStyle(PlainButtonStyle()) 35 | .help("Open on default browser") 36 | } 37 | } 38 | 39 | // MARK: - LibraryContentProvider 40 | 41 | struct SafariButton_LibraryContent: LibraryContentProvider { 42 | 43 | var views: [LibraryItem] { 44 | LibraryItem( 45 | SafariButton( 46 | url: URL(string: "https://www.apple.com")! 47 | ), 48 | title: "Open URL on Browser", 49 | category: .layout 50 | ) 51 | } 52 | } 53 | 54 | // MARK: - PreviewProvider 55 | 56 | struct SafariButton_Previews: PreviewProvider { 57 | 58 | static var previews: some View { 59 | Group { 60 | SafariButton( 61 | url: URL(string: "https://www.apple.com")! 62 | ) 63 | .preferredColorScheme(.light) 64 | 65 | SafariButton( 66 | url: URL(string: "https://www.apple.com")! 67 | ) 68 | .preferredColorScheme(.dark) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Pinboarding/Views/Search Text/SearchTextView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SearchTextView: View { 4 | 5 | @ObservedObject private var viewModel: SearchTextViewModel 6 | @FocusState private var isFocused 7 | 8 | private let action: () -> Void 9 | 10 | // MARK: - Life cycle 11 | 12 | init( 13 | viewModel: SearchTextViewModel, 14 | action: @escaping () -> Void 15 | ) { 16 | self.viewModel = viewModel 17 | self.action = action 18 | } 19 | 20 | var body: some View { 21 | ZStack(alignment: .trailing) { 22 | TextField( 23 | "Search", 24 | text: $viewModel.currentSearchTerm 25 | ) 26 | .focusable(true) 27 | .focused($isFocused) 28 | .textFieldStyle(RoundedBorderTextFieldStyle()) 29 | .frame(minWidth: 200) 30 | .onChange(of: isFocused) { newValue in 31 | if newValue == false && viewModel.currentSearchTerm.isEmpty { 32 | action() 33 | } 34 | } 35 | 36 | Button( 37 | action: { 38 | if viewModel.currentSearchTerm.isEmpty { 39 | action() 40 | } 41 | 42 | viewModel.clear() 43 | isFocused = false 44 | }, 45 | label: { 46 | Image(systemName: Asset.Action.clear) 47 | } 48 | ) 49 | .buttonStyle(BorderlessButtonStyle()) 50 | .frame(width: 20, height: 20) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Pinboarding/Views/SearchField/SearchBarViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | final class SearchTextViewModel: ObservableObject { 5 | 6 | // MARK: - Properties 7 | 8 | @Published var currentSearchTerm: String 9 | 10 | private var cancellables = Set() 11 | 12 | // MARK: - Life cycle 13 | 14 | init( 15 | searchStore: SearchStore 16 | ) { 17 | self.currentSearchTerm = searchStore.currentSearchTerm 18 | 19 | searchTermPublisher() 20 | .receive(on: RunLoop.main) 21 | .assign(to: \.currentSearchTerm, on: searchStore) 22 | .store(in: &cancellables) 23 | } 24 | 25 | // MARK: - Public 26 | 27 | func clear() { 28 | currentSearchTerm = "" 29 | } 30 | 31 | // MARK: - Private 32 | 33 | private func searchTermPublisher( 34 | ) -> AnyPublisher { 35 | $currentSearchTerm 36 | .removeDuplicates() 37 | .eraseToAnyPublisher() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Pinboarding/Views/SearchField/SearchField.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SearchBarView: View { 4 | 5 | // MARK: - Properties 6 | 7 | @EnvironmentObject private var viewModelFactory: ViewModelFactory 8 | @State private var searchExpanded = false 9 | 10 | // MARK: - Public 11 | 12 | var body: some View { 13 | HStack { 14 | HStack { 15 | if searchExpanded { 16 | SearchTextView( 17 | viewModel: viewModelFactory.makeSearchBarViewModel(), 18 | action: { withAnimation { searchExpanded.toggle() } } 19 | ) 20 | .transition(.move(edge: .trailing)) 21 | } 22 | } 23 | .frame(minWidth: 200, minHeight: 30) 24 | 25 | if !searchExpanded { 26 | Button( 27 | action: { withAnimation { searchExpanded.toggle() } }, 28 | label: { Image(systemName: "magnifyingglass") } 29 | ) 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Pinboarding/Views/Settings General/GeneralView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct GeneralView: View { 4 | 5 | // MARK: - Properties 6 | 7 | @ObservedObject private var viewModel: GeneralViewModel 8 | 9 | // MARK: - Life cycle 10 | 11 | init( 12 | viewModel: GeneralViewModel 13 | ) { 14 | self.viewModel = viewModel 15 | } 16 | 17 | // MARK: - Public 18 | 19 | var body: some View { 20 | VStack(alignment: .twoColumns, spacing: 12) { 21 | HStack { 22 | Text("Default values for new bookmarks") 23 | 24 | Toggle( 25 | "Mark new posts as private", 26 | isOn: $viewModel.isPrivate 27 | ) 28 | .rightColumnAlignmentGuide() 29 | } 30 | 31 | Toggle( 32 | "Mark new posts to read later", 33 | isOn: $viewModel.isToRead 34 | ) 35 | .rightColumnAlignmentGuide() 36 | 37 | HStack { 38 | Text("Micro.blog") 39 | 40 | Toggle( 41 | "Show \"Bookmark on Micro.blog\"", 42 | isOn: $viewModel.showMicroBlog 43 | ) 44 | .rightColumnAlignmentGuide() 45 | } 46 | 47 | HStack { 48 | Text("Images") 49 | 50 | Toggle( 51 | "Show website icon", 52 | isOn: $viewModel.showWebsiteIcons 53 | ) 54 | .rightColumnAlignmentGuide() 55 | } 56 | } 57 | .padding() 58 | } 59 | } 60 | 61 | // MARK: - PreviewProvider 62 | 63 | struct GeneralView_Previews: PreviewProvider { 64 | 65 | static var previews: some View { 66 | Group { 67 | GeneralView( 68 | viewModel: .init( 69 | settingsStore: Preview.makeSettingsStore() 70 | ) 71 | ) 72 | .preferredColorScheme(.light) 73 | 74 | GeneralView( 75 | viewModel: .init( 76 | settingsStore: Preview.makeSettingsStore( 77 | isPrivate: false, 78 | isToRead: true, 79 | showMicroBlog: false 80 | ) 81 | ) 82 | ) 83 | .preferredColorScheme(.dark) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Pinboarding/Views/Settings General/GeneralViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | final class GeneralViewModel: ObservableObject { 4 | 5 | // MARK: - Properties 6 | 7 | @Published var isPrivate: Bool = true 8 | @Published var isToRead: Bool = false 9 | @Published var showMicroBlog: Bool = false 10 | @Published var showWebsiteIcons: Bool = false 11 | 12 | private let settingsStore: SettingsStore 13 | private var cancellables = Set() 14 | 15 | // MARK: - Life cycle 16 | 17 | init( 18 | settingsStore: SettingsStore 19 | ) { 20 | self.settingsStore = settingsStore 21 | self.isPrivate = settingsStore.isPrivate 22 | self.isToRead = settingsStore.isToRead 23 | self.showMicroBlog = settingsStore.showMicroBlog 24 | self.showWebsiteIcons = settingsStore.showWebsiteIcons 25 | 26 | $isPrivate 27 | .dropFirst() 28 | .assign(to: \.settingsStore.isPrivate, on: self) 29 | .store(in: &cancellables) 30 | 31 | $isToRead 32 | .dropFirst() 33 | .assign(to: \.settingsStore.isToRead, on: self) 34 | .store(in: &cancellables) 35 | 36 | $showMicroBlog 37 | .dropFirst() 38 | .assign(to: \.settingsStore.showMicroBlog, on: self) 39 | .store(in: &cancellables) 40 | 41 | $showWebsiteIcons 42 | .dropFirst() 43 | .assign(to: \.settingsStore.showWebsiteIcons, on: self) 44 | .store(in: &cancellables) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Pinboarding/Views/Settings Login/LoginView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct LoginView: View { 4 | 5 | // MARK: - Properties 6 | 7 | @ObservedObject private var viewModel: LoginViewModel 8 | 9 | // MARK: - Life cycle 10 | 11 | init( 12 | viewModel: LoginViewModel 13 | ) { 14 | self.viewModel = viewModel 15 | } 16 | 17 | // MARK: - Public 18 | 19 | var body: some View { 20 | VStack(alignment: .leading, spacing: 12) { 21 | Text("Pinboard Auth Token") 22 | 23 | Text("Pinboarding uses auth token to access Pinboard.") 24 | .font(.footnote) 25 | 26 | Link( 27 | "View your API key.", 28 | destination: URL( 29 | string: "https://pinboard.in/settings/password" 30 | )! 31 | ) 32 | .font(.callout) 33 | 34 | SecureField( 35 | "Auth Token", 36 | text: $viewModel.authToken 37 | ) 38 | 39 | HStack { 40 | if !viewModel.isValid { 41 | Text(viewModel.authTokenMessage) 42 | .foregroundColor(.red) 43 | } 44 | 45 | Spacer() 46 | 47 | Button("Save") { 48 | viewModel.save() 49 | } 50 | .disabled(!viewModel.isValid) 51 | } 52 | } 53 | .padding() 54 | } 55 | } 56 | 57 | // MARK: - PreviewProvider 58 | 59 | struct LoginView_Previews: PreviewProvider { 60 | 61 | static var previews: some View { 62 | Group { 63 | LoginView( 64 | viewModel: LoginViewModel( 65 | tokenStore: Preview.makeTokenStore(authToken: "valid:token") 66 | ) 67 | ) 68 | .preferredColorScheme(.light) 69 | 70 | LoginView( 71 | viewModel: LoginViewModel( 72 | tokenStore: Preview.makeTokenStore(authToken: "invalidtoken") 73 | ) 74 | ) 75 | .preferredColorScheme(.dark) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Pinboarding/Views/Settings Login/LoginViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | final class LoginViewModel: ObservableObject { 5 | 6 | // MARK: - Properties 7 | 8 | @Published var authToken: String = "" 9 | @Published private(set) var authTokenMessage: String = "" 10 | @Published private(set) var isValid = false 11 | 12 | private var tokenStore: TokenStoreProtocol 13 | private var cancellables = Set() 14 | 15 | // MARK: - Life cycle 16 | 17 | init( 18 | tokenStore: TokenStoreProtocol 19 | ) { 20 | self.tokenStore = tokenStore 21 | self.authToken = tokenStore.authToken ?? "" 22 | 23 | self.isAuthTokenValidPublisher() 24 | .receive(on: RunLoop.main) 25 | .assign(to: \.isValid, on: self) 26 | .store(in: &cancellables) 27 | 28 | self.isAuthTokenValidPublisher() 29 | .receive(on: RunLoop.main) 30 | .map { isAuthValid in 31 | isAuthValid ? "" : "Please enter a valid token" 32 | } 33 | .assign(to: \.authTokenMessage, on: self) 34 | .store(in: &cancellables) 35 | } 36 | 37 | // MARK: - Public 38 | 39 | func save() { 40 | guard isValid else { 41 | return 42 | } 43 | 44 | tokenStore.authToken = authToken 45 | } 46 | 47 | // MARK: - Private 48 | 49 | private func isAuthTokenValidPublisher( 50 | ) -> AnyPublisher { 51 | $authToken 52 | .debounce(for: 0.3, scheduler: RunLoop.main) 53 | .removeDuplicates() 54 | .map(isAuthTokenValid) 55 | .eraseToAnyPublisher() 56 | } 57 | 58 | private func isAuthTokenValid( 59 | _ token: String 60 | ) -> Bool { 61 | guard 62 | case let components = token.split(separator: ":"), 63 | components.count == 2, 64 | let user = components.first, 65 | let key = components.last, 66 | !user.isEmpty, 67 | !key.isEmpty 68 | else { return false } 69 | 70 | return true 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Pinboarding/Views/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsView: View { 4 | 5 | // MARK: - Nested types 6 | 7 | private enum SettingsTab: Hashable { 8 | case login, general 9 | } 10 | 11 | // MARK: - Properties 12 | 13 | @EnvironmentObject private var viewModelFactory: ViewModelFactory 14 | 15 | // MARK: - Public 16 | 17 | var body: some View { 18 | TabView { 19 | LoginView( 20 | viewModel: viewModelFactory.makeLoginViewModel() 21 | ) 22 | .tabItem { 23 | Label("Login", systemImage: "person") 24 | } 25 | .tag(SettingsTab.login) 26 | 27 | GeneralView( 28 | viewModel: viewModelFactory.makeGeneralViewModel() 29 | ) 30 | .tabItem { 31 | Label("General", systemImage: "gear") 32 | } 33 | .tag(SettingsTab.general) 34 | } 35 | .padding() 36 | .frame(width: 500) 37 | } 38 | } 39 | 40 | // MARK: - PreviewProvider 41 | 42 | struct SettingsView_Previews: PreviewProvider { 43 | 44 | static var previews: some View { 45 | Group { 46 | SettingsView() 47 | .preferredColorScheme(.light) 48 | .environmentObject( 49 | Preview.makeTokenStore(authToken: "valid:token") 50 | ) 51 | 52 | SettingsView() 53 | .preferredColorScheme(.dark) 54 | .environmentObject( 55 | Preview.makeTokenStore(authToken: "invalid_token") 56 | ) 57 | } 58 | .withPreviewDependencies() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Pinboarding/Views/Share Button/ShareButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ShareButton: View { 4 | 5 | // MARK: - Properties 6 | 7 | @State private var showPicker = false 8 | 9 | private let title: String 10 | private let url: URL 11 | 12 | // MARK: - Life cycle 13 | 14 | init( 15 | title: String, 16 | url: URL 17 | ) { 18 | self.title = title 19 | self.url = url 20 | } 21 | 22 | // MARK: - Public 23 | 24 | var body: some View { 25 | Button( 26 | action: { showPicker = true }, 27 | label: { 28 | HStack { 29 | Image(systemName: Asset.Action.share) 30 | .font(.title3) 31 | .foregroundColor(.accentColor) 32 | 33 | Text("Share") 34 | } 35 | } 36 | ) 37 | .buttonStyle(PlainButtonStyle()) 38 | .background( 39 | SharingServicePicker( 40 | isPresented: $showPicker, 41 | sharingItems: [ 42 | title, 43 | url.absoluteString 44 | ] 45 | ) 46 | ) 47 | .help("Share this bookmark") 48 | } 49 | } 50 | 51 | // MARK: - LibraryContentProvider 52 | 53 | struct ShareButton_LibraryContent: LibraryContentProvider { 54 | 55 | var views: [LibraryItem] { 56 | LibraryItem( 57 | ShareButton( 58 | title: "Apple's website", 59 | url: URL(string: "https://www.apple.com")! 60 | ), 61 | title: "Open Share Sheet", 62 | category: .layout 63 | ) 64 | } 65 | } 66 | 67 | // MARK: - PreviewProvider 68 | 69 | struct ShareButton_Previews: PreviewProvider { 70 | 71 | static var previews: some View { 72 | Group { 73 | ShareButton( 74 | title: "Apple's website", 75 | url: URL(string: "https://www.apple.com")! 76 | ) 77 | .preferredColorScheme(.light) 78 | 79 | ShareButton( 80 | title: "Apple's website", 81 | url: URL(string: "https://www.apple.com")! 82 | ) 83 | .preferredColorScheme(.dark) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Pinboarding/Views/Sharing Service Picker/SharingServicePicker.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SharingServicePicker: NSViewRepresentable { 4 | 5 | // MARK: - Properties 6 | 7 | @Binding private var isPresented: Bool 8 | 9 | private let sharingItems: [Any] 10 | 11 | // MARK: - Life cycle 12 | 13 | init( 14 | isPresented: Binding, 15 | sharingItems: [Any] 16 | ) { 17 | self._isPresented = isPresented 18 | self.sharingItems = sharingItems 19 | } 20 | 21 | // MARK: - Public 22 | 23 | func makeNSView( 24 | context: Context 25 | ) -> NSView { 26 | NSView() 27 | } 28 | 29 | func updateNSView( 30 | _ nsView: NSView, 31 | context: Context 32 | ) { 33 | guard isPresented else { return } 34 | 35 | let picker = NSSharingServicePicker( 36 | items: sharingItems 37 | ) 38 | 39 | picker.delegate = context.coordinator 40 | 41 | DispatchQueue.main.async { 42 | picker.show( 43 | relativeTo: .zero, 44 | of: nsView, 45 | preferredEdge: .minY 46 | ) 47 | } 48 | } 49 | 50 | func makeCoordinator( 51 | ) -> Coordinator { 52 | Coordinator(picker: self) 53 | } 54 | 55 | final class Coordinator: NSObject, NSSharingServicePickerDelegate { 56 | 57 | // MARK: - Properties 58 | 59 | private let picker: SharingServicePicker 60 | 61 | // MARK: - Life cycle 62 | 63 | fileprivate init( 64 | picker: SharingServicePicker 65 | ) { 66 | self.picker = picker 67 | } 68 | 69 | // MARK: - Public 70 | 71 | func sharingServicePicker( 72 | _ sharingServicePicker: NSSharingServicePicker, 73 | didChoose service: NSSharingService? 74 | ) { 75 | sharingServicePicker.delegate = nil 76 | picker.isPresented = false 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Pinboarding/Views/Sidebar Item/SidebarItemView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SidebarItemView: View { 4 | 5 | // MARK: - Properties 6 | 7 | private let title: String 8 | private let iconName: String 9 | private let counter: String? 10 | 11 | // MARK: - Life cycle 12 | 13 | init( 14 | title: String, 15 | iconName: String, 16 | counter: String? = nil 17 | ) { 18 | self.title = title 19 | self.iconName = iconName 20 | self.counter = counter 21 | } 22 | 23 | // MARK: - Public 24 | 25 | var body: some View { 26 | HStack { 27 | Label(title, systemImage: iconName) 28 | 29 | Spacer() 30 | 31 | if let counter = counter { 32 | Text(counter) 33 | .padding(2) 34 | .frame(minWidth: 24) 35 | .background(.gray.opacity(0.2)) 36 | .clipShape(Capsule(style: .circular)) 37 | } 38 | } 39 | } 40 | } 41 | 42 | // MARK: - PreviewProvider 43 | 44 | struct SidebarItemView_Previews: PreviewProvider { 45 | 46 | static var previews: some View { 47 | Group { 48 | SidebarItemView( 49 | title: "Foo", 50 | iconName: "tag" 51 | ) 52 | .preferredColorScheme(.light) 53 | 54 | SidebarItemView( 55 | title: "Foo", 56 | iconName: "tag", 57 | counter: "42" 58 | ) 59 | .preferredColorScheme(.dark) 60 | } 61 | .frame(width: 200) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Pinboarding/Views/Sidebar Item/SidebarPrimaryItem.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | enum SidebarPrimaryItem: String, CaseIterable { 4 | case all 5 | case `public` 6 | case `private` 7 | case unread 8 | 9 | // MARK: - Properties 10 | 11 | var iconName: String { 12 | switch self { 13 | case .all: return Asset.Bookmark.all 14 | case .public: return Asset.Bookmark.public 15 | case .private: return Asset.Bookmark.private 16 | case .unread: return Asset.Bookmark.unread 17 | } 18 | } 19 | 20 | var title: String { 21 | rawValue.capitalized 22 | } 23 | 24 | var listType: BookmarksListViewModel { 25 | switch self { 26 | case .all: return .all 27 | case .public: return .public 28 | case .private: return .private 29 | case .unread: return .unread 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Pinboarding/Views/Sidebar My Bookmarks Section/MyBookmarksSectionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct MyBookmarksSectionView: View { 4 | 5 | // MARK: - Properties 6 | 7 | private let viewModel: MyBookmarksSectionViewModel 8 | 9 | // MARK: - Life cycle 10 | 11 | init( 12 | viewModel: MyBookmarksSectionViewModel 13 | ) { 14 | self.viewModel = viewModel 15 | } 16 | 17 | // MARK: - Public 18 | 19 | var body: some View { 20 | Section(header: Text("My Bookmarks")) { 21 | ForEach(viewModel.primaryItems, id: \.self) { item in 22 | NavigationLink( 23 | destination: BookmarksView( 24 | viewModel: item.listType 25 | ), 26 | label: { 27 | SidebarItemView( 28 | title: item.title, 29 | iconName: item.iconName 30 | ) 31 | } 32 | ) 33 | } 34 | } 35 | .collapsible(false) 36 | } 37 | } 38 | 39 | // MARK: - PreviewProvider 40 | 41 | struct MyBookmarksSectionView_Previews: PreviewProvider { 42 | 43 | static var previews: some View { 44 | Group { 45 | List { 46 | MyBookmarksSectionView( 47 | viewModel: MyBookmarksSectionViewModel() 48 | ) 49 | .preferredColorScheme(.light) 50 | } 51 | 52 | List { 53 | MyBookmarksSectionView( 54 | viewModel: MyBookmarksSectionViewModel() 55 | ) 56 | .preferredColorScheme(.dark) 57 | } 58 | } 59 | .frame(width: 200) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Pinboarding/Views/Sidebar My Bookmarks Section/MyBookmarksSectionViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | final class MyBookmarksSectionViewModel { 4 | 5 | // MARK: - Properties 6 | 7 | let primaryItems: [SidebarPrimaryItem] 8 | 9 | // MARK: - Life cycle 10 | 11 | init( 12 | ) { 13 | self.primaryItems = SidebarPrimaryItem.allCases 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Pinboarding/Views/Sidebar Tags Section/TagsSectionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct TagsSectionView: View { 4 | 5 | // MARK: - Properties 6 | 7 | @Environment(\.managedObjectContext) 8 | private var viewContext 9 | 10 | @FetchRequest(entity: Tag.entity(), sortDescriptors: [.makeSortByNameAscending()]) 11 | private var tags: FetchedResults 12 | 13 | // MARK: - Public 14 | 15 | var body: some View { 16 | Section(header: Text("Tags")) { 17 | ForEach(tags, id: \.self) { tag in 18 | NavigationLink( 19 | destination: BookmarksView( 20 | viewModel: .tag(name: tag.name) 21 | ), 22 | label: { 23 | SidebarItemView( 24 | title: tag.name, 25 | iconName: Asset.Tag.icon, 26 | counter: String(tag.bookmarks.count) 27 | ) 28 | } 29 | ) 30 | } 31 | } 32 | .collapsible(false) 33 | } 34 | } 35 | 36 | // MARK: - PreviewProvider 37 | 38 | struct TagsSectionView_Previews: PreviewProvider { 39 | 40 | static var previews: some View { 41 | Group { 42 | List { 43 | TagsSectionView() 44 | .preferredColorScheme(.light) 45 | } 46 | 47 | List { 48 | TagsSectionView() 49 | .preferredColorScheme(.dark) 50 | } 51 | } 52 | .withPreviewDependencies() 53 | .frame(width: 200) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Pinboarding/Views/Sidebar/SidebarView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SidebarView: View { 4 | 5 | // MARK: - Properties 6 | 7 | @ObservedObject private var viewModel: SidebarViewModel 8 | @EnvironmentObject private var viewModelFactory: ViewModelFactory 9 | 10 | // MARK: - Life cycle 11 | 12 | init( 13 | viewModel: SidebarViewModel 14 | ) { 15 | self.viewModel = viewModel 16 | } 17 | 18 | // MARK: - Public 19 | 20 | var body: some View { 21 | VStack { 22 | List { 23 | MyBookmarksSectionView( 24 | viewModel: viewModelFactory.makeMyBookmarksSectionViewModel() 25 | ) 26 | TagsSectionView() 27 | } 28 | .listStyle(SidebarListStyle()) 29 | 30 | Spacer() 31 | 32 | if viewModel.isLoading { 33 | ProgressView() 34 | .progressViewStyle(LinearProgressViewStyle()) 35 | .padding() 36 | } 37 | } 38 | } 39 | } 40 | 41 | // MARK: - PreviewProvider 42 | 43 | struct SidebarView_Previews: PreviewProvider { 44 | 45 | static var previews: some View { 46 | let loadingPublisher = Preview.makeNetworkActivityPublisher( 47 | loading: true 48 | ) 49 | 50 | let notLoadingPublisher = Preview.makeNetworkActivityPublisher( 51 | loading: false 52 | ) 53 | 54 | Group { 55 | SidebarView( 56 | viewModel: .init( 57 | networkActivityPublisher: notLoadingPublisher, 58 | searchStore: previewAppEnvironment.searchStore 59 | ) 60 | ) 61 | .preferredColorScheme(.light) 62 | .frame(width: 200) 63 | 64 | SidebarView( 65 | viewModel: .init( 66 | networkActivityPublisher: loadingPublisher, 67 | searchStore: previewAppEnvironment.searchStore 68 | ) 69 | ) 70 | .preferredColorScheme(.dark) 71 | .frame(width: 200) 72 | } 73 | .withPreviewDependencies() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Pinboarding/Views/Sidebar/SidebarViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import SwiftUI 4 | 5 | final class SidebarViewModel: ObservableObject { 6 | 7 | // MARK: - Properties 8 | 9 | @Published var currentSearchTerm: String 10 | @Published private(set) var isLoading = false 11 | 12 | private var cancellables = Set() 13 | 14 | // MARK: - Life cycle 15 | 16 | init( 17 | networkActivityPublisher: AnyPublisher, 18 | searchStore: SearchStore 19 | ) { 20 | self.currentSearchTerm = searchStore.currentSearchTerm 21 | 22 | networkActivityPublisher 23 | .receive(on: RunLoop.main) 24 | .map { $0 == .loading } 25 | .assign(to: \.isLoading, on: self) 26 | .store(in: &cancellables) 27 | 28 | searchTermPublisher() 29 | .receive(on: RunLoop.main) 30 | .assign(to: \.currentSearchTerm, on: searchStore) 31 | .store(in: &cancellables) 32 | } 33 | 34 | private func searchTermPublisher( 35 | ) -> AnyPublisher { 36 | $currentSearchTerm 37 | .removeDuplicates() 38 | .eraseToAnyPublisher() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pinboarding 2 | 3 | Pinboarding is a [Pinboard](https://pinboard.in) client for macOS. It's a work in progress, but already includes the following features: 4 | 5 | * automatic sync (and forced sync) 6 | * filter bookmarks by tag or visibility 7 | * add a new bookmark 8 | * open bookmark on browser 9 | * share a bookmark 10 | 11 | A [Pinboard](https://pinboard.in) account is required to use the app. 12 | 13 | ![](Images/dark.jpg) 14 | 15 | ![](Images/light.jpg) 16 | --------------------------------------------------------------------------------