diff --git a/Account/Sources/Account/WebFeed.swift b/Account/Sources/Account/WebFeed.swift index 17335d600..f1ac92d82 100644 --- a/Account/Sources/Account/WebFeed.swift +++ b/Account/Sources/Account/WebFeed.swift @@ -11,7 +11,7 @@ import RSCore import RSWeb import Articles -public final class WebFeed: Feed, Renamable, Hashable { +public final class WebFeed: Feed, Renamable, Hashable, ObservableObject { public var defaultReadFilterType: ReadFilterType { return .none diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index bb1b68bd2..8d62caec5 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -41,7 +41,6 @@ 176814652564BD7F00D98635 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176813B62564B9F800D98635 /* WidgetData.swift */; }; 1768146C2564BD8100D98635 /* WidgetDeepLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176813D82564BA8700D98635 /* WidgetDeepLinks.swift */; }; 1768147B2564BE5400D98635 /* widget-sample.json in Resources */ = {isa = PBXBuildFile; fileRef = 1768147A2564BE5400D98635 /* widget-sample.json */; }; - 177A0C2D25454AAB00D7EAF6 /* ReaderAPIAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177A0C2C25454AAB00D7EAF6 /* ReaderAPIAccountViewController.swift */; }; 178A9F9D2549449F00AB7E9D /* AddAccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178A9F9C2549449F00AB7E9D /* AddAccountsView.swift */; }; 178A9F9E2549449F00AB7E9D /* AddAccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178A9F9C2549449F00AB7E9D /* AddAccountsView.swift */; }; 179C39EA26F76B0500D4E741 /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 179C39E926F76B0500D4E741 /* Zip */; }; @@ -79,8 +78,6 @@ 51077C5A27A86D16000C71DB /* Hyperlegible.nnwtheme in Resources */ = {isa = PBXBuildFile; fileRef = 51077C5727A86D16000C71DB /* Hyperlegible.nnwtheme */; }; 5108F6B62375E612001ABC45 /* CacheCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5108F6B52375E612001ABC45 /* CacheCleaner.swift */; }; 5108F6B72375E612001ABC45 /* CacheCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5108F6B52375E612001ABC45 /* CacheCleaner.swift */; }; - 5108F6D22375EED2001ABC45 /* TimelineCustomizerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5108F6D12375EED2001ABC45 /* TimelineCustomizerViewController.swift */; }; - 5108F6D42375EEEF001ABC45 /* TimelinePreviewTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5108F6D32375EEEF001ABC45 /* TimelinePreviewTableViewController.swift */; }; 5108F6D823763094001ABC45 /* TickMarkSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5108F6D723763094001ABC45 /* TickMarkSlider.swift */; }; 510C416124E5CDE3008226FD /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510C416024E5CDE3008226FD /* ShareViewController.swift */; }; 510C416424E5CDE3008226FD /* ShareViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 510C416224E5CDE3008226FD /* ShareViewController.xib */; }; @@ -99,11 +96,9 @@ 510C418624E5D1B4008226FD /* ExtensionFeedAddRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */; }; 510C43F7243D035C009F70C3 /* ExtensionPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510C43F6243D035C009F70C3 /* ExtensionPoint.swift */; }; 510C43F8243D035C009F70C3 /* ExtensionPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510C43F6243D035C009F70C3 /* ExtensionPoint.swift */; }; - 510FFAB326EEA22C00F32265 /* ArticleThemesTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510FFAB226EEA22C00F32265 /* ArticleThemesTableViewController.swift */; }; 51102165233A7D6C0007A5F7 /* ArticleExtractorButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51102164233A7D6C0007A5F7 /* ArticleExtractorButton.swift */; }; 51107746243BEE2500D97C8C /* ExtensionPointPreferencesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51107745243BEE2500D97C8C /* ExtensionPointPreferencesViewController.swift */; }; 51107747243BEE2500D97C8C /* ExtensionPointPreferencesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51107745243BEE2500D97C8C /* ExtensionPointPreferencesViewController.swift */; }; - 5110C37D2373A8D100A9C04F /* InspectorIconHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5110C37C2373A8D100A9C04F /* InspectorIconHeaderView.swift */; }; 51126DA4225FDE2F00722696 /* RSImage-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51126DA3225FDE2F00722696 /* RSImage-Extensions.swift */; }; 5117715524E1EA0F00A2A836 /* ArticleExtractorButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5117715424E1EA0F00A2A836 /* ArticleExtractorButton.swift */; }; 5117715624E1EA0F00A2A836 /* ArticleExtractorButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5117715424E1EA0F00A2A836 /* ArticleExtractorButton.swift */; }; @@ -130,7 +125,6 @@ 512AF9C2236ED52C0066F8BE /* ImageHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512AF9C1236ED52C0066F8BE /* ImageHeaderView.swift */; }; 512AF9DD236F05230066F8BE /* InteractiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512AF9DC236F05230066F8BE /* InteractiveLabel.swift */; }; 512D554423C804DE0023FFFA /* OpenInSafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512D554323C804DE0023FFFA /* OpenInSafariActivity.swift */; }; - 512DD4C92430086400C17B1F /* CloudKitAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4C82430086400C17B1F /* CloudKitAccountViewController.swift */; }; 512E08E62268800D00BDCFDD /* FolderTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97A11ED9F180007D329B /* FolderTreeControllerDelegate.swift */; }; 512E08E72268801200BDCFDD /* WebFeedTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97611ED9EB96007D329B /* WebFeedTreeControllerDelegate.swift */; }; 512E09012268907400BDCFDD /* MasterFeedTableViewSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512E08F722688F7C00BDCFDD /* MasterFeedTableViewSectionHeader.swift */; }; @@ -187,7 +181,6 @@ 513F32812593EF180003048F /* Account in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 516B695E24D2F33B00B5702F /* Account */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 513F32882593EF8F0003048F /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 513F32872593EF8F0003048F /* RSCore */; }; 513F32892593EF8F0003048F /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 513F32872593EF8F0003048F /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - 5141E7392373C18B0013FF27 /* WebFeedInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5141E7382373C18B0013FF27 /* WebFeedInspectorViewController.swift */; }; 514217062921C9DD00963F14 /* Bundle-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BF42273625800C787DC /* Bundle-Extensions.swift */; }; 5142192A23522B5500E07E2C /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5142192923522B5500E07E2C /* ImageViewController.swift */; }; 514219372352510100E07E2C /* ImageScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514219362352510100E07E2C /* ImageScrollView.swift */; }; @@ -224,15 +217,9 @@ 515A5181243E90260089E588 /* ExtensionPointIdentifer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515A5176243E90200089E588 /* ExtensionPointIdentifer.swift */; }; 515D4FCA23257CB500EE1167 /* Node-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97971ED9EFAA007D329B /* Node-Extensions.swift */; }; 515D4FCC2325815A00EE1167 /* SafariExt.js in Resources */ = {isa = PBXBuildFile; fileRef = 515D4FCB2325815A00EE1167 /* SafariExt.js */; }; - 516244E3241E19F000B61C47 /* ColorPaletteTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516244E2241E19F000B61C47 /* ColorPaletteTableViewController.swift */; }; 51627A6723861DA3007B3B4B /* MasterFeedViewController+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A6623861DA3007B3B4B /* MasterFeedViewController+Drag.swift */; }; 51627A6923861DED007B3B4B /* MasterFeedViewController+Drop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A6823861DED007B3B4B /* MasterFeedViewController+Drop.swift */; }; 51627A93238A3836007B3B4B /* CroppingPreviewParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A92238A3836007B3B4B /* CroppingPreviewParameters.swift */; }; - 516A093723609A3600EAE89B /* SettingsComboTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 516A091D23609A3600EAE89B /* SettingsComboTableViewCell.xib */; }; - 516A09392360A2AE00EAE89B /* SettingsComboTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516A09382360A2AE00EAE89B /* SettingsComboTableViewCell.swift */; }; - 516A093B2360A4A000EAE89B /* SettingsTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 516A093A2360A4A000EAE89B /* SettingsTableViewCell.xib */; }; - 516A09402361240900EAE89B /* Account.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 516A093F2361240900EAE89B /* Account.storyboard */; }; - 516A09422361248000EAE89B /* Inspector.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 516A09412361248000EAE89B /* Inspector.storyboard */; }; 516AE9B32371C372007DEEAA /* MasterFeedTableViewSectionHeaderLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516AE9B22371C372007DEEAA /* MasterFeedTableViewSectionHeaderLayout.swift */; }; 516AE9DF2372269A007DEEAA /* IconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516AE9DE2372269A007DEEAA /* IconImage.swift */; }; 516AE9E02372269A007DEEAA /* IconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516AE9DE2372269A007DEEAA /* IconImage.swift */; }; @@ -277,17 +264,8 @@ 519B8D332143397200FA689C /* SharingServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519B8D322143397200FA689C /* SharingServiceDelegate.swift */; }; 519CA8E525841DB700EB079A /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = 519CA8E425841DB700EB079A /* CrashReporter */; }; 519E743D22C663F900A78E47 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E743422C663F900A78E47 /* SceneDelegate.swift */; }; - 519ED456244828C3007F8E94 /* AddExtensionPointViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519ED455244828C3007F8E94 /* AddExtensionPointViewController.swift */; }; - 519ED47A24482AEB007F8E94 /* EnableExtensionPointViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519ED47924482AEB007F8E94 /* EnableExtensionPointViewController.swift */; }; - 519ED47C24488C6F007F8E94 /* ExtensionInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519ED47B24488C6F007F8E94 /* ExtensionInspectorViewController.swift */; }; 51A052CE244FB9D7006C2024 /* AddFeedWIndowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A052CD244FB9D6006C2024 /* AddFeedWIndowController.swift */; }; 51A052CF244FB9D7006C2024 /* AddFeedWIndowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A052CD244FB9D6006C2024 /* AddFeedWIndowController.swift */; }; - 51A16999235E10D700EB091F /* LocalAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A1698F235E10D600EB091F /* LocalAccountViewController.swift */; }; - 51A1699A235E10D700EB091F /* Settings.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 51A16990235E10D600EB091F /* Settings.storyboard */; }; - 51A1699B235E10D700EB091F /* AccountInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A16991235E10D600EB091F /* AccountInspectorViewController.swift */; }; - 51A1699C235E10D700EB091F /* AddAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A16992235E10D600EB091F /* AddAccountViewController.swift */; }; - 51A1699D235E10D700EB091F /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A16993235E10D600EB091F /* SettingsViewController.swift */; }; - 51A169A0235E10D700EB091F /* FeedbinAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */; }; 51A66685238075AE00CB272D /* AddWebFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */; }; 51A737AE24DB19730015FA66 /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 51A737AD24DB19730015FA66 /* RSCore */; }; 51A737AF24DB19730015FA66 /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 51A737AD24DB19730015FA66 /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; @@ -658,7 +636,6 @@ 65ED4098235DEF770081F399 /* netnewswire-subscribe-to-feed.js in Resources */ = {isa = PBXBuildFile; fileRef = 6581C73F20CED60100F4AD34 /* netnewswire-subscribe-to-feed.js */; }; 65ED40A0235DEFF00081F399 /* container-migration.plist in Resources */ = {isa = PBXBuildFile; fileRef = 65ED409F235DEFF00081F399 /* container-migration.plist */; }; 65ED40A1235DEFF00081F399 /* container-migration.plist in Resources */ = {isa = PBXBuildFile; fileRef = 65ED409F235DEFF00081F399 /* container-migration.plist */; }; - 769F2ED513DA03EE75B993A8 /* NewsBlurAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */; }; 8405DD8A2213E0E3008CE1BF /* DetailContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD892213E0E3008CE1BF /* DetailContainerView.swift */; }; 8405DD9922153B6B008CE1BF /* TimelineContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD9822153B6B008CE1BF /* TimelineContainerView.swift */; }; 8405DD9C22153BD7008CE1BF /* NSView-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD9B22153BD7008CE1BF /* NSView-Extensions.swift */; }; @@ -840,8 +817,40 @@ DDF9E1D728EDF2FC000BC355 /* notificationSoundBlip.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = DDF9E1D628EDF2FC000BC355 /* notificationSoundBlip.mp3 */; }; DDF9E1D828EDF2FC000BC355 /* notificationSoundBlip.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = DDF9E1D628EDF2FC000BC355 /* notificationSoundBlip.mp3 */; }; DDF9E1D928EDF2FC000BC355 /* notificationSoundBlip.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = DDF9E1D628EDF2FC000BC355 /* notificationSoundBlip.mp3 */; }; + DF28B44D294ED52700C4D8CA /* View+DismissOnExternalContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF28B44C294ED52700C4D8CA /* View+DismissOnExternalContext.swift */; }; + DF28B44F294ED92F00C4D8CA /* NewsBlurAddAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF28B44E294ED92F00C4D8CA /* NewsBlurAddAccountView.swift */; }; + DF28B451294EFC6C00C4D8CA /* View+DismissOnAccountAdd.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF28B450294EFC6C00C4D8CA /* View+DismissOnAccountAdd.swift */; }; + DF28B453294FE6C600C4D8CA /* EnableExtensionPointView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF28B452294FE6C600C4D8CA /* EnableExtensionPointView.swift */; }; + DF28B455294FE74A00C4D8CA /* ExtensionSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF28B454294FE74A00C4D8CA /* ExtensionSectionHeader.swift */; }; + DF28B4572950163F00C4D8CA /* EnableExtensionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF28B4562950163F00C4D8CA /* EnableExtensionViewModel.swift */; }; + DF3630EB2936183D00326FB8 /* OPMLDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3630EA2936183D00326FB8 /* OPMLDocument.swift */; }; + DF3630EC2936183D00326FB8 /* OPMLDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3630EA2936183D00326FB8 /* OPMLDocument.swift */; }; + DF3630ED2936183D00326FB8 /* OPMLDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3630EA2936183D00326FB8 /* OPMLDocument.swift */; }; + DF3630EF293618A900326FB8 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3630EE293618A900326FB8 /* SettingsViewModel.swift */; }; + DF394F0029357A180081EB6E /* NewArticleNotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF394EFF29357A180081EB6E /* NewArticleNotificationsView.swift */; }; + DF47CDB2294803AB00FCD57E /* AddExtensionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF47CDB1294803AB00FCD57E /* AddExtensionListView.swift */; }; + DF59F072292085B800ACD33D /* ColorPaletteSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF59F071292085B800ACD33D /* ColorPaletteSelectorView.swift */; }; + DF59F0742920DB5100ACD33D /* AccountsManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF59F0732920DB5100ACD33D /* AccountsManagementView.swift */; }; DF5AD10128D6922200CA3BF7 /* SmartFeedSummaryWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1768144D2564BCE000D98635 /* SmartFeedSummaryWidget.swift */; }; + DF766FED29377FD9006FBBE2 /* ExtensionsManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF766FEC29377FD9006FBBE2 /* ExtensionsManagementView.swift */; }; DF790D6228E990A900455FC7 /* AboutData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF790D6128E990A900455FC7 /* AboutData.swift */; }; + DF84E563295122BA0045C334 /* TimelineCustomizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF84E562295122BA0045C334 /* TimelineCustomizerView.swift */; }; + DFB3497A294A962D00BC81AD /* AddAccountListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB34979294A962D00BC81AD /* AddAccountListView.swift */; }; + DFB34980294B085100BC81AD /* AccountInspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB3497F294B085100BC81AD /* AccountInspectorView.swift */; }; + DFB34988294B447F00BC81AD /* InjectedNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB34987294B447F00BC81AD /* InjectedNavigationView.swift */; }; + DFB3498A294B45AC00BC81AD /* ExtensionInspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB34989294B45AC00BC81AD /* ExtensionInspectorView.swift */; }; + DFB3498C294B4CA700BC81AD /* WebFeedInspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB3498B294B4CA700BC81AD /* WebFeedInspectorView.swift */; }; + DFB34994294C0E3900BC81AD /* ReaderAPIAddAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB34990294C0B2200BC81AD /* ReaderAPIAddAccountView.swift */; }; + DFB34996294C4DCB00BC81AD /* LocalizedNetNewsWireError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB34995294C4DCB00BC81AD /* LocalizedNetNewsWireError.swift */; }; + DFB34997294C4DCB00BC81AD /* LocalizedNetNewsWireError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB34995294C4DCB00BC81AD /* LocalizedNetNewsWireError.swift */; }; + DFB3499E294C5D5000BC81AD /* CloudKitAddAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB3499D294C5D5000BC81AD /* CloudKitAddAccountView.swift */; }; + DFB349A0294E87B700BC81AD /* LocalAddAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB3499F294E87B700BC81AD /* LocalAddAccountView.swift */; }; + DFB349A2294E90B500BC81AD /* FeedbinAddAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB349A1294E90B500BC81AD /* FeedbinAddAccountView.swift */; }; + DFB349A4294E914D00BC81AD /* AccountSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB349A3294E914D00BC81AD /* AccountSectionHeader.swift */; }; + DFBB4EAC2951BC0200639228 /* NNWThemeDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFBB4EAB2951BC0200639228 /* NNWThemeDocument.swift */; }; + DFBB4EAD2951BC0200639228 /* NNWThemeDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFBB4EAB2951BC0200639228 /* NNWThemeDocument.swift */; }; + DFBB4EAE2951BC0200639228 /* NNWThemeDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFBB4EAB2951BC0200639228 /* NNWThemeDocument.swift */; }; + DFBB4EB02951BCAC00639228 /* ArticleThemeManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFBB4EAF2951BCAC00639228 /* ArticleThemeManagerView.swift */; }; DFC14F0F28EA55BD00F6EE86 /* AboutWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFC14F0E28EA55BD00F6EE86 /* AboutWindowController.swift */; }; DFC14F1228EA5DC500F6EE86 /* AboutData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF790D6128E990A900455FC7 /* AboutData.swift */; }; DFC14F1328EA677C00F6EE86 /* Bundle-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BF42273625800C787DC /* Bundle-Extensions.swift */; }; @@ -851,9 +860,13 @@ DFCE4F9228EF26F100405869 /* About.plist in Resources */ = {isa = PBXBuildFile; fileRef = DFCE4F9028EF26F000405869 /* About.plist */; }; DFCE4F9428EF278300405869 /* Thanks.md in Resources */ = {isa = PBXBuildFile; fileRef = DFCE4F9328EF278300405869 /* Thanks.md */; }; DFCE4F9528EF278300405869 /* Thanks.md in Resources */ = {isa = PBXBuildFile; fileRef = DFCE4F9328EF278300405869 /* Thanks.md */; }; + DFD406F5291F79C900C02962 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD406F4291F79C900C02962 /* SettingsView.swift */; }; + DFD406F7291FB1A600C02962 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD406F6291FB1A600C02962 /* SafariView.swift */; }; + DFD406FA291FB5E400C02962 /* SettingsRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD406F9291FB5E400C02962 /* SettingsRows.swift */; }; + DFD406FC291FB63B00C02962 /* SettingsHelpSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD406FB291FB63B00C02962 /* SettingsHelpSheets.swift */; }; + DFD406FF291FDC0C00C02962 /* DisplayAndBehaviorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD406FE291FDC0C00C02962 /* DisplayAndBehaviorsView.swift */; }; + DFE522A32953DEF400376B77 /* CustomInsetGroupedRowStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFE522A22953DEF400376B77 /* CustomInsetGroupedRowStyle.swift */; }; DFFB8FC2279B75E300AC21D7 /* Account in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 51BC2F4A24D343A500E90810 /* Account */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - DFFC199827A0D0D7004B7AEF /* NotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFFC199727A0D0D7004B7AEF /* NotificationsViewController.swift */; }; - DFFC199A27A0D32A004B7AEF /* NotificationsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFFC199927A0D32A004B7AEF /* NotificationsTableViewCell.swift */; }; DFFC4E7428E95C01006B82AF /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFFC4E7328E95C01006B82AF /* AboutView.swift */; }; FF3ABF13232599810074C542 /* ArticleSorterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3ABF09232599450074C542 /* ArticleSorterTests.swift */; }; FF3ABF1523259DDB0074C542 /* ArticleSorter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3ABF1423259DDB0074C542 /* ArticleSorter.swift */; }; @@ -1148,7 +1161,6 @@ 176814562564BD0600D98635 /* ArticleItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleItemView.swift; sourceTree = ""; }; 1768147A2564BE5400D98635 /* widget-sample.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "widget-sample.json"; sourceTree = ""; }; 176814822564C02A00D98635 /* NetNewsWire_iOS_WidgetExtension.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = NetNewsWire_iOS_WidgetExtension.entitlements; sourceTree = ""; }; - 177A0C2C25454AAB00D7EAF6 /* ReaderAPIAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAPIAccountViewController.swift; sourceTree = ""; }; 178A9F9C2549449F00AB7E9D /* AddAccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountsView.swift; sourceTree = ""; }; 179D280C26F73D83003B2E0A /* ArticleThemePlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleThemePlist.swift; sourceTree = ""; }; 179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsNewsBlurWindowController.swift; sourceTree = ""; }; @@ -1164,8 +1176,6 @@ 5103A9F624225E4C00410853 /* AccountsAddCloudKitWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsAddCloudKitWindowController.swift; sourceTree = ""; }; 51077C5727A86D16000C71DB /* Hyperlegible.nnwtheme */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Hyperlegible.nnwtheme; sourceTree = ""; }; 5108F6B52375E612001ABC45 /* CacheCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheCleaner.swift; sourceTree = ""; }; - 5108F6D12375EED2001ABC45 /* TimelineCustomizerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineCustomizerViewController.swift; sourceTree = ""; }; - 5108F6D32375EEEF001ABC45 /* TimelinePreviewTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePreviewTableViewController.swift; sourceTree = ""; }; 5108F6D723763094001ABC45 /* TickMarkSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TickMarkSlider.swift; sourceTree = ""; }; 510C415C24E5CDE3008226FD /* NetNewsWire Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "NetNewsWire Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 510C416024E5CDE3008226FD /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; @@ -1174,10 +1184,8 @@ 510C416624E5CDE3008226FD /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; 510C418724E5D2E3008226FD /* NetNewsWire_shareextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_shareextension_target.xcconfig; sourceTree = ""; }; 510C43F6243D035C009F70C3 /* ExtensionPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionPoint.swift; sourceTree = ""; }; - 510FFAB226EEA22C00F32265 /* ArticleThemesTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleThemesTableViewController.swift; sourceTree = ""; }; 51102164233A7D6C0007A5F7 /* ArticleExtractorButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleExtractorButton.swift; sourceTree = ""; }; 51107745243BEE2500D97C8C /* ExtensionPointPreferencesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionPointPreferencesViewController.swift; sourceTree = ""; }; - 5110C37C2373A8D100A9C04F /* InspectorIconHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorIconHeaderView.swift; sourceTree = ""; }; 51121AA12265430A00BC0EC1 /* NetNewsWire_iOSapp_target.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = NetNewsWire_iOSapp_target.xcconfig; sourceTree = ""; }; 51126DA3225FDE2F00722696 /* RSImage-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RSImage-Extensions.swift"; sourceTree = ""; }; 5117715424E1EA0F00A2A836 /* ArticleExtractorButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleExtractorButton.swift; sourceTree = ""; }; @@ -1190,7 +1198,6 @@ 512AF9C1236ED52C0066F8BE /* ImageHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageHeaderView.swift; sourceTree = ""; }; 512AF9DC236F05230066F8BE /* InteractiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveLabel.swift; sourceTree = ""; }; 512D554323C804DE0023FFFA /* OpenInSafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInSafariActivity.swift; sourceTree = ""; }; - 512DD4C82430086400C17B1F /* CloudKitAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountViewController.swift; sourceTree = ""; }; 512E08F722688F7C00BDCFDD /* MasterFeedTableViewSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedTableViewSectionHeader.swift; sourceTree = ""; }; 51314617235A797400387FDC /* NetNewsWire_iOSintentextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_iOSintentextension_target.xcconfig; sourceTree = ""; }; 51314637235A7BBE00387FDC /* NetNewsWire iOS Intents Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "NetNewsWire iOS Intents Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1210,7 +1217,6 @@ 513C5CE8232571C2003D4054 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 513C5CEB232571C2003D4054 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; 513C5CED232571C2003D4054 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 5141E7382373C18B0013FF27 /* WebFeedInspectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebFeedInspectorViewController.swift; sourceTree = ""; }; 5141E7552374A2890013FF27 /* DetailIconSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailIconSchemeHandler.swift; sourceTree = ""; }; 5142192923522B5500E07E2C /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = ""; }; 514219362352510100E07E2C /* ImageScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageScrollView.swift; sourceTree = ""; }; @@ -1234,15 +1240,9 @@ 515D4FCB2325815A00EE1167 /* SafariExt.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = SafariExt.js; sourceTree = ""; }; 515D4FCD2325909200EE1167 /* NetNewsWire_iOS_ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NetNewsWire_iOS_ShareExtension.entitlements; sourceTree = ""; }; 515D4FCE2325B3D000EE1167 /* NetNewsWire_iOSshareextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_iOSshareextension_target.xcconfig; sourceTree = ""; }; - 516244E2241E19F000B61C47 /* ColorPaletteTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPaletteTableViewController.swift; sourceTree = ""; }; 51627A6623861DA3007B3B4B /* MasterFeedViewController+Drag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MasterFeedViewController+Drag.swift"; sourceTree = ""; }; 51627A6823861DED007B3B4B /* MasterFeedViewController+Drop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MasterFeedViewController+Drop.swift"; sourceTree = ""; }; 51627A92238A3836007B3B4B /* CroppingPreviewParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CroppingPreviewParameters.swift; sourceTree = ""; }; - 516A091D23609A3600EAE89B /* SettingsComboTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = SettingsComboTableViewCell.xib; sourceTree = ""; }; - 516A09382360A2AE00EAE89B /* SettingsComboTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsComboTableViewCell.swift; sourceTree = ""; }; - 516A093A2360A4A000EAE89B /* SettingsTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = SettingsTableViewCell.xib; sourceTree = ""; }; - 516A093F2361240900EAE89B /* Account.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Account.storyboard; sourceTree = ""; }; - 516A09412361248000EAE89B /* Inspector.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Inspector.storyboard; sourceTree = ""; }; 516AE5FF246AF34100731738 /* RedditAdd.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = RedditAdd.storyboard; sourceTree = ""; }; 516AE601246AF36100731738 /* RedditSelectTypeTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedditSelectTypeTableViewController.swift; sourceTree = ""; }; 516AE603246AF37B00731738 /* RedditSelectAccountTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedditSelectAccountTableViewController.swift; sourceTree = ""; }; @@ -1275,16 +1275,7 @@ 5195C1DB2720BD3000888867 /* MasterFeedRowIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedRowIdentifier.swift; sourceTree = ""; }; 519B8D322143397200FA689C /* SharingServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingServiceDelegate.swift; sourceTree = ""; }; 519E743422C663F900A78E47 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 519ED455244828C3007F8E94 /* AddExtensionPointViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExtensionPointViewController.swift; sourceTree = ""; }; - 519ED47924482AEB007F8E94 /* EnableExtensionPointViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnableExtensionPointViewController.swift; sourceTree = ""; }; - 519ED47B24488C6F007F8E94 /* ExtensionInspectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionInspectorViewController.swift; sourceTree = ""; }; 51A052CD244FB9D6006C2024 /* AddFeedWIndowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AddFeedWIndowController.swift; path = AddFeed/AddFeedWIndowController.swift; sourceTree = ""; }; - 51A1698F235E10D600EB091F /* LocalAccountViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalAccountViewController.swift; sourceTree = ""; }; - 51A16990235E10D600EB091F /* Settings.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Settings.storyboard; sourceTree = ""; }; - 51A16991235E10D600EB091F /* AccountInspectorViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountInspectorViewController.swift; sourceTree = ""; }; - 51A16992235E10D600EB091F /* AddAccountViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddAccountViewController.swift; sourceTree = ""; }; - 51A16993235E10D600EB091F /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; - 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedbinAccountViewController.swift; sourceTree = ""; }; 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddWebFeedDefaultContainer.swift; sourceTree = ""; }; 51A9A5E32380C8870033AADF /* ShareFolderPickerAccountCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ShareFolderPickerAccountCell.xib; sourceTree = ""; }; 51A9A5E52380C8B20033AADF /* ShareFolderPickerFolderCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ShareFolderPickerFolderCell.xib; sourceTree = ""; }; @@ -1390,7 +1381,6 @@ 65ED409F235DEFF00081F399 /* container-migration.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "container-migration.plist"; sourceTree = ""; }; 65ED40F2235DF5E00081F399 /* NetNewsWire_macapp_target_macappstore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_macapp_target_macappstore.xcconfig; sourceTree = ""; }; 65ED4186235E045B0081F399 /* NetNewsWire_safariextension_target_macappstore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target_macappstore.xcconfig; sourceTree = ""; }; - 769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurAccountViewController.swift; sourceTree = ""; }; 8405DD892213E0E3008CE1BF /* DetailContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailContainerView.swift; sourceTree = ""; }; 8405DD9822153B6B008CE1BF /* TimelineContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineContainerView.swift; sourceTree = ""; }; 8405DD9B22153BD7008CE1BF /* NSView-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView-Extensions.swift"; sourceTree = ""; }; @@ -1579,15 +1569,46 @@ D5F4EDB820074D7C00B9E363 /* Folder+Scriptability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Folder+Scriptability.swift"; sourceTree = ""; }; DD82AB09231003F6002269DF /* SharingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SharingTests.swift; sourceTree = ""; }; DDF9E1D628EDF2FC000BC355 /* notificationSoundBlip.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = notificationSoundBlip.mp3; sourceTree = ""; }; + DF28B44C294ED52700C4D8CA /* View+DismissOnExternalContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+DismissOnExternalContext.swift"; sourceTree = ""; }; + DF28B44E294ED92F00C4D8CA /* NewsBlurAddAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsBlurAddAccountView.swift; sourceTree = ""; }; + DF28B450294EFC6C00C4D8CA /* View+DismissOnAccountAdd.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+DismissOnAccountAdd.swift"; sourceTree = ""; }; + DF28B452294FE6C600C4D8CA /* EnableExtensionPointView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnableExtensionPointView.swift; sourceTree = ""; }; + DF28B454294FE74A00C4D8CA /* ExtensionSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionSectionHeader.swift; sourceTree = ""; }; + DF28B4562950163F00C4D8CA /* EnableExtensionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnableExtensionViewModel.swift; sourceTree = ""; }; + DF3630EA2936183D00326FB8 /* OPMLDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLDocument.swift; sourceTree = ""; }; + DF3630EE293618A900326FB8 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + DF394EFF29357A180081EB6E /* NewArticleNotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewArticleNotificationsView.swift; sourceTree = ""; }; + DF47CDB1294803AB00FCD57E /* AddExtensionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExtensionListView.swift; sourceTree = ""; }; + DF59F071292085B800ACD33D /* ColorPaletteSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPaletteSelectorView.swift; sourceTree = ""; }; + DF59F0732920DB5100ACD33D /* AccountsManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsManagementView.swift; sourceTree = ""; }; + DF766FEC29377FD9006FBBE2 /* ExtensionsManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionsManagementView.swift; sourceTree = ""; }; DF790D6128E990A900455FC7 /* AboutData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutData.swift; sourceTree = ""; }; + DF84E562295122BA0045C334 /* TimelineCustomizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineCustomizerView.swift; sourceTree = ""; }; + DFB34979294A962D00BC81AD /* AddAccountListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountListView.swift; sourceTree = ""; }; + DFB3497F294B085100BC81AD /* AccountInspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountInspectorView.swift; sourceTree = ""; }; + DFB34987294B447F00BC81AD /* InjectedNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectedNavigationView.swift; sourceTree = ""; }; + DFB34989294B45AC00BC81AD /* ExtensionInspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionInspectorView.swift; sourceTree = ""; }; + DFB3498B294B4CA700BC81AD /* WebFeedInspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebFeedInspectorView.swift; sourceTree = ""; }; + DFB34990294C0B2200BC81AD /* ReaderAPIAddAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAPIAddAccountView.swift; sourceTree = ""; }; + DFB34995294C4DCB00BC81AD /* LocalizedNetNewsWireError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedNetNewsWireError.swift; sourceTree = ""; }; + DFB3499D294C5D5000BC81AD /* CloudKitAddAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAddAccountView.swift; sourceTree = ""; }; + DFB3499F294E87B700BC81AD /* LocalAddAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAddAccountView.swift; sourceTree = ""; }; + DFB349A1294E90B500BC81AD /* FeedbinAddAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAddAccountView.swift; sourceTree = ""; }; + DFB349A3294E914D00BC81AD /* AccountSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSectionHeader.swift; sourceTree = ""; }; + DFBB4EAB2951BC0200639228 /* NNWThemeDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NNWThemeDocument.swift; sourceTree = ""; }; + DFBB4EAF2951BCAC00639228 /* ArticleThemeManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleThemeManagerView.swift; sourceTree = ""; }; DFC14F0E28EA55BD00F6EE86 /* AboutWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutWindowController.swift; sourceTree = ""; }; DFC14F1428EB177000F6EE86 /* AboutNetNewsWireView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutNetNewsWireView.swift; sourceTree = ""; }; DFC14F1628EB17A800F6EE86 /* CreditsNetNewsWireView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreditsNetNewsWireView.swift; sourceTree = ""; }; DFCE4F9028EF26F000405869 /* About.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = About.plist; sourceTree = ""; }; DFCE4F9328EF278300405869 /* Thanks.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = Thanks.md; sourceTree = ""; }; + DFD406F4291F79C900C02962 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + DFD406F6291FB1A600C02962 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; + DFD406F9291FB5E400C02962 /* SettingsRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRows.swift; sourceTree = ""; }; + DFD406FB291FB63B00C02962 /* SettingsHelpSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHelpSheets.swift; sourceTree = ""; }; + DFD406FE291FDC0C00C02962 /* DisplayAndBehaviorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayAndBehaviorsView.swift; sourceTree = ""; }; DFD6AACB27ADE80900463FAD /* NewsFax.nnwtheme */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = NewsFax.nnwtheme; sourceTree = ""; }; - DFFC199727A0D0D7004B7AEF /* NotificationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsViewController.swift; sourceTree = ""; }; - DFFC199927A0D32A004B7AEF /* NotificationsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewCell.swift; sourceTree = ""; }; + DFE522A22953DEF400376B77 /* CustomInsetGroupedRowStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomInsetGroupedRowStyle.swift; sourceTree = ""; }; DFFC4E7328E95C01006B82AF /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; FF3ABF09232599450074C542 /* ArticleSorterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleSorterTests.swift; sourceTree = ""; }; FF3ABF1423259DDB0074C542 /* ArticleSorter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleSorter.swift; sourceTree = ""; }; @@ -1855,11 +1876,9 @@ 5123DB95233EC69300282CC9 /* Inspector */ = { isa = PBXGroup; children = ( - 516A09412361248000EAE89B /* Inspector.storyboard */, - 51A16991235E10D600EB091F /* AccountInspectorViewController.swift */, - 519ED47B24488C6F007F8E94 /* ExtensionInspectorViewController.swift */, - 5110C37C2373A8D100A9C04F /* InspectorIconHeaderView.swift */, - 5141E7382373C18B0013FF27 /* WebFeedInspectorViewController.swift */, + DFB3497F294B085100BC81AD /* AccountInspectorView.swift */, + DFB34989294B45AC00BC81AD /* ExtensionInspectorView.swift */, + DFB3498B294B4CA700BC81AD /* WebFeedInspectorView.swift */, ); path = Inspector; sourceTree = ""; @@ -1929,12 +1948,11 @@ 516A093E236123A800EAE89B /* Account */ = { isa = PBXGroup; children = ( - 516A093F2361240900EAE89B /* Account.storyboard */, - 51A1698F235E10D600EB091F /* LocalAccountViewController.swift */, - 512DD4C82430086400C17B1F /* CloudKitAccountViewController.swift */, - 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */, - 769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */, - 177A0C2C25454AAB00D7EAF6 /* ReaderAPIAccountViewController.swift */, + DFB3499F294E87B700BC81AD /* LocalAddAccountView.swift */, + DFB3499D294C5D5000BC81AD /* CloudKitAddAccountView.swift */, + DFB349A1294E90B500BC81AD /* FeedbinAddAccountView.swift */, + DF28B44E294ED92F00C4D8CA /* NewsBlurAddAccountView.swift */, + DFB34990294C0B2200BC81AD /* ReaderAPIAddAccountView.swift */, ); path = Account; sourceTree = ""; @@ -1964,22 +1982,12 @@ 5183CCEB227117C70010922C /* Settings */ = { isa = PBXGroup; children = ( - DFFC4E7328E95C01006B82AF /* AboutView.swift */, - 51A16992235E10D600EB091F /* AddAccountViewController.swift */, - 519ED455244828C3007F8E94 /* AddExtensionPointViewController.swift */, + DF766FEB2936344D006FBBE2 /* General */, + DFD406FD291FDBD900C02962 /* Appearance */, + DF59F0752920E42000ACD33D /* Account and Extensions */, + DF3630E92936038400326FB8 /* New Article Notifications */, + DF766FEA2936337A006FBBE2 /* Help */, 5137C2E926F63AE6009EFEDB /* ArticleThemeImporter.swift */, - 510FFAB226EEA22C00F32265 /* ArticleThemesTableViewController.swift */, - 516244E2241E19F000B61C47 /* ColorPaletteTableViewController.swift */, - 519ED47924482AEB007F8E94 /* EnableExtensionPointViewController.swift */, - 51A16990235E10D600EB091F /* Settings.storyboard */, - 516A09382360A2AE00EAE89B /* SettingsComboTableViewCell.swift */, - 516A091D23609A3600EAE89B /* SettingsComboTableViewCell.xib */, - 516A093A2360A4A000EAE89B /* SettingsTableViewCell.xib */, - 51A16993235E10D600EB091F /* SettingsViewController.swift */, - 5108F6D12375EED2001ABC45 /* TimelineCustomizerViewController.swift */, - 5108F6D32375EEEF001ABC45 /* TimelinePreviewTableViewController.swift */, - DFFC199727A0D0D7004B7AEF /* NotificationsViewController.swift */, - DFFC199927A0D32A004B7AEF /* NotificationsTableViewCell.swift */, ); path = Settings; sourceTree = ""; @@ -2030,6 +2038,7 @@ 5183CCD9226E31A50010922C /* NonIntrinsicImageView.swift */, 5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */, 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */, + DFD406F6291FB1A600C02962 /* SafariView.swift */, 51C45250226506F400C03939 /* String-Extensions.swift */, 5108F6D723763094001ABC45 /* TickMarkSlider.swift */, C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */, @@ -2123,6 +2132,7 @@ 51C452802265093600C03939 /* Add */ = { isa = PBXGroup; children = ( + DFB3497E294B07D900BC81AD /* Views */, 51C452822265093600C03939 /* Add.storyboard */, 51E4397F23805EBC00015C31 /* AddComboTableViewCell.swift */, 51E43961238037C400015C31 /* AddFeedFolderViewController.swift */, @@ -2578,6 +2588,7 @@ 511D43CE231FA51100FB1562 /* Resources */, 176813A22564B9D100D98635 /* Widget */, 173A64162547BE0900267F6E /* AccountType+Helpers.swift */, + DFB34985294B3B0800BC81AD /* Localizations */, ); path = Shared; sourceTree = ""; @@ -2689,6 +2700,7 @@ 5123DB95233EC69300282CC9 /* Inspector */, 513145F9235A55A700387FDC /* Intents */, 5183CCEB227117C70010922C /* Settings */, + DFB34986294B446300BC81AD /* SwiftUI Extensions */, 51C45245226506C800C03939 /* UIKit Extensions */, 513C5CE7232571C2003D4054 /* ShareExtension */, 51314643235A7C2300387FDC /* IntentsExtension */, @@ -2726,6 +2738,8 @@ children = ( 849A97591ED9EB0D007D329B /* DefaultFeedsImporter.swift */, 84A3EE52223B667F00557320 /* DefaultFeeds.opml */, + DF3630EA2936183D00326FB8 /* OPMLDocument.swift */, + DFBB4EAB2951BC0200639228 /* NNWThemeDocument.swift */, ); path = Importers; sourceTree = ""; @@ -2841,6 +2855,90 @@ path = Scriptability; sourceTree = ""; }; + DF3630E92936038400326FB8 /* New Article Notifications */ = { + isa = PBXGroup; + children = ( + DF394EFF29357A180081EB6E /* NewArticleNotificationsView.swift */, + ); + path = "New Article Notifications"; + sourceTree = ""; + }; + DF59F0752920E42000ACD33D /* Account and Extensions */ = { + isa = PBXGroup; + children = ( + DFB3497B294AA95200BC81AD /* Accounts */, + DFB3497C294AA95A00BC81AD /* Extensions */, + ); + path = "Account and Extensions"; + sourceTree = ""; + }; + DF766FEA2936337A006FBBE2 /* Help */ = { + isa = PBXGroup; + children = ( + DFFC4E7328E95C01006B82AF /* AboutView.swift */, + DFD406FB291FB63B00C02962 /* SettingsHelpSheets.swift */, + ); + path = Help; + sourceTree = ""; + }; + DF766FEB2936344D006FBBE2 /* General */ = { + isa = PBXGroup; + children = ( + DFD406F4291F79C900C02962 /* SettingsView.swift */, + DF3630EE293618A900326FB8 /* SettingsViewModel.swift */, + DFD406F9291FB5E400C02962 /* SettingsRows.swift */, + ); + path = General; + sourceTree = ""; + }; + DFB3497B294AA95200BC81AD /* Accounts */ = { + isa = PBXGroup; + children = ( + DF59F0732920DB5100ACD33D /* AccountsManagementView.swift */, + DFB34979294A962D00BC81AD /* AddAccountListView.swift */, + ); + path = Accounts; + sourceTree = ""; + }; + DFB3497C294AA95A00BC81AD /* Extensions */ = { + isa = PBXGroup; + children = ( + DF766FEC29377FD9006FBBE2 /* ExtensionsManagementView.swift */, + DF47CDB1294803AB00FCD57E /* AddExtensionListView.swift */, + DF28B452294FE6C600C4D8CA /* EnableExtensionPointView.swift */, + DF28B4562950163F00C4D8CA /* EnableExtensionViewModel.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + DFB3497E294B07D900BC81AD /* Views */ = { + isa = PBXGroup; + children = ( + ); + path = Views; + sourceTree = ""; + }; + DFB34985294B3B0800BC81AD /* Localizations */ = { + isa = PBXGroup; + children = ( + DFB34995294C4DCB00BC81AD /* LocalizedNetNewsWireError.swift */, + ); + path = Localizations; + sourceTree = ""; + }; + DFB34986294B446300BC81AD /* SwiftUI Extensions */ = { + isa = PBXGroup; + children = ( + DFB349A3294E914D00BC81AD /* AccountSectionHeader.swift */, + DFE522A22953DEF400376B77 /* CustomInsetGroupedRowStyle.swift */, + DF28B454294FE74A00C4D8CA /* ExtensionSectionHeader.swift */, + DFB34987294B447F00BC81AD /* InjectedNavigationView.swift */, + DF28B450294EFC6C00C4D8CA /* View+DismissOnAccountAdd.swift */, + DF28B44C294ED52700C4D8CA /* View+DismissOnExternalContext.swift */, + ); + path = "SwiftUI Extensions"; + sourceTree = ""; + }; DFC14F0928EA51AB00F6EE86 /* About */ = { isa = PBXGroup; children = ( @@ -2851,6 +2949,17 @@ path = About; sourceTree = ""; }; + DFD406FD291FDBD900C02962 /* Appearance */ = { + isa = PBXGroup; + children = ( + DFD406FE291FDC0C00C02962 /* DisplayAndBehaviorsView.swift */, + DF59F071292085B800ACD33D /* ColorPaletteSelectorView.swift */, + DF84E562295122BA0045C334 /* TimelineCustomizerView.swift */, + DFBB4EAF2951BCAC00639228 /* ArticleThemeManagerView.swift */, + ); + path = Appearance; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -3413,23 +3522,20 @@ 511D43D2231FA62C00FB1562 /* GlobalKeyboardShortcuts.plist in Resources */, 84C9FCA12262A1B300D921D6 /* Main.storyboard in Resources */, 51BB7C312335ACDE008E8144 /* page.html in Resources */, + 512392C324E3451400F11704 /* TwitterAdd.storyboard in Resources */, 516A093723609A3600EAE89B /* SettingsComboTableViewCell.xib in Resources */, 51077C5A27A86D16000C71DB /* Hyperlegible.nnwtheme in Resources */, - 516A09422361248000EAE89B /* Inspector.storyboard in Resources */, DDF9E1D928EDF2FC000BC355 /* notificationSoundBlip.mp3 in Resources */, 51DEE81A26FBFF84006DAA56 /* Promenade.nnwtheme in Resources */, 1768140B2564BB8300D98635 /* NetNewsWire_iOSwidgetextension_target.xcconfig in Resources */, 5103A9B424216A4200410853 /* blank.html in Resources */, 51D0214826ED617100FF2E0F /* core.css in Resources */, 84C9FCA42262A1B800D921D6 /* LaunchScreenPhone.storyboard in Resources */, - 516A093B2360A4A000EAE89B /* SettingsTableViewCell.xib in Resources */, 511D43D1231FA62800FB1562 /* SidebarKeyboardShortcuts.plist in Resources */, - 516A09402361240900EAE89B /* Account.storyboard in Resources */, 51C452AB22650DC600C03939 /* template.html in Resources */, 84A3EE61223B667F00557320 /* DefaultFeeds.opml in Resources */, B27EEBFB244D15F3000932E6 /* stylesheet.css in Resources */, 511D43CF231FA62200FB1562 /* DetailKeyboardShortcuts.plist in Resources */, - 51A1699A235E10D700EB091F /* Settings.storyboard in Resources */, 49F40DF92335B71000552BF4 /* newsfoot.js in Resources */, 512392C024E33A3C00F11704 /* RedditAdd.storyboard in Resources */, 5177C21327B07CFE00643901 /* NewsFax.nnwtheme in Resources */, @@ -3813,6 +3919,7 @@ 65ED3FBD235DEF6C0081F399 /* AppDefaults.swift in Sources */, 65ED3FBE235DEF6C0081F399 /* Account+Scriptability.swift in Sources */, 65ED3FBF235DEF6C0081F399 /* NothingInspectorViewController.swift in Sources */, + DF3630EC2936183D00326FB8 /* OPMLDocument.swift in Sources */, 1710B92A255246F900679C0D /* EnableExtensionPointHelpView.swift in Sources */, 51927A0528E28D1C000AE856 /* MainWindow.swift in Sources */, 65ED3FC0235DEF6C0081F399 /* AppNotifications.swift in Sources */, @@ -3841,6 +3948,7 @@ 65ED3FD5235DEF6C0081F399 /* SmartFeed.swift in Sources */, 51333D1724685D2E00EB5C91 /* AddRedditFeedWindowController.swift in Sources */, 65ED3FD6235DEF6C0081F399 /* MarkStatusCommand.swift in Sources */, + DFBB4EAD2951BC0200639228 /* NNWThemeDocument.swift in Sources */, 5183CFB0254C78C8006B83A5 /* EnableExtensionPointView.swift in Sources */, 65ED3FD7235DEF6C0081F399 /* NSApplication+Scriptability.swift in Sources */, 65ED3FD8235DEF6C0081F399 /* NSView-Extensions.swift in Sources */, @@ -3993,18 +4101,22 @@ buildActionMask = 2147483647; files = ( 51E36E71239D6610006F47A5 /* AddFeedSelectFolderTableViewCell.swift in Sources */, - 512DD4C92430086400C17B1F /* CloudKitAccountViewController.swift in Sources */, 840D617F2029031C009BC708 /* AppDelegate.swift in Sources */, 512E08E72268801200BDCFDD /* WebFeedTreeControllerDelegate.swift in Sources */, 51C452A422650A2D00C03939 /* ArticleUtilities.swift in Sources */, 51EF0F79227716380050506E /* ColorHash.swift in Sources */, + DF28B4572950163F00C4D8CA /* EnableExtensionViewModel.swift in Sources */, + DF59F072292085B800ACD33D /* ColorPaletteSelectorView.swift in Sources */, 51F9F3FB23DFB25700A314FD /* Animations.swift in Sources */, + DFB34988294B447F00BC81AD /* InjectedNavigationView.swift in Sources */, 5195C1DA2720205F00888867 /* ShadowTableChanges.swift in Sources */, 5183CCDA226E31A50010922C /* NonIntrinsicImageView.swift in Sources */, B2B80778239C4C7000F191E0 /* RSImage-AppIcons.swift in Sources */, + DFB349A2294E90B500BC81AD /* FeedbinAddAccountView.swift in Sources */, 518ED21D23D0F26000E0A862 /* UIViewController-Extensions.swift in Sources */, - DFFC199827A0D0D7004B7AEF /* NotificationsViewController.swift in Sources */, + DFD406FF291FDC0C00C02962 /* DisplayAndBehaviorsView.swift in Sources */, 51A9A5F52380F6A60033AADF /* ModalNavigationController.swift in Sources */, + DFBB4EAE2951BC0200639228 /* NNWThemeDocument.swift in Sources */, 51EAED96231363EF00A9EEE3 /* NonIntrinsicButton.swift in Sources */, 51C4527B2265091600C03939 /* MasterUnreadIndicatorView.swift in Sources */, 5186A635235EF3A800C97195 /* VibrantLabel.swift in Sources */, @@ -4015,23 +4127,22 @@ 51C45291226509C800C03939 /* SmartFeed.swift in Sources */, 51C452A722650A3D00C03939 /* RSImage-Extensions.swift in Sources */, 511B9807237DCAC90028BCAA /* UserInfoKey.swift in Sources */, - DFFC199A27A0D32A004B7AEF /* NotificationsTableViewCell.swift in Sources */, 51C45269226508F600C03939 /* MasterFeedTableViewCell.swift in Sources */, 51F85BFD2275DCA800C787DC /* SingleLineUILabelSizer.swift in Sources */, 51E43962238037C400015C31 /* AddFeedFolderViewController.swift in Sources */, - 519ED47A24482AEB007F8E94 /* EnableExtensionPointViewController.swift in Sources */, 51C4528F226509BD00C03939 /* UnreadFeed.swift in Sources */, 51FD413B2342BD0500880194 /* MasterTimelineUnreadCountView.swift in Sources */, + DFB3497A294A962D00BC81AD /* AddAccountListView.swift in Sources */, 513146B2235A81A400387FDC /* AddWebFeedIntentHandler.swift in Sources */, 51D87EE12311D34700E63F03 /* ActivityType.swift in Sources */, 51C452772265091600C03939 /* MultilineUILabelSizer.swift in Sources */, 51C452A522650A2D00C03939 /* SmallIconProvider.swift in Sources */, + DF28B453294FE6C600C4D8CA /* EnableExtensionPointView.swift in Sources */, 51AB8AB323B7F4C6008F147D /* WebViewController.swift in Sources */, - 516A09392360A2AE00EAE89B /* SettingsComboTableViewCell.swift in Sources */, + DFD406F7291FB1A600C02962 /* SafariView.swift in Sources */, + DF3630ED2936183D00326FB8 /* OPMLDocument.swift in Sources */, 176813D22564BA5900D98635 /* WidgetDataEncoder.swift in Sources */, 51D5948722668EFA00DFC836 /* MarkStatusCommand.swift in Sources */, - 51A1699C235E10D700EB091F /* AddAccountViewController.swift in Sources */, - 51A16999235E10D700EB091F /* LocalAccountViewController.swift in Sources */, 176813D12564BA5900D98635 /* WidgetDataDecoder.swift in Sources */, 176813D02564BA5900D98635 /* WidgetData.swift in Sources */, 510289CD24519A1D00426DDF /* SelectComboTableViewCell.swift in Sources */, @@ -4046,20 +4157,24 @@ 51C4526B226508F600C03939 /* MasterFeedViewController.swift in Sources */, 5126EE97226CB48A00C22AFC /* SceneCoordinator.swift in Sources */, 84CAFCB022BC8C35007694F0 /* FetchRequestOperation.swift in Sources */, + DFD406FA291FB5E400C02962 /* SettingsRows.swift in Sources */, + DFB3499E294C5D5000BC81AD /* CloudKitAddAccountView.swift in Sources */, + DFB3498C294B4CA700BC81AD /* WebFeedInspectorView.swift in Sources */, + DFB349A0294E87B700BC81AD /* LocalAddAccountView.swift in Sources */, 5193CD5A245E44A90092735E /* RedditFeedProvider-Extensions.swift in Sources */, 51EF0F77227716200050506E /* FaviconGenerator.swift in Sources */, 51938DF3231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift in Sources */, 51C4525A226508D600C03939 /* UIStoryboard-Extensions.swift in Sources */, 517A745B2443665000B553B9 /* UIPageViewController-Extensions.swift in Sources */, + DF28B455294FE74A00C4D8CA /* ExtensionSectionHeader.swift in Sources */, 51BB7C272335A8E5008E8144 /* ArticleActivityItemSource.swift in Sources */, 51F85BF52273625800C787DC /* Bundle-Extensions.swift in Sources */, DF790D6228E990A900455FC7 /* AboutData.swift in Sources */, - 177A0C2D25454AAB00D7EAF6 /* ReaderAPIAccountViewController.swift in Sources */, 51C452A622650A3500C03939 /* Node-Extensions.swift in Sources */, + DF394F0029357A180081EB6E /* NewArticleNotificationsView.swift in Sources */, 51C45294226509C800C03939 /* SearchFeedDelegate.swift in Sources */, 5F323809231DF9F000706F6B /* VibrantTableViewCell.swift in Sources */, 51FE10042345529D0056195D /* UserNotificationManager.swift in Sources */, - 519ED47C24488C6F007F8E94 /* ExtensionInspectorViewController.swift in Sources */, 51C4CFF224D37D1F00AF9874 /* Secrets.swift in Sources */, 51C452A022650A1900C03939 /* WebFeedIconDownloader.swift in Sources */, 51C4529E22650A1900C03939 /* ImageDownloader.swift in Sources */, @@ -4071,17 +4186,19 @@ 51B5C87B23F2317700032075 /* ExtensionFeedAddRequest.swift in Sources */, 51627A93238A3836007B3B4B /* CroppingPreviewParameters.swift in Sources */, 512AF9DD236F05230066F8BE /* InteractiveLabel.swift in Sources */, + DFB3498A294B45AC00BC81AD /* ExtensionInspectorView.swift in Sources */, 51E3EB3D229AB08300645299 /* ErrorHandler.swift in Sources */, 5183CCE5226F4DFA0010922C /* RefreshInterval.swift in Sources */, 51C4529D22650A1000C03939 /* FaviconURLFinder.swift in Sources */, 5142192A23522B5500E07E2C /* ImageViewController.swift in Sources */, - 516244E3241E19F000B61C47 /* ColorPaletteTableViewController.swift in Sources */, 51C45258226508CF00C03939 /* AppAssets.swift in Sources */, + DF28B451294EFC6C00C4D8CA /* View+DismissOnAccountAdd.swift in Sources */, 51FA73A82332BE880090D516 /* ExtractedArticle.swift in Sources */, 51C4527C2265091600C03939 /* MasterTimelineDefaultCellLayout.swift in Sources */, 51E4398023805EBC00015C31 /* AddComboTableViewCell.swift in Sources */, 51C4529A22650A0400C03939 /* ArticleTheme.swift in Sources */, 51C4527F2265092C00C03939 /* ArticleViewController.swift in Sources */, + DFBB4EB02951BCAC00639228 /* ArticleThemeManagerView.swift in Sources */, 51C4526A226508F600C03939 /* MasterFeedTableViewCellLayout.swift in Sources */, 51C452AE2265104D00C03939 /* ArticleStringFormatter.swift in Sources */, 512E08E62268800D00BDCFDD /* FolderTreeControllerDelegate.swift in Sources */, @@ -4096,61 +4213,68 @@ 51C9DE5823EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift in Sources */, 51B5C87D23F2346200032075 /* ExtensionContainersFile.swift in Sources */, 51102165233A7D6C0007A5F7 /* ArticleExtractorButton.swift in Sources */, - 5141E7392373C18B0013FF27 /* WebFeedInspectorViewController.swift in Sources */, C5A6ED6D23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift in Sources */, - 5108F6D42375EEEF001ABC45 /* TimelinePreviewTableViewController.swift in Sources */, 84CAFCA522BC8C08007694F0 /* FetchRequestQueue.swift in Sources */, 512392BE24E33A3C00F11704 /* RedditSelectAccountTableViewController.swift in Sources */, 515A517B243E90260089E588 /* ExtensionPoint.swift in Sources */, 51C4529C22650A1000C03939 /* SingleFaviconDownloader.swift in Sources */, 17D643B226F8A436008D4C05 /* ArticleThemeDownloader.swift in Sources */, + DFB34997294C4DCB00BC81AD /* LocalizedNetNewsWireError.swift in Sources */, 51E595A6228CC36500FCC42B /* ArticleStatusSyncTimer.swift in Sources */, 51F9F3F723DF6DB200A314FD /* ArticleIconSchemeHandler.swift in Sources */, 512AF9C2236ED52C0066F8BE /* ImageHeaderView.swift in Sources */, 512392C124E33A3C00F11704 /* RedditSelectTypeTableViewController.swift in Sources */, 515A5181243E90260089E588 /* ExtensionPointIdentifer.swift in Sources */, + DF766FED29377FD9006FBBE2 /* ExtensionsManagementView.swift in Sources */, 51C45290226509C100C03939 /* PseudoFeed.swift in Sources */, 51C452A922650DC600C03939 /* ArticleRenderer.swift in Sources */, 51C45297226509E300C03939 /* DefaultFeedsImporter.swift in Sources */, 512E094D2268B8AB00BDCFDD /* DeleteCommand.swift in Sources */, - 5110C37D2373A8D100A9C04F /* InspectorIconHeaderView.swift in Sources */, + DFD406FC291FB63B00C02962 /* SettingsHelpSheets.swift in Sources */, 51F85BFB2275D85000C787DC /* Array-Extensions.swift in Sources */, 51C452AC22650FD200C03939 /* AppNotifications.swift in Sources */, 51EF0F7E2277A57D0050506E /* MasterTimelineAccessibilityCellLayout.swift in Sources */, - 51A1699B235E10D700EB091F /* AccountInspectorViewController.swift in Sources */, 512D554423C804DE0023FFFA /* OpenInSafariActivity.swift in Sources */, + DF47CDB2294803AB00FCD57E /* AddExtensionListView.swift in Sources */, + DFB34994294C0E3900BC81AD /* ReaderAPIAddAccountView.swift in Sources */, 512392C224E33A3C00F11704 /* RedditEnterDetailTableViewController.swift in Sources */, 51C452762265091600C03939 /* MasterTimelineViewController.swift in Sources */, 5195C1DC2720BD3000888867 /* MasterFeedRowIdentifier.swift in Sources */, 5108F6D823763094001ABC45 /* TickMarkSlider.swift in Sources */, + DF3630EF293618A900326FB8 /* SettingsViewModel.swift in Sources */, 51C452882265093600C03939 /* AddFeedViewController.swift in Sources */, 51B5C8C023F3866C00032075 /* ExtensionFeedAddRequestFile.swift in Sources */, - 51A169A0235E10D700EB091F /* FeedbinAccountViewController.swift in Sources */, 51934CCE2310792F006127BE /* ActivityManager.swift in Sources */, + DFB34980294B085100BC81AD /* AccountInspectorView.swift in Sources */, 5108F6B72375E612001ABC45 /* CacheCleaner.swift in Sources */, + DF59F0742920DB5100ACD33D /* AccountsManagementView.swift in Sources */, 518651DA235621840078E021 /* ImageTransition.swift in Sources */, 51C266EA238C334800F53014 /* ContextMenuPreviewViewController.swift in Sources */, 173A642C2547BE9600267F6E /* AccountType+Helpers.swift in Sources */, + DFE522A32953DEF400376B77 /* CustomInsetGroupedRowStyle.swift in Sources */, 51627A6923861DED007B3B4B /* MasterFeedViewController+Drop.swift in Sources */, 514219372352510100E07E2C /* ImageScrollView.swift in Sources */, 516AE9B32371C372007DEEAA /* MasterFeedTableViewSectionHeaderLayout.swift in Sources */, D3555BF524664566005E48C3 /* ArticleSearchBar.swift in Sources */, + DF28B44D294ED52700C4D8CA /* View+DismissOnExternalContext.swift in Sources */, 8454C3F3263F2D8700E3F9C7 /* IconImageCache.swift in Sources */, + DFD406F5291F79C900C02962 /* SettingsView.swift in Sources */, B24E9ADE245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */, C5A6ED5223C9AF4300AB6BE2 /* TitleActivityItemSource.swift in Sources */, 17071EF126F8137400F5E71D /* ArticleTheme+Notifications.swift in Sources */, 51C4529B22650A1000C03939 /* FaviconDownloader.swift in Sources */, 84DEE56622C32CA4005FC42C /* SmartFeedDelegate.swift in Sources */, + DF28B44F294ED92F00C4D8CA /* NewsBlurAddAccountView.swift in Sources */, + DF84E563295122BA0045C334 /* TimelineCustomizerView.swift in Sources */, 512E09012268907400BDCFDD /* MasterFeedTableViewSectionHeader.swift in Sources */, 512392BF24E33A3C00F11704 /* RedditSelectSortTableViewController.swift in Sources */, 516AE9E02372269A007DEEAA /* IconImage.swift in Sources */, - 519ED456244828C3007F8E94 /* AddExtensionPointViewController.swift in Sources */, 51C45268226508F600C03939 /* MasterFeedUnreadCountView.swift in Sources */, + DFB349A4294E914D00BC81AD /* AccountSectionHeader.swift in Sources */, D3A39865246505DF00F9A366 /* FindInArticleActivity.swift in Sources */, 5183CCD0226E1E880010922C /* NonIntrinsicLabel.swift in Sources */, 5137C2EA26F63AE6009EFEDB /* ArticleThemeImporter.swift in Sources */, 51C4529F22650A1900C03939 /* AuthorAvatarDownloader.swift in Sources */, - 5108F6D22375EED2001ABC45 /* TimelineCustomizerViewController.swift in Sources */, 519E743D22C663F900A78E47 /* SceneDelegate.swift in Sources */, FFD43E412340F488009E5CA3 /* MarkAsReadAlertController.swift in Sources */, 51C452A322650A1E00C03939 /* HTMLMetadataDownloader.swift in Sources */, @@ -4161,12 +4285,9 @@ 51627A6723861DA3007B3B4B /* MasterFeedViewController+Drag.swift in Sources */, 51FFF0C4235EE8E5002762AA /* VibrantButton.swift in Sources */, 51C45259226508D300C03939 /* AppDefaults.swift in Sources */, - 510FFAB326EEA22C00F32265 /* ArticleThemesTableViewController.swift in Sources */, 511D4419231FC02D00FB1562 /* KeyboardManager.swift in Sources */, - 51A1699D235E10D700EB091F /* SettingsViewController.swift in Sources */, 51C45293226509C800C03939 /* StarredFeedDelegate.swift in Sources */, 51D6A5BC23199C85001C27D8 /* MasterTimelineDataSource.swift in Sources */, - 769F2ED513DA03EE75B993A8 /* NewsBlurAccountViewController.swift in Sources */, 51BC4B01247277E0000A6ED8 /* URL-Extensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4267,12 +4388,14 @@ 84AD1EBC2032AF5C00BC20B7 /* SidebarOutlineDataSource.swift in Sources */, 845A29241FC9255E007B49E3 /* SidebarCellAppearance.swift in Sources */, 845EE7B11FC2366500854A1F /* StarredFeedDelegate.swift in Sources */, + DFBB4EAC2951BC0200639228 /* NNWThemeDocument.swift in Sources */, DFC14F1228EA5DC500F6EE86 /* AboutData.swift in Sources */, 848F6AE51FC29CFB002D422E /* FaviconDownloader.swift in Sources */, 511B9806237DCAC90028BCAA /* UserInfoKey.swift in Sources */, 84C9FC7722629E1200D921D6 /* AdvancedPreferencesViewController.swift in Sources */, 849EE72120391F560082A1EA /* SharingServicePickerDelegate.swift in Sources */, 1710B9132552354E00679C0D /* AddAccountHelpView.swift in Sources */, + DFB34996294C4DCB00BC81AD /* LocalizedNetNewsWireError.swift in Sources */, 51D205EF28E3CF8D007C46EF /* LinkTextField.swift in Sources */, 5108F6B62375E612001ABC45 /* CacheCleaner.swift in Sources */, 849A97981ED9EFAA007D329B /* Node-Extensions.swift in Sources */, @@ -4321,6 +4444,7 @@ 51FA73A72332BE880090D516 /* ExtractedArticle.swift in Sources */, 84B99C9D1FAE83C600ECDEDB /* DeleteCommand.swift in Sources */, 849A97541ED9EAC0007D329B /* AddWebFeedWindowController.swift in Sources */, + DF3630EB2936183D00326FB8 /* OPMLDocument.swift in Sources */, 5144EA40227A37EC00D19003 /* ImportOPMLWindowController.swift in Sources */, 178A9F9D2549449F00AB7E9D /* AddAccountsView.swift in Sources */, 51C4CFF024D37D1F00AF9874 /* Secrets.swift in Sources */, diff --git a/Shared/AppNotifications.swift b/Shared/AppNotifications.swift index 84cfd9a72..fb470ad12 100644 --- a/Shared/AppNotifications.swift +++ b/Shared/AppNotifications.swift @@ -12,6 +12,7 @@ import Articles extension Notification.Name { static let InspectableObjectsDidChange = Notification.Name("TimelineSelectionDidChangeNotification") static let UserDidAddFeed = Notification.Name("UserDidAddFeedNotification") + static let LaunchedFromExternalAction = Notification.Name("LaunchedFromExternalAction") #if !MAC_APP_STORE static let WebInspectorEnabledDidChange = Notification.Name("WebInspectorEnabledDidChange") diff --git a/Shared/ArticleStyles/ArticleThemesManager.swift b/Shared/ArticleStyles/ArticleThemesManager.swift index 4c872195f..71d24842d 100644 --- a/Shared/ArticleStyles/ArticleThemesManager.swift +++ b/Shared/ArticleStyles/ArticleThemesManager.swift @@ -13,13 +13,17 @@ import UIKit #endif import RSCore +import Combine +#if canImport(AppKit) +import AppKit +#endif public extension Notification.Name { static let ArticleThemeNamesDidChangeNotification = Notification.Name("ArticleThemeNamesDidChangeNotification") static let CurrentArticleThemeDidChangeNotification = Notification.Name("CurrentArticleThemeDidChangeNotification") } -final class ArticleThemesManager: NSObject, NSFilePresenter, Logging { +final class ArticleThemesManager: NSObject, NSFilePresenter, Logging, ObservableObject { static var shared: ArticleThemesManager! public let folderPath: String @@ -36,6 +40,7 @@ final class ArticleThemesManager: NSObject, NSFilePresenter, Logging { do { currentTheme = try articleThemeWithThemeName(newValue) AppDefaults.shared.currentThemeName = newValue + objectWillChange.send() updateFilePresenter() } catch { logger.error("Unable to set new theme: \(error.localizedDescription, privacy: .public)") @@ -54,12 +59,14 @@ final class ArticleThemesManager: NSObject, NSFilePresenter, Logging { }() { didSet { NotificationCenter.default.post(name: .CurrentArticleThemeDidChangeNotification, object: self) + objectWillChange.send() } } lazy var themeNames = { buildThemeNames() }() { didSet { NotificationCenter.default.post(name: .ArticleThemeNamesDidChangeNotification, object: self) + objectWillChange.send() } } @@ -113,6 +120,7 @@ final class ArticleThemesManager: NSObject, NSFilePresenter, Logging { } try FileManager.default.copyItem(atPath: filename, toPath: toFilename) + objectWillChange.send() themeNames = buildThemeNames() } @@ -136,6 +144,73 @@ final class ArticleThemesManager: NSObject, NSFilePresenter, Logging { return try ArticleTheme(path: path, isAppTheme: isAppTheme) } + + func themesByDeveloper() -> (builtIn: [ArticleTheme], other: [ArticleTheme]) { + let installedProvidedThemes = themeNames.map({ try? articleThemeWithThemeName($0) }).compactMap({ $0 }).filter({ $0.isAppTheme }).sorted(by: { $0.name < $1.name }).filter({ $0.name != AppDefaults.defaultThemeName }) + + let installedOtherThemes = themeNames.map({ try? articleThemeWithThemeName($0) }).compactMap({ $0 }).filter({ !$0.isAppTheme }).sorted(by: { $0.name < $1.name }) + + return (installedProvidedThemes, installedOtherThemes) + } + + #if os(macOS) + func articleThemesMenu(for popUpButton: NSPopUpButton?) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + menu.removeAllItems() + + let defaultMenuItem = NSMenuItem() + defaultMenuItem.title = ArticleTheme.defaultTheme.name + defaultMenuItem.action = #selector(updateThemeSelection(_:)) + defaultMenuItem.state = currentTheme.name == defaultMenuItem.title ? .on : .off + defaultMenuItem.target = self + menu.addItem(defaultMenuItem) + menu.addItem(NSMenuItem.separator()) + + let rancheroHeading = NSMenuItem(title: "Built-in Themes", action: nil, keyEquivalent: "") + rancheroHeading.attributedTitle = NSAttributedString(string: "Built-in Themes", attributes: [NSAttributedString.Key.foregroundColor : NSColor.secondaryLabelColor, NSAttributedString.Key.font: NSFont.boldSystemFont(ofSize: 12)]) + rancheroHeading.isEnabled = false + menu.addItem(rancheroHeading) + + let installedThemes = ArticleThemesManager.shared.themesByDeveloper() + + for theme in installedThemes.0 { + let item = NSMenuItem() + item.title = theme.name + item.action = #selector(updateThemeSelection(_:)) + item.state = currentTheme.name == theme.name ? .on : .off + item.target = self + menu.addItem(item) + } + + menu.addItem(NSMenuItem.separator()) + + let thirdPartyHeading = NSMenuItem(title: "Other Themes", action: nil, keyEquivalent: "") + thirdPartyHeading.attributedTitle = NSAttributedString(string: "Other Themes", attributes: [NSAttributedString.Key.foregroundColor : NSColor.secondaryLabelColor, NSAttributedString.Key.font: NSFont.boldSystemFont(ofSize: 12)]) + thirdPartyHeading.isEnabled = false + menu.addItem(thirdPartyHeading) + + for theme in installedThemes.1 { + let item = NSMenuItem() + item.title = theme.name + item.action = #selector(updateThemeSelection(_:)) + item.state = currentTheme.name == theme.name ? .on : .off + item.target = self + menu.addItem(item) + } + popUpButton?.selectItem(withTitle: ArticleThemesManager.shared.currentThemeName) + if popUpButton?.indexOfSelectedItem == -1 { + popUpButton?.selectItem(withTitle: ArticleTheme.defaultTheme.name) + } + return menu + } + + @objc + func updateThemeSelection(_ menuItem: NSMenuItem) { + currentThemeName = menuItem.title + } + + #endif func deleteTheme(themeName: String) { if let filename = pathForThemeName(themeName, folder: folderPath) { diff --git a/Shared/Extensions/IconImage.swift b/Shared/Extensions/IconImage.swift index e460e4b6b..b8eef9555 100644 --- a/Shared/Extensions/IconImage.swift +++ b/Shared/Extensions/IconImage.swift @@ -162,7 +162,7 @@ extension CGImage { } -enum IconSize: Int, CaseIterable { +enum IconSize: Int, CaseIterable, CustomStringConvertible { case small = 1 case medium = 2 case large = 3 @@ -181,5 +181,16 @@ enum IconSize: Int, CaseIterable { return CGSize(width: IconSize.largeDimension, height: IconSize.largeDimension) } } + + var description: String { + switch self { + case .small: + return Bundle.main.localizedString(forKey: "SMALL_ICON_SIZE", value: nil, table: "Settings") + case .medium: + return Bundle.main.localizedString(forKey: "MEDIUM_ICON_SIZE", value: nil, table: "Settings") + case .large: + return Bundle.main.localizedString(forKey: "LARGE_ICON_SIZE", value: nil, table: "Settings") + } + } } diff --git a/Shared/Importers/NNWThemeDocument.swift b/Shared/Importers/NNWThemeDocument.swift new file mode 100644 index 000000000..c9a711f55 --- /dev/null +++ b/Shared/Importers/NNWThemeDocument.swift @@ -0,0 +1,35 @@ +// +// NNWThemeDocument.swift +// NetNewsWire +// +// Created by Stuart Breckenridge on 20/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import UniformTypeIdentifiers + +public struct NNWThemeDocument: FileDocument { + + public static var readableContentTypes: [UTType] { + UTType.types(tag: "nnwtheme", tagClass: .filenameExtension, conformingTo: nil) + } + + public static var writableContentTypes: [UTType] { + UTType.types(tag: "nnwtheme", tagClass: .filenameExtension, conformingTo: nil) + } + + public init(configuration: ReadConfiguration) throws { + guard let _ = configuration.file.regularFileContents else { + throw CocoaError(.fileReadCorruptFile) + } + return + } + + public func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let wrapper = try FileWrapper(url: URL(string: "")!) + return wrapper + } + +} + diff --git a/Shared/Importers/OPMLDocument.swift b/Shared/Importers/OPMLDocument.swift new file mode 100644 index 000000000..673a6dbde --- /dev/null +++ b/Shared/Importers/OPMLDocument.swift @@ -0,0 +1,42 @@ +// +// OPMLDocument.swift +// NetNewsWire +// +// Created by Stuart Breckenridge on 29/11/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account +import UniformTypeIdentifiers + +public struct OPMLDocument: FileDocument { + + public var account: Account! + + public static var readableContentTypes: [UTType] { + UTType.types(tag: "opml", tagClass: .filenameExtension, conformingTo: nil) + } + + public static var writableContentTypes: [UTType] { + UTType.types(tag: "opml", tagClass: .filenameExtension, conformingTo: nil) + } + + public init(configuration: ReadConfiguration) throws { + + } + + public init(_ account: Account) throws { + self.account = account + } + + @MainActor public func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let accountName = account.nameForDisplay.replacingOccurrences(of: " ", with: "").trimmingCharacters(in: .whitespaces) + let filename = "Subscriptions-\(accountName).opml" + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(filename) + let opmlString = OPMLExporter.OPMLString(with: account, title: filename) + try opmlString.write(to: tempFile, atomically: true, encoding: String.Encoding.utf8) + let wrapper = try FileWrapper(url: tempFile) + return wrapper + } +} diff --git a/Shared/Localizations/LocalizedNetNewsWireError.swift b/Shared/Localizations/LocalizedNetNewsWireError.swift new file mode 100644 index 000000000..e033e6626 --- /dev/null +++ b/Shared/Localizations/LocalizedNetNewsWireError.swift @@ -0,0 +1,46 @@ +// +// LocalizedNetNewsWireError.swift +// NetNewsWire +// +// Created by Stuart Breckenridge on 16/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import Foundation + +public enum LocalizedNetNewsWireError: LocalizedError { + + /// Displayed when the user tries to create a duplicate + /// account with the same username. + case duplicateAccount + + /// Displayed when the user attempts to add a + /// iCloud account but iCloud and/or iCloud Drive + /// are not enabled. + case iCloudDriveMissing + + case userNameAndPasswordRequired + + case invalidUsernameOrPassword + + case keychainError + + case duplicateDefaultTheme + + public var errorDescription: String? { + switch self { + case .duplicateAccount: + return NSLocalizedString("There is already an account of that type with that username created.", comment: "Error message: duplicate account with same username.") + case .iCloudDriveMissing: + return NSLocalizedString("Unable to add iCloud Account. Please make sure you have iCloud and iCloud Drive enabled in System Settings.", comment: "Error message: The user cannot enable the iCloud account becasue iCloud or iCloud Drive isn't enabled in Settings.") + case .userNameAndPasswordRequired: + return NSLocalizedString("Username and password required", comment: "Error message: The user must provide a username and password.") + case .invalidUsernameOrPassword: + return NSLocalizedString("Invalid username or password", comment: "Error message: The user provided an invalid username or password.") + case .keychainError: + return NSLocalizedString("Keychain error while storing credentials.", comment: "Error message: Unable to save due a Keychain error.") + case .duplicateDefaultTheme: + return NSLocalizedString("You cannot import a theme that shares the same name as a provided theme.", comment: "Error message: cannot import theme as this is a duplicate of a provided theme.") + } + } +} diff --git a/Shared/Resources/NewsFax.nnwtheme/Info.plist b/Shared/Resources/NewsFax.nnwtheme/Info.plist index 68e2fb253..39ec987b7 100644 --- a/Shared/Resources/NewsFax.nnwtheme/Info.plist +++ b/Shared/Resources/NewsFax.nnwtheme/Info.plist @@ -7,9 +7,9 @@ ThemeIdentifier com.mynameisstuart.themes.newsfax CreatorHomePage - https://mynameisstuart.com/ + https://stuartbreckenridge.net/ CreatorName - Stuart Breckenridge + Ranchero Software Version 3 diff --git a/Shared/Resources/Promenade.nnwtheme/Info.plist b/Shared/Resources/Promenade.nnwtheme/Info.plist index 24ec5ca47..8baa4f763 100644 --- a/Shared/Resources/Promenade.nnwtheme/Info.plist +++ b/Shared/Resources/Promenade.nnwtheme/Info.plist @@ -7,9 +7,9 @@ ThemeIdentifier com.mynameisstuart.themes.promenade CreatorHomePage - https://mynameisstuart.com/ + https://stuartbreckenridge.net/ CreatorName - Stuart Breckenridge + Ranchero Software Version 14 diff --git a/Shared/Timer/AccountRefreshTimer.swift b/Shared/Timer/AccountRefreshTimer.swift index 33d07c32a..b0e81fb4e 100644 --- a/Shared/Timer/AccountRefreshTimer.swift +++ b/Shared/Timer/AccountRefreshTimer.swift @@ -73,7 +73,7 @@ class AccountRefreshTimer { lastTimedRefresh = Date() update() - AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log) + AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log, completion: nil) } } diff --git a/iOS/Account/Account.storyboard b/iOS/Account/Account.storyboard deleted file mode 100644 index f1fb29edf..000000000 --- a/iOS/Account/Account.storyboard +++ /dev/null @@ -1,810 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOS/Account/CloudKitAccountViewController.swift b/iOS/Account/CloudKitAccountViewController.swift deleted file mode 100644 index ca3ce71e9..000000000 --- a/iOS/Account/CloudKitAccountViewController.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// CloudKitAccountViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 3/28/20. -// Copyright © 2020 Ranchero Software. All rights reserved. -// - -import UIKit -import SafariServices -import Account - -enum CloudKitAccountViewControllerError: LocalizedError { - case iCloudDriveMissing - - var errorDescription: String? { - return NSLocalizedString("Unable to add iCloud Account. Please make sure you have iCloud and iCloud Drive enabled in System Preferences.", comment: "Unable to add iCloud Account.") - } -} - -class CloudKitAccountViewController: UITableViewController { - - weak var delegate: AddAccountDismissDelegate? - @IBOutlet weak var footerLabel: UILabel! - - override func viewDidLoad() { - super.viewDidLoad() - setupFooter() - - tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") - } - - private func setupFooter() { - footerLabel.text = NSLocalizedString("NetNewsWire will use your iCloud account to sync your subscriptions across your Mac and iOS devices.", comment: "iCloud") - } - - @IBAction func cancel(_ sender: Any) { - dismiss(animated: true, completion: nil) - delegate?.dismiss() - } - - @IBAction func add(_ sender: Any) { - guard FileManager.default.ubiquityIdentityToken != nil else { - presentError(CloudKitAccountViewControllerError.iCloudDriveMissing) - return - } - - let _ = AccountManager.shared.createAccount(type: .cloudKit) - dismiss(animated: true, completion: nil) - delegate?.dismiss() - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - if section == 0 { - let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView - headerView.imageView.image = AppAssets.image(for: .cloudKit) - return headerView - } else { - return super.tableView(tableView, viewForHeaderInSection: section) - } - } - - @IBAction func openLimitationsAndSolutions(_ sender: Any) { - let vc = SFSafariViewController(url: CloudKitWebDocumentation.limitationsAndSolutionsURL) - vc.modalPresentationStyle = .pageSheet - present(vc, animated: true) - } - -} diff --git a/iOS/Account/CloudKitAddAccountView.swift b/iOS/Account/CloudKitAddAccountView.swift new file mode 100644 index 000000000..e06cbc591 --- /dev/null +++ b/iOS/Account/CloudKitAddAccountView.swift @@ -0,0 +1,74 @@ +// +// CloudKitAddAccountView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 16/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account + +struct CloudKitAddAccountView: View { + + @Environment(\.dismiss) private var dismiss + @State private var accountError: (Error?, Bool) = (nil, false) + + var body: some View { + NavigationView { + Form { + AccountSectionHeader(accountType: .cloudKit) + Section { createCloudKitAccount } + Section(footer: cloudKitExplainer) {} + } + .navigationTitle(Text(verbatim: "iCloud")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { dismiss() }, label: { Text("Cancel", comment: "Button title") }) + } + } + .alert(Text("Error", comment: "Alert title: Error"), isPresented: $accountError.1) { + Button(action: {}, label: { Text("Dismiss", comment: "Button title") }) + } message: { + Text(accountError.0?.localizedDescription ?? "Unknown Error") + } + .dismissOnExternalContextLaunch() + .dismissOnAccountAdd() + } + } + + var createCloudKitAccount: some View { + Button { + guard FileManager.default.ubiquityIdentityToken != nil else { + accountError = (LocalizedNetNewsWireError.iCloudDriveMissing, true) + return + } + let _ = AccountManager.shared.createAccount(type: .cloudKit) + } label: { + HStack { + Spacer() + Text("Use iCloud", comment: "Button title") + Spacer() + } + } + } + + var cloudKitExplainer: some View { + VStack(spacing: 4) { + if !AccountManager.shared.accounts.contains(where: { $0.type == .cloudKit }) { + // The explainer is only shown when a CloudKit account doesn't exist. + Text("NetNewsWire will use your iCloud account to sync your subscriptions across your Mac and iOS devices.", comment: "iCloud account explanatory text") + } + Text("[iCloud Syncing Limitations & Solutions](https://netnewswire.com/help/iCloud)", comment: "Link which opens webpage describing iCloud syncing limitations.") + }.multilineTextAlignment(.center) + } + + +} + +struct iCloudAccountView_Previews: PreviewProvider { + static var previews: some View { + CloudKitAddAccountView() + } +} diff --git a/iOS/Account/FeedbinAccountViewController.swift b/iOS/Account/FeedbinAccountViewController.swift deleted file mode 100644 index 964ff7e93..000000000 --- a/iOS/Account/FeedbinAccountViewController.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// FeedbinAccountViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 5/19/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import UIKit -import Account -import Secrets -import RSWeb -import SafariServices -import RSCore - -class FeedbinAccountViewController: UITableViewController, Logging { - - @IBOutlet weak var activityIndicator: UIActivityIndicatorView! - @IBOutlet weak var cancelBarButtonItem: UIBarButtonItem! - @IBOutlet weak var emailTextField: UITextField! - @IBOutlet weak var passwordTextField: UITextField! - @IBOutlet weak var showHideButton: UIButton! - @IBOutlet weak var actionButton: UIButton! - @IBOutlet weak var footerLabel: UILabel! - - weak var account: Account? - weak var delegate: AddAccountDismissDelegate? - - override func viewDidLoad() { - super.viewDidLoad() - setupFooter() - - activityIndicator.isHidden = true - emailTextField.delegate = self - passwordTextField.delegate = self - - if let account = account, let credentials = try? account.retrieveCredentials(type: .basic) { - actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal) - actionButton.isEnabled = true - emailTextField.text = credentials.username - passwordTextField.text = credentials.secret - } else { - actionButton.setTitle(NSLocalizedString("Add Account", comment: "Add Account"), for: .normal) - } - - NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: emailTextField) - NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: passwordTextField) - - tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") - - } - - private func setupFooter() { - footerLabel.text = NSLocalizedString("Sign in to your Feedbin account and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have a Feedbin account?", comment: "Feedbin") - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - if section == 0 { - let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView - headerView.imageView.image = AppAssets.image(for: .feedbin) - return headerView - } else { - return super.tableView(tableView, viewForHeaderInSection: section) - } - } - - @IBAction func cancel(_ sender: Any) { - dismiss(animated: true, completion: nil) - } - - - @IBAction func showHidePassword(_ sender: Any) { - if passwordTextField.isSecureTextEntry { - passwordTextField.isSecureTextEntry = false - showHideButton.setTitle("Hide", for: .normal) - } else { - passwordTextField.isSecureTextEntry = true - showHideButton.setTitle("Show", for: .normal) - } - } - - @IBAction func action(_ sender: Any) { - guard let email = emailTextField.text, let password = passwordTextField.text else { - showError(NSLocalizedString("Username & password required.", comment: "Credentials Error")) - return - } - - // When you fill in the email address via auto-complete it adds extra whitespace - let trimmedEmail = email.trimmingCharacters(in: .whitespaces) - - guard account != nil || !AccountManager.shared.duplicateServiceAccount(type: .feedbin, username: trimmedEmail) else { - showError(NSLocalizedString("There is already a Feedbin account with that username created.", comment: "Duplicate Error")) - return - } - - resignFirstResponder() - toggleActivityIndicatorAnimation(visible: true) - setNavigationEnabled(to: false) - - let credentials = Credentials(type: .basic, username: trimmedEmail, secret: password) - Account.validateCredentials(type: .feedbin, credentials: credentials) { result in - self.toggleActivityIndicatorAnimation(visible: false) - self.setNavigationEnabled(to: true) - - switch result { - case .success(let credentials): - if let credentials = credentials { - if self.account == nil { - self.account = AccountManager.shared.createAccount(type: .feedbin) - } - - do { - - do { - try self.account?.removeCredentials(type: .basic) - } catch { - self.logger.error("Error removing credentials: \(error.localizedDescription, privacy: .public).") - } - try self.account?.storeCredentials(credentials) - - self.account?.refreshAll() { result in - switch result { - case .success: - break - case .failure(let error): - self.presentError(error) - } - } - - self.dismiss(animated: true, completion: nil) - self.delegate?.dismiss() - } catch { - self.showError(NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error")) - self.logger.error("Keychain error while storing credentials: \(error.localizedDescription, privacy: .public).") - } - } else { - self.showError(NSLocalizedString("Invalid email/password combination.", comment: "Credentials Error")) - } - case .failure: - self.showError(NSLocalizedString("Network error. Try again later.", comment: "Credentials Error")) - } - - } - } - - @IBAction func signUpWithProvider(_ sender: Any) { - let url = URL(string: "https://feedbin.com/signup")! - let safari = SFSafariViewController(url: url) - safari.modalPresentationStyle = .currentContext - self.present(safari, animated: true, completion: nil) - } - - - @objc func textDidChange(_ note: Notification) { - actionButton.isEnabled = !(emailTextField.text?.isEmpty ?? false) && !(passwordTextField.text?.isEmpty ?? false) - } - - private func showError(_ message: String) { - presentError(title: NSLocalizedString("Error", comment: "Credentials Error"), message: message) - } - - private func setNavigationEnabled(to value:Bool){ - cancelBarButtonItem.isEnabled = value - actionButton.isEnabled = value - } - - private func toggleActivityIndicatorAnimation(visible value: Bool){ - activityIndicator.isHidden = !value - if value { - activityIndicator.startAnimating() - } else { - activityIndicator.stopAnimating() - } - } - -} - -extension FeedbinAccountViewController: UITextFieldDelegate { - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - if textField == emailTextField { - passwordTextField.becomeFirstResponder() - } else { - textField.resignFirstResponder() - action(self) - } - return true - } - -} diff --git a/iOS/Account/FeedbinAddAccountView.swift b/iOS/Account/FeedbinAddAccountView.swift new file mode 100644 index 000000000..51db90293 --- /dev/null +++ b/iOS/Account/FeedbinAddAccountView.swift @@ -0,0 +1,188 @@ +// +// FeedbinAddAccountView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 18/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account +import Secrets +import RSWeb +import SafariServices +import RSCore + + +struct FeedbinAddAccountView: View { + + @Environment(\.dismiss) private var dismiss + @State var account: Account? = nil + @State private var accountEmail: String = "" + @State private var accountPassword: String = "" + @State private var showProgressIndicator: Bool = false + @State private var accountError: (Error?, Bool) = (nil, false) + + var body: some View { + NavigationView { + Form { + AccountSectionHeader(accountType: .feedbin) + accountDetails + accountButton + Section(footer: feedbinAccountExplainer) {} + } + .task { + retrieveCredentials() + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { dismiss() }, label: { Text("Cancel", comment: "Button title") }) + .disabled(showProgressIndicator) + } + ToolbarItem(placement: .navigationBarTrailing) { + if showProgressIndicator { ProgressView() } + } + } + .alert(Text("Error", comment: "Alert title: Error"), isPresented: $accountError.1) { + Button(role: .cancel) { + // + } label: { + Text("Dismiss", comment: "Button title") + } + } message: { + Text(accountError.0?.localizedDescription ?? "Error") + } + .navigationTitle(Text(verbatim: account?.type.localizedAccountName() ?? "Feedbin")) + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(showProgressIndicator) + .dismissOnExternalContextLaunch() + .dismissOnAccountAdd() + } + } + + var accountDetails: some View { + Section { + TextField("Email", text: $accountEmail, prompt: Text("Email Address", comment: "Textfield for the user to enter their account email address.")) + .autocorrectionDisabled() + .autocapitalization(.none) + .textContentType(.username) + SecureField("Password", text: $accountPassword, prompt: Text("Password", comment: "Textfield for the user to enter their account password.")) + .textContentType(.password) + } + } + + var accountButton: some View { + Section { + Button { + Task { + do { + if account == nil { + // Create a new account + try await executeAccountCredentials() + } else { + // Updating account credentials + try await executeAccountCredentials() + dismiss() + } + } catch { + accountError = (error, true) + } + } + } label: { + HStack{ + Spacer() + if account == nil { + Text("Add Account", comment: "Button title") + } else { + Text("Update Credentials", comment: "Button title") + } + Spacer() + } + } + .disabled(!validateCredentials()) + } + } + + var feedbinAccountExplainer: some View { + if account == nil { + return Text("Sign in to your Feedbin account and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have a Feedbin account? [Sign Up Here](https://feedbin.com/signup)", comment: "Explanatory text describing the Feedbin account.") + .multilineTextAlignment(.center) + } + return Text("").multilineTextAlignment(.center) + } + + private func validateCredentials() -> Bool { + if (accountEmail.trimmingWhitespace.count == 0) || (accountPassword.trimmingWhitespace.count == 0) { + return false + } + return true + } + + private func retrieveCredentials() { + if let account = account { + do { + if let creds = try account.retrieveCredentials(type: .basic) { + accountEmail = creds.username + accountPassword = creds.secret + } + } catch { + accountError = (error, true) + } + } + } + + private func executeAccountCredentials() async throws { + let trimmedEmailAddress = accountEmail.trimmingWhitespace + + guard (account != nil || !AccountManager.shared.duplicateServiceAccount(type: .feedbin, username: trimmedEmailAddress)) else { + throw LocalizedNetNewsWireError.duplicateAccount + } + showProgressIndicator = true + + let credentials = Credentials(type: .basic, username: trimmedEmailAddress, secret: accountPassword) + return try await withCheckedThrowingContinuation { continuation in + Account.validateCredentials(type: .feedbin, credentials: credentials) { result in + switch result { + case .success(let credentials): + if let validatedCredentials = credentials { + if self.account == nil { + self.account = AccountManager.shared.createAccount(type: .feedbin) + } + + do { + try? self.account?.removeCredentials(type: .basic) + try self.account?.storeCredentials(validatedCredentials) + self.account?.refreshAll(completion: { result in + switch result { + case .success(_): + showProgressIndicator = false + continuation.resume() + case .failure(let failure): + showProgressIndicator = false + continuation.resume(throwing: failure) + } + }) + } catch { + showProgressIndicator = false + continuation.resume(throwing: LocalizedNetNewsWireError.keychainError) + } + } else { + showProgressIndicator = false + continuation.resume(throwing: LocalizedNetNewsWireError.invalidUsernameOrPassword) + } + case .failure(let failure): + showProgressIndicator = false + continuation.resume(throwing: failure) + } + } + } + } + + +} + +struct FeedbinAddAccountView_Previews: PreviewProvider { + static var previews: some View { + FeedbinAddAccountView() + } +} diff --git a/iOS/Account/LocalAccountViewController.swift b/iOS/Account/LocalAccountViewController.swift deleted file mode 100644 index 31c415d8b..000000000 --- a/iOS/Account/LocalAccountViewController.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// LocalAccountViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 5/19/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import UIKit -import Account - -class LocalAccountViewController: UITableViewController { - - @IBOutlet weak var nameTextField: UITextField! - @IBOutlet weak var footerLabel: UILabel! - - weak var delegate: AddAccountDismissDelegate? - - override func viewDidLoad() { - super.viewDidLoad() - setupFooter() - navigationItem.title = Account.defaultLocalAccountName - nameTextField.delegate = self - tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") - } - - private func setupFooter() { - footerLabel.text = NSLocalizedString("Local accounts do not sync your feeds across devices.", comment: "Local") - } - - @IBAction func cancel(_ sender: Any) { - dismiss(animated: true, completion: nil) - } - - @IBAction func add(_ sender: Any) { - let account = AccountManager.shared.createAccount(type: .onMyMac) - account.name = nameTextField.text - dismiss(animated: true, completion: nil) - delegate?.dismiss() - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - if section == 0 { - let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView - headerView.imageView.image = AppAssets.image(for: .onMyMac) - return headerView - } else { - return super.tableView(tableView, viewForHeaderInSection: section) - } - } - -} - -extension LocalAccountViewController: UITextFieldDelegate { - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return true - } - -} diff --git a/iOS/Account/LocalAddAccountView.swift b/iOS/Account/LocalAddAccountView.swift new file mode 100644 index 000000000..4ca855080 --- /dev/null +++ b/iOS/Account/LocalAddAccountView.swift @@ -0,0 +1,86 @@ +// +// LocalAddAccountView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 18/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account + +struct LocalAddAccountView: View { + + @Environment(\.dismiss) var dismiss + @State private var accountName: String = "" + + var body: some View { + NavigationView { + Form { + AccountSectionHeader(accountType: .onMyMac) + Section { accountNameSection } + Section { addAccountButton } + Section(footer: accountFooterView) {} + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { dismiss() }, label: { Text("Cancel", comment: "Button title") }) + } + } + .navigationTitle(deviceAccountName()) + .navigationBarTitleDisplayMode(.inline) + .dismissOnExternalContextLaunch() + .dismissOnAccountAdd() + } + } + + var accountNameSection: some View { + TextField("Name", + text: $accountName, + prompt: Text("Name", comment: "Textfield placeholder for the name of the account.")) + .autocorrectionDisabled() + .autocapitalization(.none) + } + + var addAccountButton: some View { + Button { + let account = AccountManager.shared.createAccount(type: .onMyMac) + if accountName.trimmingWhitespace.count > 0 { account.name = accountName } + } label: { + HStack { + Spacer() + Text("Add Account", comment: "Button title") + Spacer() + } + } + } + + var accountFooterView: some View { + HStack { + Spacer() + Text("Local accounts do not sync your feeds across devices.", comment: "Explanatory text describing the local account.") + .multilineTextAlignment(.center) + Spacer() + } + } + + private func accountImage() -> UIImage { + if UIDevice.current.userInterfaceIdiom == .pad { + return AppAssets.accountLocalPadImage + } + return AppAssets.accountLocalPhoneImage + } + + private func deviceAccountName() -> Text { + if UIDevice.current.userInterfaceIdiom == .pad { + return Text("On My iPad", comment: "Account name for iPad") + } + return Text("On My iPhone", comment: "Account name for iPhone") + } +} + +struct LocalAddAccountView_Previews: PreviewProvider { + static var previews: some View { + LocalAddAccountView() + } +} diff --git a/iOS/Account/NewsBlurAccountViewController.swift b/iOS/Account/NewsBlurAccountViewController.swift deleted file mode 100644 index 9f46361d0..000000000 --- a/iOS/Account/NewsBlurAccountViewController.swift +++ /dev/null @@ -1,197 +0,0 @@ -// -// NewsBlurAccountViewController.swift -// NetNewsWire -// -// Created by Anh-Quang Do on 3/9/20. -// Copyright (c) 2020 Ranchero Software. All rights reserved. -// - -import UIKit -import Account -import Secrets -import RSWeb -import RSCore -import SafariServices - -class NewsBlurAccountViewController: UITableViewController, Logging { - - @IBOutlet weak var activityIndicator: UIActivityIndicatorView! - @IBOutlet weak var cancelBarButtonItem: UIBarButtonItem! - @IBOutlet weak var usernameTextField: UITextField! - @IBOutlet weak var passwordTextField: UITextField! - @IBOutlet weak var showHideButton: UIButton! - @IBOutlet weak var actionButton: UIButton! - @IBOutlet weak var footerLabel: UILabel! - - weak var account: Account? - weak var delegate: AddAccountDismissDelegate? - - override func viewDidLoad() { - super.viewDidLoad() - setupFooter() - activityIndicator.isHidden = true - usernameTextField.delegate = self - passwordTextField.delegate = self - - if let account = account, let credentials = try? account.retrieveCredentials(type: .newsBlurBasic) { - actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal) - actionButton.isEnabled = true - usernameTextField.text = credentials.username - passwordTextField.text = credentials.secret - } else { - actionButton.setTitle(NSLocalizedString("Add Account", comment: "Add Account"), for: .normal) - } - - NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: usernameTextField) - NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: passwordTextField) - - tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") - } - - private func setupFooter() { - footerLabel.text = NSLocalizedString("Sign in to your NewsBlur account and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have a NewsBlur account?", comment: "NewsBlur") - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - if section == 0 { - let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView - headerView.imageView.image = AppAssets.image(for: .newsBlur) - return headerView - } else { - return super.tableView(tableView, viewForHeaderInSection: section) - } - } - - @IBAction func cancel(_ sender: Any) { - dismiss(animated: true, completion: nil) - } - - @IBAction func showHidePassword(_ sender: Any) { - if passwordTextField.isSecureTextEntry { - passwordTextField.isSecureTextEntry = false - showHideButton.setTitle("Hide", for: .normal) - } else { - passwordTextField.isSecureTextEntry = true - showHideButton.setTitle("Show", for: .normal) - } - } - - @IBAction func action(_ sender: Any) { - - guard let username = usernameTextField.text else { - showError(NSLocalizedString("Username required.", comment: "Credentials Error")) - return - } - - // When you fill in the email address via auto-complete it adds extra whitespace - let trimmedUsername = username.trimmingCharacters(in: .whitespaces) - - guard account != nil || !AccountManager.shared.duplicateServiceAccount(type: .newsBlur, username: trimmedUsername) else { - showError(NSLocalizedString("There is already a NewsBlur account with that username created.", comment: "Duplicate Error")) - return - } - - let password = passwordTextField.text ?? "" - - startAnimatingActivityIndicator() - disableNavigation() - - let basicCredentials = Credentials(type: .newsBlurBasic, username: trimmedUsername, secret: password) - Account.validateCredentials(type: .newsBlur, credentials: basicCredentials) { result in - - self.stopAnimatingActivityIndicator() - self.enableNavigation() - - switch result { - case .success(let sessionCredentials): - if let sessionCredentials = sessionCredentials { - - if self.account == nil { - self.account = AccountManager.shared.createAccount(type: .newsBlur) - } - - do { - - do { - try self.account?.removeCredentials(type: .newsBlurBasic) - try self.account?.removeCredentials(type: .newsBlurSessionId) - } catch { - self.logger.error("Error removing credentials: \(error.localizedDescription, privacy: .public).") - } - try self.account?.storeCredentials(basicCredentials) - try self.account?.storeCredentials(sessionCredentials) - - self.account?.refreshAll() { result in - switch result { - case .success: - break - case .failure(let error): - self.presentError(error) - } - } - - self.dismiss(animated: true, completion: nil) - self.delegate?.dismiss() - } catch { - self.showError(NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error")) - self.logger.error("Keychain error while storing credentials: \(error.localizedDescription, privacy: .public).") - } - } else { - self.showError(NSLocalizedString("Invalid username/password combination.", comment: "Credentials Error")) - } - case .failure(let error): - self.showError(error.localizedDescription) - } - - } - } - - @IBAction func signUpWithProvider(_ sender: Any) { - let url = URL(string: "https://newsblur.com")! - let safari = SFSafariViewController(url: url) - safari.modalPresentationStyle = .currentContext - self.present(safari, animated: true, completion: nil) - } - - @objc func textDidChange(_ note: Notification) { - actionButton.isEnabled = !(usernameTextField.text?.isEmpty ?? false) - } - - private func showError(_ message: String) { - presentError(title: "Error", message: message) - } - - private func enableNavigation() { - self.cancelBarButtonItem.isEnabled = true - self.actionButton.isEnabled = true - } - - private func disableNavigation() { - cancelBarButtonItem.isEnabled = false - actionButton.isEnabled = false - } - - private func startAnimatingActivityIndicator() { - activityIndicator.isHidden = false - activityIndicator.startAnimating() - } - - private func stopAnimatingActivityIndicator() { - self.activityIndicator.isHidden = true - self.activityIndicator.stopAnimating() - } - -} - -extension NewsBlurAccountViewController: UITextFieldDelegate { - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return true - } - -} diff --git a/iOS/Account/NewsBlurAddAccountView.swift b/iOS/Account/NewsBlurAddAccountView.swift new file mode 100644 index 000000000..5a47bad44 --- /dev/null +++ b/iOS/Account/NewsBlurAddAccountView.swift @@ -0,0 +1,196 @@ +// +// NewsBlurAddAccountView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 18/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account +import Secrets +import RSWeb +import RSCore + +struct NewsBlurAddAccountView: View, Logging { + + @Environment(\.dismiss) private var dismiss + @State var account: Account? = nil + @State private var accountUserName: String = "" + @State private var accountPassword: String = "" + @State private var showProgressIndicator: Bool = false + @State private var accountError: (Error?, Bool) = (nil, false) + + var body: some View { + NavigationView { + Form { + AccountSectionHeader(accountType: .newsBlur) + accountDetails + accountButton + Section(footer: newsBlurAccountExplainer) {} + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { dismiss() }, label: { Text("Cancel", comment: "Button title") }) + .disabled(showProgressIndicator) + } + ToolbarItem(placement: .navigationBarTrailing) { + if showProgressIndicator { ProgressView() } + } + } + .navigationTitle(Text(AccountType.newsBlur.localizedAccountName())) + .navigationBarTitleDisplayMode(.inline) + .task { + retreiveCredentials() + } + .alert(Text("Error", comment: "Alert title: Error"), isPresented: $accountError.1) { + Button(role: .cancel) { + // + } label: { + Text("Dismiss", comment: "Button title") + } + } message: { + Text(accountError.0?.localizedDescription ?? "") + } + .interactiveDismissDisabled(showProgressIndicator) + .dismissOnExternalContextLaunch() + .dismissOnAccountAdd() + } + } + + func retreiveCredentials() { + if let account = account { + let credentials = try? account.retrieveCredentials(type: .newsBlurBasic) + if let credentials = credentials { + self.accountUserName = credentials.username + self.accountPassword = credentials.secret + } + } + } + + var accountDetails: some View { + Section { + TextField("Email", text: $accountUserName, prompt: Text("Username or Email", comment: "Textfield for the user to enter their account username or email.")) + .autocorrectionDisabled() + .autocapitalization(.none) + .textContentType(.username) + SecureField("Password", text: $accountPassword, prompt: Text("Password", comment: "Textfield for the user to enter their account password.")) + .textContentType(.password) + } + } + + var accountButton: some View { + Section { + Button { + Task { + do { + if account == nil { + // Create a new account + try await executeAccountCredentials() + } else { + // Updating account credentials + try await executeAccountCredentials() + dismiss() + } + } catch { + accountError = (error, true) + } + } + } label: { + HStack{ + Spacer() + if account == nil { + Text("Add Account", comment: "Button title") + } else { + Text("Update Credentials", comment: "Button title") + } + Spacer() + } + } + .disabled(!validateCredentials()) + } + } + + var newsBlurAccountExplainer: some View { + if account == nil { + return Text("Sign in to your NewsBlur account and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have a NewsBlur account? [Sign Up Here](https://newsblur.com)", comment: "Explanatory text describing the NewsBlur account") + .multilineTextAlignment(.center) + } + return Text("").multilineTextAlignment(.center) + } + + private func validateCredentials() -> Bool { + if (accountUserName.trimmingWhitespace.count == 0) || (accountPassword.trimmingWhitespace.count == 0) { + return false + } + return true + } + + private func executeAccountCredentials() async throws { + let trimmedUsername = accountUserName.trimmingWhitespace + + guard (account != nil || !AccountManager.shared.duplicateServiceAccount(type: .newsBlur, username: trimmedUsername)) else { + throw LocalizedNetNewsWireError.duplicateAccount + } + showProgressIndicator = true + + let basicCredentials = Credentials(type: .newsBlurBasic, username: trimmedUsername, secret: accountPassword) + + return try await withCheckedThrowingContinuation { continuation in + Account.validateCredentials(type: .newsBlur, credentials: basicCredentials) { result in + switch result { + case .success(let credentials): + if let sessionsCredentials = credentials { + + if self.account == nil { + self.account = AccountManager.shared.createAccount(type: .newsBlur) + } + + do { + do { + try self.account?.removeCredentials(type: .newsBlurBasic) + try self.account?.removeCredentials(type: .newsBlurSessionId) + } catch { + NewsBlurAddAccountView.logger.error("\(error.localizedDescription)") + } + + try self.account?.storeCredentials(basicCredentials) + try self.account?.storeCredentials(sessionsCredentials) + + self.account?.refreshAll(completion: { result in + switch result { + case .success(_): + showProgressIndicator = false + continuation.resume() + return + case .failure(let failure): + showProgressIndicator = false + continuation.resume(throwing: failure) + return + } + }) + } catch { + showProgressIndicator = false + continuation.resume(throwing: LocalizedNetNewsWireError.keychainError) + return + } + } else { + showProgressIndicator = false + continuation.resume(throwing: LocalizedNetNewsWireError.invalidUsernameOrPassword) + return + } + case .failure(let failure): + showProgressIndicator = false + continuation.resume(throwing: failure) + return + } + } + } + } +} + +struct NewsBlurAddAccountView_Previews: PreviewProvider { + static var previews: some View { + NewsBlurAddAccountView() + } +} diff --git a/iOS/Account/ReaderAPIAccountViewController.swift b/iOS/Account/ReaderAPIAccountViewController.swift deleted file mode 100644 index 9d98a30aa..000000000 --- a/iOS/Account/ReaderAPIAccountViewController.swift +++ /dev/null @@ -1,322 +0,0 @@ -// -// ReaderAPIAccountViewController.swift -// NetNewsWire-iOS -// -// Created by Stuart Breckenridge on 25/10/20. -// Copyright © 2020 Ranchero Software. All rights reserved. -// - -import UIKit -import Account -import Secrets -import RSWeb -import SafariServices -import RSCore - -class ReaderAPIAccountViewController: UITableViewController, Logging { - - @IBOutlet weak var activityIndicator: UIActivityIndicatorView! - @IBOutlet weak var cancelBarButtonItem: UIBarButtonItem! - @IBOutlet weak var usernameTextField: UITextField! - @IBOutlet weak var passwordTextField: UITextField! - @IBOutlet weak var apiURLTextField: UITextField! - @IBOutlet weak var showHideButton: UIButton! - @IBOutlet weak var actionButton: UIButton! - @IBOutlet weak var footerLabel: UILabel! - @IBOutlet weak var signUpButton: UIButton! - - weak var account: Account? - var accountType: AccountType? - weak var delegate: AddAccountDismissDelegate? - - override func viewDidLoad() { - super.viewDidLoad() - setupFooter() - - activityIndicator.isHidden = true - usernameTextField.delegate = self - passwordTextField.delegate = self - - if let unwrappedAcount = account, - let credentials = try? retrieveCredentialsForAccount(for: unwrappedAcount) { - actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal) - actionButton.isEnabled = true - usernameTextField.text = credentials.username - passwordTextField.text = credentials.secret - } else { - actionButton.setTitle(NSLocalizedString("Add Account", comment: "Add Account"), for: .normal) - } - - if let unwrappedAccountType = accountType { - switch unwrappedAccountType { - case .freshRSS: - title = NSLocalizedString("FreshRSS", comment: "FreshRSS") - apiURLTextField.placeholder = NSLocalizedString("API URL: fresh.rss.net/api/greader.php", comment: "FreshRSS API Helper") - case .inoreader: - title = NSLocalizedString("InoReader", comment: "InoReader") - case .bazQux: - title = NSLocalizedString("BazQux", comment: "BazQux") - case .theOldReader: - title = NSLocalizedString("The Old Reader", comment: "The Old Reader") - default: - title = "" - } - } - - NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: usernameTextField) - NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: passwordTextField) - - tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") - - } - - private func setupFooter() { - switch accountType { - case .bazQux: - footerLabel.text = NSLocalizedString("Sign in to your BazQux account and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have a BazQux account?", comment: "BazQux") - signUpButton.setTitle(NSLocalizedString("Sign Up Here", comment: "BazQux SignUp"), for: .normal) - case .inoreader: - footerLabel.text = NSLocalizedString("Sign in to your InoReader account and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have an InoReader account?", comment: "InoReader") - signUpButton.setTitle(NSLocalizedString("Sign Up Here", comment: "InoReader SignUp"), for: .normal) - case .theOldReader: - footerLabel.text = NSLocalizedString("Sign in to your The Old Reader account and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have a The Old Reader account?", comment: "TOR") - signUpButton.setTitle(NSLocalizedString("Sign Up Here", comment: "TOR SignUp"), for: .normal) - case .freshRSS: - footerLabel.text = NSLocalizedString("Sign in to your FreshRSS instance and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have an FreshRSS instance?", comment: "FreshRSS") - signUpButton.setTitle(NSLocalizedString("Find Out More", comment: "FreshRSS SignUp"), for: .normal) - default: - return - } - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - if section == 0 { - let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView - headerView.imageView.image = headerViewImage() - return headerView - } else { - return super.tableView(tableView, viewForHeaderInSection: section) - } - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch section { - case 0: - switch accountType { - case .freshRSS: - return 3 - default: - return 2 - } - default: - return 1 - } - } - - - @IBAction func cancel(_ sender: Any) { - dismiss(animated: true, completion: nil) - } - - @IBAction func showHidePassword(_ sender: Any) { - if passwordTextField.isSecureTextEntry { - passwordTextField.isSecureTextEntry = false - showHideButton.setTitle("Hide", for: .normal) - } else { - passwordTextField.isSecureTextEntry = true - showHideButton.setTitle("Show", for: .normal) - } - } - - @IBAction func action(_ sender: Any) { - guard validateDataEntry(), let type = accountType else { - return - } - - let username = usernameTextField.text! - let password = passwordTextField.text! - let url = apiURL()! - - // When you fill in the email address via auto-complete it adds extra whitespace - let trimmedUsername = username.trimmingCharacters(in: .whitespaces) - - guard account != nil || !AccountManager.shared.duplicateServiceAccount(type: type, username: trimmedUsername) else { - showError(NSLocalizedString("There is already an account of that type with that username created.", comment: "Duplicate Error")) - return - } - - startAnimatingActivityIndicator() - disableNavigation() - - let credentials = Credentials(type: .readerBasic, username: trimmedUsername, secret: password) - Account.validateCredentials(type: type, credentials: credentials, endpoint: url) { result in - - self.stopAnimatingActivityIndicator() - self.enableNavigation() - - switch result { - case .success(let validatedCredentials): - if let validatedCredentials = validatedCredentials { - - if self.account == nil { - self.account = AccountManager.shared.createAccount(type: type) - } - - do { - self.account?.endpointURL = url - - try? self.account?.removeCredentials(type: .readerBasic) - try? self.account?.removeCredentials(type: .readerAPIKey) - try self.account?.storeCredentials(credentials) - try self.account?.storeCredentials(validatedCredentials) - - self.dismiss(animated: true, completion: nil) - - self.account?.refreshAll() { result in - switch result { - case .success: - break - case .failure(let error): - self.showError(NSLocalizedString(error.localizedDescription, comment: "Account Refresh Error")) - } - } - - self.delegate?.dismiss() - } catch { - self.showError(NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error")) - self.logger.error("Keychain error while storing credentials: \(error.localizedDescription, privacy: .public).") - } - } else { - self.showError(NSLocalizedString("Invalid username/password combination.", comment: "Credentials Error")) - } - case .failure(let error): - self.showError(error.localizedDescription) - } - - } - } - - private func retrieveCredentialsForAccount(for account: Account) throws -> Credentials? { - switch accountType { - case .bazQux, .inoreader, .theOldReader, .freshRSS: - return try account.retrieveCredentials(type: .readerBasic) - default: - return nil - } - } - - private func headerViewImage() -> UIImage? { - if let accountType = accountType { - switch accountType { - case .bazQux: - return AppAssets.accountBazQuxImage - case .inoreader: - return AppAssets.accountInoreaderImage - case .theOldReader: - return AppAssets.accountTheOldReaderImage - case .freshRSS: - return AppAssets.accountFreshRSSImage - default: - return nil - } - } - return nil - } - - private func validateDataEntry() -> Bool { - switch accountType { - case .freshRSS: - if !usernameTextField.hasText || !passwordTextField.hasText || !apiURLTextField.hasText { - showError(NSLocalizedString("Username, password, and API URL are required.", comment: "Credentials Error")) - return false - } - guard let _ = URL(string: apiURLTextField.text!) else { - showError(NSLocalizedString("Invalid API URL.", comment: "Invalid API URL")) - return false - } - default: - if !usernameTextField.hasText || !passwordTextField.hasText { - showError(NSLocalizedString("Username and password are required.", comment: "Credentials Error")) - return false - } - } - return true - } - - @IBAction func signUpWithProvider(_ sender: Any) { - var url: URL! - switch accountType { - case .bazQux: - url = URL(string: "https://bazqux.com")! - case .inoreader: - url = URL(string: "https://www.inoreader.com")! - case .theOldReader: - url = URL(string: "https://theoldreader.com")! - case .freshRSS: - url = URL(string: "https://freshrss.org")! - default: - return - } - let safari = SFSafariViewController(url: url) - safari.modalPresentationStyle = .currentContext - self.present(safari, animated: true, completion: nil) - } - - private func apiURL() -> URL? { - switch accountType { - case .freshRSS: - return URL(string: apiURLTextField.text!)! - case .inoreader: - return URL(string: ReaderAPIVariant.inoreader.host)! - case .bazQux: - return URL(string: ReaderAPIVariant.bazQux.host)! - case .theOldReader: - return URL(string: ReaderAPIVariant.theOldReader.host)! - default: - return nil - } - } - - - - @objc func textDidChange(_ note: Notification) { - actionButton.isEnabled = !(usernameTextField.text?.isEmpty ?? false) - } - - private func showError(_ message: String) { - presentError(title: "Error", message: message) - } - - private func enableNavigation() { - self.cancelBarButtonItem.isEnabled = true - self.actionButton.isEnabled = true - } - - private func disableNavigation() { - cancelBarButtonItem.isEnabled = false - actionButton.isEnabled = false - } - - private func startAnimatingActivityIndicator() { - activityIndicator.isHidden = false - activityIndicator.startAnimating() - } - - private func stopAnimatingActivityIndicator() { - self.activityIndicator.isHidden = true - self.activityIndicator.stopAnimating() - } -} - -extension ReaderAPIAccountViewController: UITextFieldDelegate { - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return true - } - -} diff --git a/iOS/Account/ReaderAPIAddAccountView.swift b/iOS/Account/ReaderAPIAddAccountView.swift new file mode 100644 index 000000000..c0842d14f --- /dev/null +++ b/iOS/Account/ReaderAPIAddAccountView.swift @@ -0,0 +1,240 @@ +// +// ReaderAPIAccountView.swift +// NetNewsWire +// +// Created by Stuart Breckenridge on 16/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account +import Secrets +import RSWeb +import SafariServices +import RSCore + +struct ReaderAPIAddAccountView: View { + + @Environment(\.dismiss) var dismiss + + var accountType: AccountType? + @State var account: Account? + @State private var accountCredentials: Credentials? + @State private var accountUserName: String = "" + @State private var accountSecret: String = "" + @State private var accountAPIUrl: String = "" + @State private var showProgressIndicator: Bool = false + @State private var accountError: (Error?, Bool) = (nil, false) + + var body: some View { + NavigationView { + Form { + if accountType != nil { + AccountSectionHeader(accountType: accountType!) + } + accountDetails + accountButton + Section(footer: readerAccountExplainer) {} + } + .navigationTitle(Text(accountType?.localizedAccountName() ?? "")) + .navigationBarTitleDisplayMode(.inline) + .task { + retrieveAccountCredentials() + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { dismiss() }, label: { Text("Cancel", comment: "Button title") }) + .disabled(showProgressIndicator) + } + ToolbarItem(placement: .navigationBarTrailing) { + if showProgressIndicator { ProgressView() } + } + } + .alert(Text("Error", comment: "Alert title: Error"), isPresented: $accountError.1) { + Button(role: .cancel) { + // + } label: { + Text("Dismiss", comment: "Button title") + } + } message: { + Text(accountError.0?.localizedDescription ?? "") + } + .interactiveDismissDisabled(showProgressIndicator) + .dismissOnExternalContextLaunch() + .dismissOnAccountAdd() + } + } + + var readerAccountExplainer: some View { + if accountType == nil { return Text("").multilineTextAlignment(.center) } + switch accountType! { + case .bazQux: + return Text("Sign in to your BazQux account and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have a BazQux account? [Sign Up Here](https://bazqux.com)", comment: "Explanatory text describing the BazQux account").multilineTextAlignment(.center) + case .inoreader: + return Text("Sign in to your InoReader account and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have an InoReader account? [Sign Up Here](https://www.inoreader.com)", comment: "Explanatory text describing the Inoreader account").multilineTextAlignment(.center) + case .theOldReader: + return Text("Sign in to your The Old Reader account and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have a The Old Reader account? [Sign Up Here](https://theoldreader.com)", comment: "Explanatory text describing The Old Reader account").multilineTextAlignment(.center) + case .freshRSS: + return Text("Sign in to your FreshRSS instance and sync your feeds across your devices. Your username and password will be encrypted and stored in Keychain.\n\nDon’t have an FreshRSS instance? [Sign Up Here](https://freshrss.org)", comment: "Explanatory text describing the FreshRSS account").multilineTextAlignment(.center) + default: + return Text("").multilineTextAlignment(.center) + } + } + + + + var accountDetails: some View { + Section { + TextField("Username", text: $accountUserName) + .autocorrectionDisabled() + .autocapitalization(.none) + .textContentType(.username) + SecureField("Password", text: $accountSecret) + .textContentType(.password) + if accountType == .freshRSS && accountCredentials == nil { + TextField("FreshRSS URL", text: $accountAPIUrl, prompt: Text("fresh.rss.net/api/greader.php")) + .autocorrectionDisabled() + .autocapitalization(.none) + } + } + } + + var accountButton: some View { + Section { + Button { + Task { + do { + if account == nil { + // Create a new account + try await executeAccountCredentials() + } else { + // Updating account credentials + try await executeAccountCredentials() + dismiss() + } + } catch { + accountError = (error, true) + } + } + } label: { + HStack { + Spacer() + if accountCredentials == nil { + Text("Add Account", comment: "Button title") + } else { + Text("Update Credentials", comment: "Button title") + } + Spacer() + } + } + .disabled(!validateCredentials()) + } + } + + // MARK: - API + + private func retrieveAccountCredentials() { + if let account = account { + do { + if let creds = try account.retrieveCredentials(type: .readerBasic) { + self.accountCredentials = creds + accountUserName = creds.username + accountSecret = creds.secret + } + } catch { + accountError = (error, true) + } + } + } + + private func validateCredentials() -> Bool { + if accountType == nil { return false } + switch accountType! { + case .freshRSS: + if (accountUserName.trimmingWhitespace.count == 0) || (accountSecret.trimmingWhitespace.count == 0) || (accountAPIUrl.trimmingWhitespace.count == 0) { + return false + } + default: + if (accountUserName.trimmingWhitespace.count == 0) || (accountSecret.trimmingWhitespace.count == 0) { + return false + } + } + return true + } + + private func executeAccountCredentials() async throws { + + let trimmedAccountUserName = accountUserName.trimmingWhitespace + + guard (account != nil || !AccountManager.shared.duplicateServiceAccount(type: accountType!, username: trimmedAccountUserName)) else { + throw LocalizedNetNewsWireError.duplicateAccount + } + + showProgressIndicator = true + let credentials = Credentials(type: .readerBasic, username: trimmedAccountUserName, secret: accountSecret) + + return try await withCheckedThrowingContinuation { continuation in + Account.validateCredentials(type: accountType!, credentials: credentials, endpoint: apiURL()) { result in + switch result { + case .success(let validatedCredentials): + if let validatedCredentials = validatedCredentials { + if self.account == nil { + self.account = AccountManager.shared.createAccount(type: accountType!) + } + + do { + self.account?.endpointURL = apiURL() + try? self.account?.removeCredentials(type: .readerBasic) + try? self.account?.removeCredentials(type: .readerAPIKey) + try self.account?.storeCredentials(credentials) + try self.account?.storeCredentials(validatedCredentials) + + self.account?.refreshAll(completion: { result in + switch result { + case .success: + showProgressIndicator = false + continuation.resume() + return + case .failure(let error): + showProgressIndicator = false + continuation.resume(throwing: error) + return + } + }) + } catch { + showProgressIndicator = false + continuation.resume(throwing: LocalizedNetNewsWireError.keychainError) + return + } + } + case .failure(let failure): + showProgressIndicator = false + continuation.resume(throwing: failure) + return + } + } + } + } + + private func apiURL() -> URL? { + switch accountType! { + case .freshRSS: + return URL(string: accountAPIUrl)! + case .inoreader: + return URL(string: ReaderAPIVariant.inoreader.host)! + case .bazQux: + return URL(string: ReaderAPIVariant.bazQux.host)! + case .theOldReader: + return URL(string: ReaderAPIVariant.theOldReader.host)! + default: + return nil + } + } + +} + +struct ReaderAPIAccountView_Previews: PreviewProvider { + static var previews: some View { + ReaderAPIAddAccountView() + } +} diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift index 604500269..418d16877 100644 --- a/iOS/AppDefaults.swift +++ b/iOS/AppDefaults.swift @@ -7,6 +7,8 @@ // import UIKit +import Combine +import SwiftUI enum UserInterfaceColorPalette: Int, CustomStringConvertible, CaseIterable { case automatic = 0 @@ -26,7 +28,7 @@ enum UserInterfaceColorPalette: Int, CustomStringConvertible, CaseIterable { } -final class AppDefaults { +final class AppDefaults: ObservableObject { static let defaultThemeName = "NetNewsWire" @@ -86,6 +88,7 @@ final class AppDefaults { } set { setInt(for: Key.userInterfaceColorPalette, newValue.rawValue) + AppDefaults.shared.objectWillChange.send() } } @@ -95,6 +98,7 @@ final class AppDefaults { } set { AppDefaults.setString(for: Key.addWebFeedAccountID, newValue) + AppDefaults.shared.objectWillChange.send() } } @@ -104,6 +108,7 @@ final class AppDefaults { } set { AppDefaults.setString(for: Key.addWebFeedFolderName, newValue) + AppDefaults.shared.objectWillChange.send() } } @@ -113,6 +118,7 @@ final class AppDefaults { } set { AppDefaults.setString(for: Key.addFolderAccountID, newValue) + AppDefaults.shared.objectWillChange.send() } } @@ -122,6 +128,7 @@ final class AppDefaults { } set { UserDefaults.standard.set(newValue, forKey: Key.activeExtensionPointIDs) + AppDefaults.shared.objectWillChange.send() } } @@ -131,6 +138,7 @@ final class AppDefaults { } set { UserDefaults.standard.set(newValue, forKey: Key.hasUsedFullScreenPreviously) + AppDefaults.shared.objectWillChange.send() } } @@ -140,6 +148,7 @@ final class AppDefaults { } set { UserDefaults.standard.setValue(newValue, forKey: Key.useSystemBrowser) + AppDefaults.shared.objectWillChange.send() } } @@ -149,6 +158,7 @@ final class AppDefaults { } set { AppDefaults.setDate(for: Key.lastImageCacheFlushDate, newValue) + AppDefaults.shared.objectWillChange.send() } } @@ -158,6 +168,7 @@ final class AppDefaults { } set { AppDefaults.setBool(for: Key.timelineGroupByFeed, newValue) + AppDefaults.shared.objectWillChange.send() } } @@ -167,6 +178,7 @@ final class AppDefaults { } set { AppDefaults.setBool(for: Key.refreshClearsReadArticles, newValue) + AppDefaults.shared.objectWillChange.send() } } @@ -176,15 +188,33 @@ final class AppDefaults { } set { AppDefaults.setSortDirection(for: Key.timelineSortDirection, newValue) + AppDefaults.shared.objectWillChange.send() } } - + + /// This is a `Bool` wrapper for `timelineSortDirection`'s + /// `ComparisonResult` + var timelineSortDirectionBool: Bool { + get { + if AppDefaults.shared.timelineSortDirection == .orderedAscending { + return true + } + return false + } + set { + if newValue == true { timelineSortDirection = .orderedAscending } else { + timelineSortDirection = .orderedDescending + } + } + } + var articleFullscreenEnabled: Bool { get { return AppDefaults.bool(for: Key.articleFullscreenEnabled) } set { AppDefaults.setBool(for: Key.articleFullscreenEnabled, newValue) + AppDefaults.shared.objectWillChange.send() } } @@ -203,6 +233,7 @@ final class AppDefaults { } set { AppDefaults.setInt(for: Key.timelineNumberOfLines, newValue) + objectWillChange.send() } } @@ -213,6 +244,7 @@ final class AppDefaults { } set { AppDefaults.store.set(newValue.rawValue, forKey: Key.timelineIconDimension) + objectWillChange.send() } } @@ -222,6 +254,7 @@ final class AppDefaults { } set { AppDefaults.setString(for: Key.currentThemeName, newValue) + AppDefaults.shared.objectWillChange.send() } } @@ -240,6 +273,7 @@ final class AppDefaults { } set { AppDefaults.setBool(for: Key.markArticlesAsReadOnScroll, newValue) + AppDefaults.shared.objectWillChange.send() } } diff --git a/iOS/Inspector/AccountInspectorView.swift b/iOS/Inspector/AccountInspectorView.swift new file mode 100644 index 000000000..feeb2a008 --- /dev/null +++ b/iOS/Inspector/AccountInspectorView.swift @@ -0,0 +1,145 @@ +// +// AccountInspectorView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 15/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import SafariServices +import Account + +struct AccountInspectorView: View { + + @Environment(\.dismiss) var dismiss + @State private var showRemoveAccountAlert: Bool = false + @State private var showAccountCredentialsSheet: Bool = false + var account: Account + + + var body: some View { + Form { + AccountSectionHeader(accountType: account.type) + accountNameAndActiveSection + + if account.type != .onMyMac && + account.type != .cloudKit && + account.type != .feedly { + credentialsSection + } + + if account != AccountManager.shared.defaultAccount { + removeAccountSection + } + + if account.type == .cloudKit { + Section(footer: cloudKitLimitations){} + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(account.nameForDisplay) + .tint(Color(uiColor: AppAssets.primaryAccentColor)) + .edgesIgnoringSafeArea(.bottom) + .sheet(isPresented: $showAccountCredentialsSheet) { + switch account.type { + case .theOldReader, .bazQux, .inoreader, .freshRSS: + ReaderAPIAddAccountView(accountType: account.type, account: account) + case .feedbin: + FeedbinAddAccountView(account: account) + case .newsBlur: + NewsBlurAddAccountView(account: account) + default: + EmptyView() + } + } + .dismissOnExternalContextLaunch() + } + + var accountHeaderView: some View { + HStack { + Spacer() + Image(uiImage: account.smallIcon!.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 48, height: 48) + Spacer() + } + } + + var accountNameAndActiveSection: some View { + Section { + TextField(text: Binding( + get: { account.name ?? account.defaultName }, + set: { account.name = $0 }), + prompt: Text(account.defaultName)) { + Text("Name", comment: "Textfield for the user to enter account name.") + } + + Toggle(isOn: Binding(get: { + account.isActive + }, set: { account.isActive = $0 })) { + Text("Active", comment: "Toggle denoting if the account is active.") + } + } + } + + var credentialsSection: some View { + Section { + Button { + showAccountCredentialsSheet = true + } label: { + HStack { + Spacer() + Text("Credentials", comment: "Button title") + Spacer() + } + } + } + } + + var removeAccountSection: some View { + Section { + Button(role: .destructive) { + showRemoveAccountAlert = true + } label: { + HStack { + Spacer() + Text("Remove Account", comment: "Button title") + Spacer() + } + } + .alert(Text("Are you sure you want to remove “\(account.nameForDisplay)”?", comment: "Alert title: confirm account removal"), isPresented: $showRemoveAccountAlert) { + Button(role: .destructive) { + AccountManager.shared.deleteAccount(account) + dismiss() + } label: { + Text("Remove Account", comment: "Button title") + } + + Button(role: .cancel) { + // + } label: { + Text("Cancel", comment: "Button title") + } + + } message: { + Text("This action cannot be undone.", comment: "Alert message: remove account confirmation") + } + } + } + + var cloudKitLimitations: some View { + HStack { + Spacer() + Text("[iCloud Syncing Limitations & Solutions](https://netnewswire.com/help/iCloud)", comment: "Link to the NetNewsWire iCloud syncing limitations and soltutions website.") + Spacer() + } + } +} + +struct AccountInspectorView_Previews: PreviewProvider { + static var previews: some View { + AccountInspectorView(account: AccountManager.shared.defaultAccount) + } +} diff --git a/iOS/Inspector/AccountInspectorViewController.swift b/iOS/Inspector/AccountInspectorViewController.swift deleted file mode 100644 index be8bad6b6..000000000 --- a/iOS/Inspector/AccountInspectorViewController.swift +++ /dev/null @@ -1,211 +0,0 @@ -// -// AccountInspectorViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 5/17/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import UIKit -import SafariServices -import Account - -class AccountInspectorViewController: UITableViewController { - - static let preferredContentSizeForFormSheetDisplay = CGSize(width: 460.0, height: 400.0) - - @IBOutlet weak var nameTextField: UITextField! - @IBOutlet weak var activeSwitch: UISwitch! - @IBOutlet weak var deleteAccountButton: VibrantButton! - @IBOutlet weak var limitationsAndSolutionsView: UIView! - - var isModal = false - weak var account: Account? - - override func viewDidLoad() { - super.viewDidLoad() - - guard let account = account else { return } - - nameTextField.placeholder = account.defaultName - nameTextField.text = account.name - nameTextField.delegate = self - activeSwitch.isOn = account.isActive - - navigationItem.title = account.nameForDisplay - - if account.type != .onMyMac { - deleteAccountButton.setTitle(NSLocalizedString("Remove Account", comment: "Remove Account"), for: .normal) - } - - if account.type != .cloudKit { - limitationsAndSolutionsView.isHidden = true - } - - if isModal { - let doneBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done)) - navigationItem.leftBarButtonItem = doneBarButtonItem - } - - tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") - - } - - override func viewWillDisappear(_ animated: Bool) { - account?.name = nameTextField.text - account?.isActive = activeSwitch.isOn - } - - @objc func done() { - dismiss(animated: true) - } - - @IBAction func credentials(_ sender: Any) { - guard let account = account else { return } - switch account.type { - case .feedbin: - let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "FeedbinAccountNavigationViewController") as! UINavigationController - let addViewController = navController.topViewController as! FeedbinAccountViewController - addViewController.account = account - navController.modalPresentationStyle = .currentContext - present(navController, animated: true) - case .newsBlur: - let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "NewsBlurAccountNavigationViewController") as! UINavigationController - let addViewController = navController.topViewController as! NewsBlurAccountViewController - addViewController.account = account - navController.modalPresentationStyle = .currentContext - present(navController, animated: true) - case .inoreader, .bazQux, .theOldReader, .freshRSS: - let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "ReaderAPIAccountNavigationViewController") as! UINavigationController - let addViewController = navController.topViewController as! ReaderAPIAccountViewController - addViewController.accountType = account.type - addViewController.account = account - navController.modalPresentationStyle = .currentContext - present(navController, animated: true) - default: - break - } - } - - @IBAction func deleteAccount(_ sender: Any) { - guard let account = account else { - return - } - - let title = NSLocalizedString("Remove Account", comment: "Remove Account") - let message: String = { - switch account.type { - case .feedly: - return NSLocalizedString("Are you sure you want to remove this account? NetNewsWire will no longer be able to access articles and feeds unless the account is added again.", comment: "Log Out and Remove Account") - default: - return NSLocalizedString("Are you sure you want to remove this account? This cannot be undone.", comment: "Remove Account") - } - }() - let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel") - let cancelAction = UIAlertAction(title: cancelTitle, style: .cancel) - alertController.addAction(cancelAction) - - let markTitle = NSLocalizedString("Remove", comment: "Remove") - let markAction = UIAlertAction(title: markTitle, style: .default) { [weak self] (action) in - guard let self = self, let account = self.account else { return } - AccountManager.shared.deleteAccount(account) - if self.isModal { - self.dismiss(animated: true) - } else { - self.navigationController?.popViewController(animated: true) - } - } - alertController.addAction(markAction) - alertController.preferredAction = markAction - - present(alertController, animated: true) - } - - @IBAction func openLimitationsAndSolutions(_ sender: Any) { - let vc = SFSafariViewController(url: CloudKitWebDocumentation.limitationsAndSolutionsURL) - vc.modalPresentationStyle = .pageSheet - present(vc, animated: true) - } - -} - -// MARK: Table View - -extension AccountInspectorViewController { - - var hidesCredentialsSection: Bool { - guard let account = account else { - return true - } - switch account.type { - case .onMyMac, .cloudKit, .feedly: - return true - default: - return false - } - } - - override func numberOfSections(in tableView: UITableView) -> Int { - guard let account = account else { return 0 } - - if account == AccountManager.shared.defaultAccount { - return 1 - } else if hidesCredentialsSection { - return 2 - } else { - return super.numberOfSections(in: tableView) - } - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let account = account else { return nil } - - if section == 0 { - let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView - headerView.imageView.image = AppAssets.image(for: account.type) - return headerView - } else { - return super.tableView(tableView, viewForHeaderInSection: section) - } - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: UITableViewCell - - if indexPath.section == 1, hidesCredentialsSection { - cell = super.tableView(tableView, cellForRowAt: IndexPath(row: 0, section: 2)) - } else { - cell = super.tableView(tableView, cellForRowAt: indexPath) - } - - return cell - } - - override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - if indexPath.section > 0 { - return true - } - return false - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - } - -} - -// MARK: UITextFieldDelegate - -extension AccountInspectorViewController: UITextFieldDelegate { - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return true - } - -} diff --git a/iOS/Inspector/ExtensionInspectorView.swift b/iOS/Inspector/ExtensionInspectorView.swift new file mode 100644 index 000000000..b489bf8ad --- /dev/null +++ b/iOS/Inspector/ExtensionInspectorView.swift @@ -0,0 +1,79 @@ +// +// ExtensionInspectorView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 15/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI + +struct ExtensionInspectorView: View { + + @Environment(\.dismiss) var dismiss + @State private var showDeactivateConfirmation: Bool = false + var extensionPoint: ExtensionPoint? + + var body: some View { + Form { + Section(header: extensionHeader) {} + Section(footer: extensionExplainer, content: { + // + }) + + HStack { + Spacer() + Button(role: .destructive) { + showDeactivateConfirmation = true + } label: { + Text("Deactivate Extension", comment: "Button title") + } + .alert(Text("Are you sure you want to deactivate “\(extensionPoint?.title ?? "")?", comment: "Alert title: confirm deactivate extension") , isPresented: $showDeactivateConfirmation) { + + Button(role: .destructive) { + ExtensionPointManager.shared.deactivateExtensionPoint(extensionPoint!.extensionPointID) + dismiss() + } label: { + Text("Deactivate Extension", comment: "Button title") + } + + Button(role: .cancel) { + // + } label: { + Text("Cancel", comment: "Button title") + } + } message: { + Text("This action cannot be undone.", comment: "Alert message: remove account confirmation") + } + Spacer() + } + + + } + .navigationTitle(Text(extensionPoint?.title ?? "")) + .edgesIgnoringSafeArea(.bottom) + .dismissOnExternalContextLaunch() + } + + var extensionHeader: some View { + HStack { + Spacer() + Image(uiImage: extensionPoint!.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 48, height: 48) + Spacer() + } + } + + var extensionExplainer: some View { + Text(extensionPoint?.description.string ?? "") + .multilineTextAlignment(.center) + } +} + +struct ExtensionInspectorView_Previews: PreviewProvider { + static var previews: some View { + ExtensionInspectorView() + } +} diff --git a/iOS/Inspector/ExtensionInspectorViewController.swift b/iOS/Inspector/ExtensionInspectorViewController.swift deleted file mode 100644 index 79c2cc43c..000000000 --- a/iOS/Inspector/ExtensionInspectorViewController.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// ExtensionPointInspectorViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 4/16/20. -// Copyright © 2020 Ranchero Software. All rights reserved. -// - -import UIKit - -class ExtensionPointInspectorViewController: UITableViewController { - - @IBOutlet weak var extensionDescription: UILabel! - var extensionPoint: ExtensionPoint? - - override func viewDidLoad() { - super.viewDidLoad() - guard let extensionPoint = extensionPoint else { return } - navigationItem.title = extensionPoint.title - extensionDescription.attributedText = extensionPoint.description - tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") - } - - @IBAction func disable(_ sender: Any) { - guard let extensionPoint = extensionPoint else { return } - - let title = NSLocalizedString("Deactivate Extension", comment: "Deactivate Extension") - let extensionPointTypeTitle = extensionPoint.extensionPointID.extensionPointType.title - let message = NSLocalizedString("Are you sure you want to deactivate the \(extensionPointTypeTitle) extension “\(extensionPoint.title)”?", comment: "Deactivate text") - - let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel") - let cancelAction = UIAlertAction(title: cancelTitle, style: .cancel) - alertController.addAction(cancelAction) - - let markTitle = NSLocalizedString("Deactivate", comment: "Deactivate") - let markAction = UIAlertAction(title: markTitle, style: .default) { [weak self] (action) in - ExtensionPointManager.shared.deactivateExtensionPoint(extensionPoint.extensionPointID) - self?.navigationController?.popViewController(animated: true) - } - alertController.addAction(markAction) - alertController.preferredAction = markAction - - present(alertController, animated: true) - - } -} - -// MARK: Table View - -extension ExtensionPointInspectorViewController { - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let extensionPoint = extensionPoint else { return nil } - - if section == 0 { - let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView - headerView.imageView.image = extensionPoint.image - return headerView - } else { - return super.tableView(tableView, viewForHeaderInSection: section) - } - } - - override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - if indexPath.section > 0 { - return true - } - return false - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - } - -} - diff --git a/iOS/Inspector/Inspector.storyboard b/iOS/Inspector/Inspector.storyboard deleted file mode 100644 index b9c566da5..000000000 --- a/iOS/Inspector/Inspector.storyboard +++ /dev/null @@ -1,516 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOS/Inspector/InspectorIconHeaderView.swift b/iOS/Inspector/InspectorIconHeaderView.swift deleted file mode 100644 index e0e45f559..000000000 --- a/iOS/Inspector/InspectorIconHeaderView.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// InspectorIconHeaderView.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 11/6/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import UIKit - -class InspectorIconHeaderView: UITableViewHeaderFooterView { - - var iconView = IconView() - - override init(reuseIdentifier: String?) { - super.init(reuseIdentifier: reuseIdentifier) - commonInit() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - commonInit() - } - - func commonInit() { - addSubview(iconView) - } - - override func layoutSubviews() { - let x = (bounds.width - 48.0) / 2 - let y = (bounds.height - 48.0) / 2 - iconView.frame = CGRect(x: x, y: y, width: 48.0, height: 48.0) - } -} diff --git a/iOS/Inspector/WebFeedInspectorView.swift b/iOS/Inspector/WebFeedInspectorView.swift new file mode 100644 index 000000000..ef7a24653 --- /dev/null +++ b/iOS/Inspector/WebFeedInspectorView.swift @@ -0,0 +1,87 @@ +// +// WebFeedInspectorView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 15/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account +import SafariServices +import UserNotifications + +struct WebFeedInspectorView: View { + + var webFeed: WebFeed! + @State private var showHomePage: Bool = false + + var body: some View { + Form { + + Section(header: webFeedHeaderView) {} + + Section { + TextField(webFeed.nameForDisplay, + text: Binding( + get: { webFeed.name ?? webFeed.nameForDisplay }, + set: { webFeed.name = $0 }), + prompt: nil) + + Toggle(isOn: Binding(get: { webFeed.isNotifyAboutNewArticles ?? false }, set: { webFeed.isNotifyAboutNewArticles = $0 })) { + Text("Notify About New Articles", comment: "Toggle denoting whether the user has enabled new article notifications for this feed.") + } + + if webFeed.isFeedProvider == false { + Toggle(isOn: Binding( + get: { webFeed.isArticleExtractorAlwaysOn ?? false }, + set: { webFeed.isArticleExtractorAlwaysOn = $0 })) { + Text("Always Show Reader View", comment: "Toggle denoting whether the user has enabled Reader view for this feed.") + } + } + } + + Section(header: Text("Home Page", comment: "Home Page section header in the Feed inspector.")) { + HStack { + Text(webFeed.homePageURL?.decodedURLString ?? "") + Spacer() + Image(uiImage: AppAssets.safariImage) + .renderingMode(.template) + .foregroundColor(Color(uiColor: AppAssets.primaryAccentColor)) + } + .onTapGesture { + if webFeed.homePageURL != nil { showHomePage = true } + } + } + + Section(header: Text("Feed URL", comment: "Feed URL section header in the Feed inspector.")) { + Text(webFeed.url.description) + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(webFeed.nameForDisplay) + .sheet(isPresented: $showHomePage, onDismiss: nil) { + SafariView(url: URL(string: webFeed.homePageURL!)!) + } + .tint(Color(uiColor: AppAssets.primaryAccentColor)) + .dismissOnExternalContextLaunch() + } + + var webFeedHeaderView: some View { + HStack { + Spacer() + Image(uiImage: webFeed.smallIcon!.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 48, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 4)) + Spacer() + } + } +} + +struct WebFeedInspectorView_Previews: PreviewProvider { + static var previews: some View { + WebFeedInspectorView() + } +} diff --git a/iOS/Inspector/WebFeedInspectorViewController.swift b/iOS/Inspector/WebFeedInspectorViewController.swift deleted file mode 100644 index 3a96b70df..000000000 --- a/iOS/Inspector/WebFeedInspectorViewController.swift +++ /dev/null @@ -1,233 +0,0 @@ -// -// WebFeedInspectorViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 11/6/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import UIKit -import Account -import SafariServices -import UserNotifications - -class WebFeedInspectorViewController: UITableViewController { - - static let preferredContentSizeForFormSheetDisplay = CGSize(width: 460.0, height: 500.0) - - var webFeed: WebFeed! - @IBOutlet weak var nameTextField: UITextField! - @IBOutlet weak var notifyAboutNewArticlesSwitch: UISwitch! - @IBOutlet weak var alwaysShowReaderViewSwitch: UISwitch! - @IBOutlet weak var homePageLabel: InteractiveLabel! - @IBOutlet weak var feedURLLabel: InteractiveLabel! - - private var headerView: InspectorIconHeaderView? - private var iconImage: IconImage? { - return IconImageCache.shared.imageForFeed(webFeed) - } - - private let homePageIndexPath = IndexPath(row: 0, section: 1) - - private var shouldHideHomePageSection: Bool { - return webFeed.homePageURL == nil - } - - private var userNotificationSettings: UNNotificationSettings? - - override func viewDidLoad() { - tableView.register(InspectorIconHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") - - navigationItem.title = webFeed.nameForDisplay - nameTextField.text = webFeed.nameForDisplay - - notifyAboutNewArticlesSwitch.setOn(webFeed.isNotifyAboutNewArticles ?? false, animated: false) - - if webFeed.isFeedProvider { - alwaysShowReaderViewSwitch.isOn = false - alwaysShowReaderViewSwitch.isEnabled = false - } else { - alwaysShowReaderViewSwitch.setOn(webFeed.isArticleExtractorAlwaysOn ?? false, animated: false) - } - - - homePageLabel.text = webFeed.homePageURL?.decodedURLString - feedURLLabel.text = webFeed.url.decodedURLString - - NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil) - - NotificationCenter.default.addObserver(self, selector: #selector(updateNotificationSettings), name: UIApplication.willEnterForegroundNotification, object: nil) - - } - - override func viewDidAppear(_ animated: Bool) { - updateNotificationSettings() - } - - override func viewDidDisappear(_ animated: Bool) { - if nameTextField.text != webFeed.nameForDisplay { - let nameText = nameTextField.text ?? "" - let newName = nameText.isEmpty ? (webFeed.name ?? NSLocalizedString("Untitled", comment: "Feed name")) : nameText - webFeed.rename(to: newName) { _ in } - } - } - - // MARK: Notifications - @objc func webFeedIconDidBecomeAvailable(_ notification: Notification) { - headerView?.iconView.iconImage = iconImage - } - - @IBAction func notifyAboutNewArticlesChanged(_ sender: Any) { - guard let settings = userNotificationSettings else { - notifyAboutNewArticlesSwitch.isOn = !notifyAboutNewArticlesSwitch.isOn - return - } - if settings.authorizationStatus == .denied { - notifyAboutNewArticlesSwitch.isOn = !notifyAboutNewArticlesSwitch.isOn - present(notificationUpdateErrorAlert(), animated: true, completion: nil) - } else if settings.authorizationStatus == .authorized { - webFeed.isNotifyAboutNewArticles = notifyAboutNewArticlesSwitch.isOn - } else { - UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in - self.updateNotificationSettings() - if granted { - DispatchQueue.main.async { - self.webFeed.isNotifyAboutNewArticles = self.notifyAboutNewArticlesSwitch.isOn - UIApplication.shared.registerForRemoteNotifications() - } - } else { - DispatchQueue.main.async { - self.notifyAboutNewArticlesSwitch.isOn = !self.notifyAboutNewArticlesSwitch.isOn - } - } - } - } - } - - @IBAction func alwaysShowReaderViewChanged(_ sender: Any) { - webFeed.isArticleExtractorAlwaysOn = alwaysShowReaderViewSwitch.isOn - } - - @IBAction func done(_ sender: Any) { - dismiss(animated: true) - } - - /// Returns a new indexPath, taking into consideration any - /// conditions that may require the tableView to be - /// displayed differently than what is setup in the storyboard. - private func shift(_ indexPath: IndexPath) -> IndexPath { - return IndexPath(row: indexPath.row, section: shift(indexPath.section)) - } - - /// Returns a new section, taking into consideration any - /// conditions that may require the tableView to be - /// displayed differently than what is setup in the storyboard. - private func shift(_ section: Int) -> Int { - if section >= homePageIndexPath.section && shouldHideHomePageSection { - return section + 1 - } - return section - } - - -} - -// MARK: Table View - -extension WebFeedInspectorViewController { - - override func numberOfSections(in tableView: UITableView) -> Int { - let numberOfSections = super.numberOfSections(in: tableView) - return shouldHideHomePageSection ? numberOfSections - 1 : numberOfSections - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return super.tableView(tableView, numberOfRowsInSection: shift(section)) - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: shift(section)) - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = super.tableView(tableView, cellForRowAt: shift(indexPath)) - if indexPath.section == 0 && indexPath.row == 1 { - guard let label = cell.contentView.subviews.filter({ $0.isKind(of: UILabel.self) })[0] as? UILabel else { - return cell - } - label.numberOfLines = 2 - label.text = webFeed.notificationDisplayName.capitalized - } - return cell - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - super.tableView(tableView, titleForHeaderInSection: shift(section)) - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - if shift(section) == 0 { - headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as? InspectorIconHeaderView - headerView?.iconView.iconImage = iconImage - return headerView - } else { - return super.tableView(tableView, viewForHeaderInSection: shift(section)) - } - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if shift(indexPath) == homePageIndexPath, - let homePageUrlString = webFeed.homePageURL, - let homePageUrl = URL(string: homePageUrlString) { - - let safari = SFSafariViewController(url: homePageUrl) - safari.modalPresentationStyle = .pageSheet - present(safari, animated: true) { - tableView.deselectRow(at: indexPath, animated: true) - } - } - } - -} - -// MARK: UITextFieldDelegate - -extension WebFeedInspectorViewController: UITextFieldDelegate { - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return true - } - -} - -// MARK: UNUserNotificationCenter - -extension WebFeedInspectorViewController { - - @objc - func updateNotificationSettings() { - UNUserNotificationCenter.current().getNotificationSettings { (settings) in - DispatchQueue.main.async { - self.userNotificationSettings = settings - if settings.authorizationStatus == .authorized { - UIApplication.shared.registerForRemoteNotifications() - } - } - } - } - - func notificationUpdateErrorAlert() -> UIAlertController { - let alert = UIAlertController(title: NSLocalizedString("Enable Notifications", comment: "Notifications"), - message: NSLocalizedString("Notifications need to be enabled in the Settings app.", comment: "Notifications need to be enabled in the Settings app."), preferredStyle: .alert) - let openSettings = UIAlertAction(title: NSLocalizedString("Open Settings", comment: "Open Settings"), style: .default) { (action) in - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [UIApplication.OpenExternalURLOptionsKey.universalLinksOnly : false], completionHandler: nil) - } - let dismiss = UIAlertAction(title: NSLocalizedString("Dismiss", comment: "Dismiss"), style: .cancel, handler: nil) - alert.addAction(openSettings) - alert.addAction(dismiss) - alert.preferredAction = openSettings - return alert - } - -} diff --git a/iOS/Resources/Assets.xcassets/Settings/Contents.json b/iOS/Resources/Assets.xcassets/Settings/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/iOS/Resources/Assets.xcassets/Settings/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Resources/Assets.xcassets/Settings/app.account.imageset/Contents.json b/iOS/Resources/Assets.xcassets/Settings/app.account.imageset/Contents.json new file mode 100644 index 000000000..7c6a92182 --- /dev/null +++ b/iOS/Resources/Assets.xcassets/Settings/app.account.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "app.account.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Resources/Assets.xcassets/Settings/app.account.imageset/app.account.pdf b/iOS/Resources/Assets.xcassets/Settings/app.account.imageset/app.account.pdf new file mode 100644 index 000000000..e339edc4e Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/app.account.imageset/app.account.pdf differ diff --git a/iOS/Resources/Assets.xcassets/Settings/app.appearance.automatic.imageset/Contents.json b/iOS/Resources/Assets.xcassets/Settings/app.appearance.automatic.imageset/Contents.json new file mode 100644 index 000000000..11a2b61ec --- /dev/null +++ b/iOS/Resources/Assets.xcassets/Settings/app.appearance.automatic.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "app.appearance.automatic.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "app.appearance.automatic 1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Resources/Assets.xcassets/Settings/app.appearance.automatic.imageset/app.appearance.automatic 1.png b/iOS/Resources/Assets.xcassets/Settings/app.appearance.automatic.imageset/app.appearance.automatic 1.png new file mode 100644 index 000000000..67bc3e466 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/app.appearance.automatic.imageset/app.appearance.automatic 1.png differ diff --git a/iOS/Resources/Assets.xcassets/Settings/app.appearance.automatic.imageset/app.appearance.automatic.png b/iOS/Resources/Assets.xcassets/Settings/app.appearance.automatic.imageset/app.appearance.automatic.png new file mode 100644 index 000000000..67bc3e466 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/app.appearance.automatic.imageset/app.appearance.automatic.png differ diff --git a/iOS/Resources/Assets.xcassets/Settings/app.appearance.dark.imageset/Contents.json b/iOS/Resources/Assets.xcassets/Settings/app.appearance.dark.imageset/Contents.json new file mode 100644 index 000000000..ed84c97cb --- /dev/null +++ b/iOS/Resources/Assets.xcassets/Settings/app.appearance.dark.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "app.appearance.dark.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "app.appearance.dark 1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Resources/Assets.xcassets/Settings/app.appearance.dark.imageset/app.appearance.dark 1.png b/iOS/Resources/Assets.xcassets/Settings/app.appearance.dark.imageset/app.appearance.dark 1.png new file mode 100644 index 000000000..b3da0a6e7 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/app.appearance.dark.imageset/app.appearance.dark 1.png differ diff --git a/iOS/Resources/Assets.xcassets/Settings/app.appearance.dark.imageset/app.appearance.dark.png b/iOS/Resources/Assets.xcassets/Settings/app.appearance.dark.imageset/app.appearance.dark.png new file mode 100644 index 000000000..b3da0a6e7 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/app.appearance.dark.imageset/app.appearance.dark.png differ diff --git a/iOS/Resources/Assets.xcassets/Settings/app.appearance.imageset/Contents.json b/iOS/Resources/Assets.xcassets/Settings/app.appearance.imageset/Contents.json new file mode 100644 index 000000000..330472128 --- /dev/null +++ b/iOS/Resources/Assets.xcassets/Settings/app.appearance.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "app.appearance.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Resources/Assets.xcassets/Settings/app.appearance.imageset/app.appearance.pdf b/iOS/Resources/Assets.xcassets/Settings/app.appearance.imageset/app.appearance.pdf new file mode 100644 index 000000000..19f943839 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/app.appearance.imageset/app.appearance.pdf differ diff --git a/iOS/Resources/Assets.xcassets/Settings/app.appearance.light.imageset/Contents.json b/iOS/Resources/Assets.xcassets/Settings/app.appearance.light.imageset/Contents.json new file mode 100644 index 000000000..3050a7534 --- /dev/null +++ b/iOS/Resources/Assets.xcassets/Settings/app.appearance.light.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "app.appearance.light.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "app.appearance.light 1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Resources/Assets.xcassets/Settings/app.appearance.light.imageset/app.appearance.light 1.png b/iOS/Resources/Assets.xcassets/Settings/app.appearance.light.imageset/app.appearance.light 1.png new file mode 100644 index 000000000..d777687ff Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/app.appearance.light.imageset/app.appearance.light 1.png differ diff --git a/iOS/Resources/Assets.xcassets/Settings/app.appearance.light.imageset/app.appearance.light.png b/iOS/Resources/Assets.xcassets/Settings/app.appearance.light.imageset/app.appearance.light.png new file mode 100644 index 000000000..d777687ff Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/app.appearance.light.imageset/app.appearance.light.png differ diff --git a/iOS/Resources/Assets.xcassets/Settings/app.export.opml.imageset/Contents.json b/iOS/Resources/Assets.xcassets/Settings/app.export.opml.imageset/Contents.json new file mode 100644 index 000000000..7acf71c0a --- /dev/null +++ b/iOS/Resources/Assets.xcassets/Settings/app.export.opml.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "app.export.opml.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Resources/Assets.xcassets/Settings/app.export.opml.imageset/app.export.opml.pdf b/iOS/Resources/Assets.xcassets/Settings/app.export.opml.imageset/app.export.opml.pdf new file mode 100644 index 000000000..fc9b8ddcb Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/app.export.opml.imageset/app.export.opml.pdf differ diff --git a/iOS/Resources/Assets.xcassets/Settings/app.extension.imageset/Contents.json b/iOS/Resources/Assets.xcassets/Settings/app.extension.imageset/Contents.json new file mode 100644 index 000000000..3e6386566 --- /dev/null +++ b/iOS/Resources/Assets.xcassets/Settings/app.extension.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "app.extension.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Resources/Assets.xcassets/Settings/app.extension.imageset/app.extension.pdf b/iOS/Resources/Assets.xcassets/Settings/app.extension.imageset/app.extension.pdf new file mode 100644 index 000000000..1893a41f0 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/app.extension.imageset/app.extension.pdf differ diff --git a/iOS/Resources/Assets.xcassets/Settings/app.import.opml.imageset/Contents.json b/iOS/Resources/Assets.xcassets/Settings/app.import.opml.imageset/Contents.json new file mode 100644 index 000000000..3e481982f --- /dev/null +++ b/iOS/Resources/Assets.xcassets/Settings/app.import.opml.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "app.import.opml.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Resources/Assets.xcassets/Settings/app.import.opml.imageset/app.import.opml.pdf b/iOS/Resources/Assets.xcassets/Settings/app.import.opml.imageset/app.import.opml.pdf new file mode 100644 index 000000000..45edaf327 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/app.import.opml.imageset/app.import.opml.pdf differ diff --git a/iOS/Resources/Assets.xcassets/Settings/notifications.sounds.imageset/Contents.json b/iOS/Resources/Assets.xcassets/Settings/notifications.sounds.imageset/Contents.json new file mode 100644 index 000000000..6222d7dd9 --- /dev/null +++ b/iOS/Resources/Assets.xcassets/Settings/notifications.sounds.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "notifications.sounds.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Resources/Assets.xcassets/Settings/notifications.sounds.imageset/notifications.sounds.pdf b/iOS/Resources/Assets.xcassets/Settings/notifications.sounds.imageset/notifications.sounds.pdf new file mode 100644 index 000000000..be7e71232 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/notifications.sounds.imageset/notifications.sounds.pdf differ diff --git a/iOS/Resources/Assets.xcassets/Settings/system.settings.imageset/Contents.json b/iOS/Resources/Assets.xcassets/Settings/system.settings.imageset/Contents.json new file mode 100644 index 000000000..842c6a591 --- /dev/null +++ b/iOS/Resources/Assets.xcassets/Settings/system.settings.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "system.settings.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/Resources/Assets.xcassets/Settings/system.settings.imageset/system.settings.pdf b/iOS/Resources/Assets.xcassets/Settings/system.settings.imageset/system.settings.pdf new file mode 100644 index 000000000..c17072520 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/Settings/system.settings.imageset/system.settings.pdf differ diff --git a/iOS/Resources/Assets.xcassets/accountFeedbin.imageset/Contents.json b/iOS/Resources/Assets.xcassets/accountFeedbin.imageset/Contents.json index b16e4373f..64197aab9 100644 --- a/iOS/Resources/Assets.xcassets/accountFeedbin.imageset/Contents.json +++ b/iOS/Resources/Assets.xcassets/accountFeedbin.imageset/Contents.json @@ -1,16 +1,6 @@ { "images" : [ { - "filename" : "feedbin-logo-filled-1.pdf", - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], "filename" : "feedbin-logo-filled.pdf", "idiom" : "universal" } diff --git a/iOS/Resources/Assets.xcassets/accountFeedbin.imageset/feedbin-logo-filled-1.pdf b/iOS/Resources/Assets.xcassets/accountFeedbin.imageset/feedbin-logo-filled-1.pdf deleted file mode 100644 index a68d754ae..000000000 Binary files a/iOS/Resources/Assets.xcassets/accountFeedbin.imageset/feedbin-logo-filled-1.pdf and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/accountFeedbin.imageset/feedbin-logo-filled.pdf b/iOS/Resources/Assets.xcassets/accountFeedbin.imageset/feedbin-logo-filled.pdf index a68d754ae..fd592dbf4 100644 Binary files a/iOS/Resources/Assets.xcassets/accountFeedbin.imageset/feedbin-logo-filled.pdf and b/iOS/Resources/Assets.xcassets/accountFeedbin.imageset/feedbin-logo-filled.pdf differ diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index a30ccfe1a..0ecab2b4f 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -13,6 +13,7 @@ import Articles import RSCore import RSTree import SafariServices +import SwiftUI protocol MainControllerIdentifiable { var mainControllerIdentifier: MainControllerIdentifier { get } @@ -1172,23 +1173,14 @@ final class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { } func showSettings(scrollToArticlesSection: Bool = false) { - let settingsNavController = UIStoryboard.settings.instantiateInitialViewController() as! UINavigationController - let settingsViewController = settingsNavController.topViewController as! SettingsViewController - settingsViewController.scrollToArticlesSection = scrollToArticlesSection - settingsNavController.modalPresentationStyle = .formSheet - settingsViewController.presentingParentController = rootSplitViewController - rootSplitViewController.present(settingsNavController, animated: true) + var s = scrollToArticlesSection + let hostedSettings = UIHostingController(rootView: SettingsView(isConfigureAppearanceShown: Binding(get: { s }, set: { s = $0 }))) + rootSplitViewController.present(hostedSettings, animated: true) } func showAccountInspector(for account: Account) { - let accountInspectorNavController = - UIStoryboard.inspector.instantiateViewController(identifier: "AccountInspectorNavigationViewController") as! UINavigationController - let accountInspectorController = accountInspectorNavController.topViewController as! AccountInspectorViewController - accountInspectorNavController.modalPresentationStyle = .formSheet - accountInspectorNavController.preferredContentSize = AccountInspectorViewController.preferredContentSizeForFormSheetDisplay - accountInspectorController.isModal = true - accountInspectorController.account = account - rootSplitViewController.present(accountInspectorNavController, animated: true) + let hosting = UIHostingController(rootView: InjectedNavigationView(injectedView: AccountInspectorView(account: account))) + rootSplitViewController.present(hosting, animated: true, completion: nil) } func showFeedInspector() { @@ -1201,13 +1193,10 @@ final class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { } func showFeedInspector(for feed: WebFeed) { - let feedInspectorNavController = - UIStoryboard.inspector.instantiateViewController(identifier: "FeedInspectorNavigationViewController") as! UINavigationController - let feedInspectorController = feedInspectorNavController.topViewController as! WebFeedInspectorViewController - feedInspectorNavController.modalPresentationStyle = .formSheet - feedInspectorNavController.preferredContentSize = WebFeedInspectorViewController.preferredContentSizeForFormSheetDisplay - feedInspectorController.webFeed = feed - rootSplitViewController.present(feedInspectorNavController, animated: true) + + let hosting = UIHostingController(rootView: InjectedNavigationView(injectedView: WebFeedInspectorView(webFeed: feed))) + + rootSplitViewController.present(hosting, animated: true) } func showAddWebFeed(initialFeed: String? = nil, initialFeedName: String? = nil) { @@ -1333,8 +1322,11 @@ final class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { if presentedController.isKind(of: SFSafariViewController.self) { presentedController.dismiss(animated: true, completion: nil) } - guard let settings = presentedController.children.first as? SettingsViewController else { return } - settings.dismiss(animated: true, completion: nil) + + // There's no obvious way to detect if the presented controller + // is the SwiftUI UIHostingController. Posting a notification + // which it can react to seems to be the simplest solution. + NotificationCenter.default.post(name: .LaunchedFromExternalAction, object: nil) } } diff --git a/iOS/SceneDelegate.swift b/iOS/SceneDelegate.swift index 81947ebac..1cd4c50bb 100644 --- a/iOS/SceneDelegate.swift +++ b/iOS/SceneDelegate.swift @@ -202,7 +202,7 @@ import RSCore } task.resume() } else { - print("No theme URL") + self.logger.debug("No theme URL.") return } } else { diff --git a/iOS/Settings/Account and Extensions/Accounts/AccountsManagementView.swift b/iOS/Settings/Account and Extensions/Accounts/AccountsManagementView.swift new file mode 100644 index 000000000..6dc43826d --- /dev/null +++ b/iOS/Settings/Account and Extensions/Accounts/AccountsManagementView.swift @@ -0,0 +1,148 @@ +// +// AccountsManagementView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 13/11/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account +import Combine + +public final class AccountManagementViewModel: ObservableObject { + + @Published var sortedActiveAccounts = [Account]() + @Published var sortedInactiveAccounts = [Account]() + @Published var accountsForDeletion = [Account]() + @Published var showAccountDeletionAlert: Bool = false + @Published var showAddAccountSheet: Bool = false + public var accountToDelete: Account? = nil + + init() { + refreshAccounts() + + NotificationCenter.default.addObserver(self, selector: #selector(refreshAccounts(_:)), name: .AccountStateDidChange, object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(refreshAccounts(_:)), name: .UserDidAddAccount, object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(refreshAccounts(_:)), name: .UserDidDeleteAccount, object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(refreshAccounts(_:)), name: .DisplayNameDidChange, object: nil) + } + + func temporarilyDeleteAccount(_ account: Account) { + if account.isActive { + sortedActiveAccounts.removeAll(where: { $0.accountID == account.accountID }) + } else { + sortedInactiveAccounts.removeAll(where: { $0.accountID == account.accountID }) + } + accountToDelete = account + showAccountDeletionAlert = true + } + + func restoreAccount(_ account: Account) { + accountToDelete = nil + self.refreshAccounts() + } + + @objc + private func refreshAccounts(_ sender: Any? = nil) { + sortedActiveAccounts = AccountManager.shared.sortedActiveAccounts + sortedInactiveAccounts = AccountManager.shared.sortedAccounts.filter({ $0.isActive == false }) + } + +} + + +struct AccountsManagementView: View { + + @StateObject private var viewModel = AccountManagementViewModel() + + var body: some View { + List { + Section(header: Text("Active Accounts", comment: "Active accounts section header")) { + ForEach(viewModel.sortedActiveAccounts, id: \.self) { account in + accountRow(account) + } + } + + Section(header: Text("Inactive Accounts", comment: "Inactive accounts section header")) { + ForEach(viewModel.sortedInactiveAccounts, id: \.self) { account in + accountRow(account) + } + } + } + .navigationTitle(Text("Manage Accounts", comment: "Navigation title: Manage Accounts")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + viewModel.showAddAccountSheet = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $viewModel.showAddAccountSheet) { + AddAccountListView() + } + .alert(Text("Are you sure you want to remove “\(viewModel.accountToDelete?.nameForDisplay ?? "")”?", comment: "Alert title: confirm account removal"), + isPresented: $viewModel.showAccountDeletionAlert) { + Button(role: .destructive) { + AccountManager.shared.deleteAccount(viewModel.accountToDelete!) + } label: { + Text("Remove Account", comment: "Button title") + } + + Button(role: .cancel) { + viewModel.restoreAccount(viewModel.accountToDelete!) + } label: { + Text("Cancel", comment: "Button title") + } + } message: { + Text("This action cannot be undone.", comment: "Alert message: remove account confirmation") + } + } + + func accountRow(_ account: Account) -> some View { + NavigationLink { + AccountInspectorView(account: account) + } label: { + Image(uiImage: account.smallIcon!.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 25, height: 25) + Text(account.nameForDisplay) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if account != AccountManager.shared.defaultAccount { + Button(role: .destructive) { + viewModel.temporarilyDeleteAccount(account) + } label: { + Label { + Text("Remove Account", comment: "Button title") + } icon: { + Image(systemName: "trash") + } + } + } + Button { + withAnimation { + account.isActive.toggle() + } + } label: { + if account.isActive { + Image(systemName: "minus.circle") + } else { + Image(systemName: "togglepower") + } + }.tint(account.isActive ? .yellow : Color(uiColor: AppAssets.primaryAccentColor)) + } + } +} + +struct AddAccountView_Previews: PreviewProvider { + static var previews: some View { + AccountsManagementView() + } +} diff --git a/iOS/Settings/Account and Extensions/Accounts/AddAccountListView.swift b/iOS/Settings/Account and Extensions/Accounts/AddAccountListView.swift new file mode 100644 index 000000000..b56e2b3a4 --- /dev/null +++ b/iOS/Settings/Account and Extensions/Accounts/AddAccountListView.swift @@ -0,0 +1,222 @@ +// +// AddAccountListView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 15/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account +import RSCore + +public final class AddAccountListViewModel: ObservableObject, OAuthAccountAuthorizationOperationDelegate { + + @Published public var showAddAccountSheet: (Bool, accountType: AccountType) = (false, .onMyMac) + @Published public var showAddAccountError: (Error?, Bool) = (nil, false) + public var webAccountTypes: [AccountType] { + if AppDefaults.shared.isDeveloperBuild { + return [.bazQux, .feedbin, .feedly, .inoreader, .newsBlur, .theOldReader] + .filter({ $0.isDeveloperRestricted == false }) + } else { + return [.bazQux, .feedbin, .feedly, .inoreader, .newsBlur, .theOldReader] + } + } + + public var rootViewController: UIViewController? { + var currentKeyWindow: UIWindow? { + UIApplication.shared.connectedScenes + .filter { $0.activationState == .foregroundActive } + .map { $0 as? UIWindowScene } + .compactMap { $0 } + .first?.windows + .filter { $0.isKeyWindow } + .first + } + + var rootViewController: UIViewController? { + currentKeyWindow?.rootViewController + } + + return rootViewController + } + + public func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didCreate account: Account) { + account.refreshAll { [weak self] result in + switch result { + case .success: + break + case .failure(let error): + guard let viewController = self?.rootViewController else { + return + } + viewController.presentError(error) + } + } + } + + public func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didFailWith error: Error) { + showAddAccountError = (error, true) + } +} + +struct AddAccountListView: View { + + @Environment(\.dismiss) var dismiss + @StateObject private var viewModel = AddAccountListViewModel() + + + var body: some View { + NavigationView { + List { + localAccountSection + cloudKitSection + webAccountSection + selfHostedSection + } + .navigationTitle(Text("Add Account", comment: "Navigation title: Add Account")) + .navigationBarTitleDisplayMode(.inline) + .listItemTint(.primary) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(role: .cancel) { + dismiss() + } label: { + Text("Cancel", comment: "Button title") + } + } + } + .sheet(isPresented: $viewModel.showAddAccountSheet.0) { + switch viewModel.showAddAccountSheet.accountType { + case .onMyMac: + LocalAddAccountView() + case .cloudKit: + CloudKitAddAccountView() + case .newsBlur: + NewsBlurAddAccountView() + case .freshRSS, .inoreader, .bazQux, .theOldReader: + ReaderAPIAddAccountView(accountType: viewModel.showAddAccountSheet.accountType, account: nil) + default: + Text(viewModel.showAddAccountSheet.accountType.localizedAccountName()) + } + } + .alert(Text("Error", comment: "Alert title: Error"), + isPresented: $viewModel.showAddAccountError.1, + actions: { }, + message: { + Text("\(viewModel.showAddAccountError.0?.localizedDescription ?? "Unknown Error")") + }) + .dismissOnAccountAdd() + } + } + + var localAccountSection: some View { + Section { + Button { + viewModel.showAddAccountSheet = (true, .onMyMac) + } label: { + Label { + Text(AccountType.onMyMac.localizedAccountName()) + .foregroundColor(.primary) + } icon: { + Image(uiImage: AppAssets.image(for: .onMyMac)!) + .resizable() + .frame(width: 30, height: 30) + } + } + } header: { + Text("Local", comment: "Add Account: Local account section header") + } footer: { + Text("Local accounts do not sync your feeds across devices", comment: "Local account section footer") + } + } + + var cloudKitSection: some View { + Section { + Button { + viewModel.showAddAccountSheet = (true, .cloudKit) + } label: { + Label { + Text(AccountType.cloudKit.localizedAccountName()) + .foregroundColor(interactionDisabled(for: .cloudKit) ? .secondary : .primary) + } icon: { + Image(uiImage: AppAssets.image(for: .cloudKit)!) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + } + } + .disabled(interactionDisabled(for: .cloudKit)) + } header: { + Text("iCloud", comment: "Add Account: iCloud section header") + } footer: { + Text("Your iCloud account syncs your feeds across your Mac and iOS devices", comment: "Add Account: iCloud section footer") + } + } + + var webAccountSection: some View { + Section { + ForEach(viewModel.webAccountTypes, id: \.self) { webAccount in + Button { + if webAccount == .feedly { + let addAccount = OAuthAccountAuthorizationOperation(accountType: .feedly) + addAccount.delegate = viewModel + addAccount.presentationAnchor = viewModel.rootViewController?.view.window + MainThreadOperationQueue.shared.add(addAccount) + } else { + viewModel.showAddAccountSheet = (true, webAccount) + } + + } label: { + Label { + Text(webAccount.localizedAccountName()) + .foregroundColor(.primary) + } icon: { + Image(uiImage: AppAssets.image(for: webAccount)!) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + } + } + } + } header: { + Text("Web Account", comment: "Add Account: Web Account section header") + } footer: { + Text("Web accounts sync your feeds across all your devices", comment: "Add Account: Web Account section footer") + } + } + + var selfHostedSection: some View { + Section { + Button { + viewModel.showAddAccountSheet = (true, .freshRSS) + } label: { + Label { + Text(AccountType.freshRSS.localizedAccountName()) + .foregroundColor(.primary) + } icon: { + Image(uiImage: AppAssets.image(for: .freshRSS)!) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + } + } + } header: { + Text("Self-Hosted", comment: "Add Accont: Self-hosted section header") + } footer: { + Text("Self-hosted accounts sync your feeds across all your devices", comment: "Add Account: Self-hosted section footer") + } + } + + private func interactionDisabled(for accountType: AccountType) -> Bool { + if accountType == .cloudKit { + if AccountManager.shared.accounts.contains(where: { $0.type == .cloudKit }) { + return true + } + return AppDefaults.shared.isDeveloperBuild + } + + return accountType.isDeveloperRestricted + } + +} diff --git a/iOS/Settings/Account and Extensions/Extensions/AddExtensionListView.swift b/iOS/Settings/Account and Extensions/Extensions/AddExtensionListView.swift new file mode 100644 index 000000000..b7e5178ea --- /dev/null +++ b/iOS/Settings/Account and Extensions/Extensions/AddExtensionListView.swift @@ -0,0 +1,65 @@ +// +// AddExtensionListView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 13/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Accounts + +struct AddExtensionListView: View { + + @State private var availableExtensionPointTypes = ExtensionPointManager.shared.availableExtensionPointTypes.sorted(by: { $0.title < $1.title }) + @Environment(\.dismiss) var dismiss + @State private var showExtensionPointView: (ExtensionPoint.Type?, Bool) = (nil, false) + + var body: some View { + NavigationView { + List { + Section(header: Text("Feed Providers", comment: "Feed Providers section header"), + footer: Text("Feed Providers allow you to subscribe to some pages as if they were RSS feeds.", comment: "Feed Providers section footer.")) { + ForEach(0.. ASPresentationAnchor { + return rootViewController!.view.window! + } + + public var rootViewController: UIViewController? { + var currentKeyWindow: UIWindow? { + UIApplication.shared.connectedScenes + .filter { $0.activationState == .foregroundActive } + .map { $0 as? UIWindowScene } + .compactMap { $0 } + .first?.windows + .filter { $0.isKeyWindow } + .first + } + + var rootViewController: UIViewController? { + currentKeyWindow?.rootViewController + } + + return rootViewController + } + +} diff --git a/iOS/Settings/Account and Extensions/Extensions/ExtensionsManagementView.swift b/iOS/Settings/Account and Extensions/Extensions/ExtensionsManagementView.swift new file mode 100644 index 000000000..56143efa8 --- /dev/null +++ b/iOS/Settings/Account and Extensions/Extensions/ExtensionsManagementView.swift @@ -0,0 +1,90 @@ +// +// ExtensionsManagementView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 30/11/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account + +struct ExtensionsManagementView: View { + + @State private var availableExtensionPointTypes = ExtensionPointManager.shared.availableExtensionPointTypes.sorted(by: { $0.title < $1.title }) + @State private var showAddExtensionView: Bool = false + @State private var showDeactivateAlert: Bool = false + @State private var extensionToDeactivate: Dictionary.Element? = nil + + var body: some View { + List { + activeExtensionsSection + } + .navigationTitle(Text("Manage Extensions", comment: "Navigation title: Manage Extensions")) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showAddExtensionView = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showAddExtensionView) { + AddExtensionListView() + } + .alert(Text("Are you sure you want to deactivate “\(extensionToDeactivate?.value.title ?? "")?", comment: "Alert title: confirm deactivate extension"), + isPresented: $showDeactivateAlert) { + + Button(role: .destructive) { + ExtensionPointManager.shared.deactivateExtensionPoint(extensionToDeactivate!.value.extensionPointID) + } label: { + Text("Deactivate Extension", comment: "Button: deactivate extension.") + } + + Button(role: .cancel) { + extensionToDeactivate = nil + } label: { + Text("Cancel", comment: "Button title") + } + + } message: { + Text("This action cannot be undone.", comment: "Alert message: confirmation that deactivation of extension cannot be undone.") + } + .onReceive(NotificationCenter.default.publisher(for: .ActiveExtensionPointsDidChange), perform: { _ in + availableExtensionPointTypes = ExtensionPointManager.shared.availableExtensionPointTypes.sorted(by: { $0.title < $1.title }) + }) + } + + private var activeExtensionsSection: some View { + Section(header: Text("Active Extensions", comment: "Active Extensions section header")) { + ForEach(0.. Int { - return AddAccountSections.allCases.count - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if section == AddAccountSections.local.rawValue { - return AddAccountSections.local.sectionContent.count - } - - if section == AddAccountSections.icloud.rawValue { - return AddAccountSections.icloud.sectionContent.count - } - - if section == AddAccountSections.web.rawValue { - return AddAccountSections.web.sectionContent.count - } - - if section == AddAccountSections.selfhosted.rawValue { - return AddAccountSections.selfhosted.sectionContent.count - } - - return 0 - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - switch section { - case AddAccountSections.local.rawValue: - return AddAccountSections.local.sectionHeader - case AddAccountSections.icloud.rawValue: - return AddAccountSections.icloud.sectionHeader - case AddAccountSections.web.rawValue: - return AddAccountSections.web.sectionHeader - case AddAccountSections.selfhosted.rawValue: - return AddAccountSections.selfhosted.sectionHeader - default: - return nil - } - } - - override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - switch section { - case AddAccountSections.local.rawValue: - return AddAccountSections.local.sectionFooter - case AddAccountSections.icloud.rawValue: - return AddAccountSections.icloud.sectionFooter - case AddAccountSections.web.rawValue: - return AddAccountSections.web.sectionFooter - case AddAccountSections.selfhosted.rawValue: - return AddAccountSections.selfhosted.sectionFooter - default: - return nil - } - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAccountTableViewCell", for: indexPath) as! SettingsComboTableViewCell - - switch indexPath.section { - case AddAccountSections.local.rawValue: - cell.comboNameLabel?.text = AddAccountSections.local.sectionContent[indexPath.row].localizedAccountName() - cell.comboImage?.image = AppAssets.image(for: .onMyMac) - case AddAccountSections.icloud.rawValue: - cell.comboNameLabel?.text = AddAccountSections.icloud.sectionContent[indexPath.row].localizedAccountName() - cell.comboImage?.image = AppAssets.image(for: AddAccountSections.icloud.sectionContent[indexPath.row]) - if AppDefaults.shared.isDeveloperBuild || AccountManager.shared.accounts.contains(where: { $0.type == .cloudKit }) { - cell.isUserInteractionEnabled = false - cell.comboNameLabel?.isEnabled = false - } - case AddAccountSections.web.rawValue: - cell.comboNameLabel?.text = AddAccountSections.web.sectionContent[indexPath.row].localizedAccountName() - cell.comboImage?.image = AppAssets.image(for: AddAccountSections.web.sectionContent[indexPath.row]) - let type = AddAccountSections.web.sectionContent[indexPath.row] - if (type == .feedly || type == .inoreader) && AppDefaults.shared.isDeveloperBuild { - cell.isUserInteractionEnabled = false - cell.comboNameLabel?.isEnabled = false - } - case AddAccountSections.selfhosted.rawValue: - cell.comboNameLabel?.text = AddAccountSections.selfhosted.sectionContent[indexPath.row].localizedAccountName() - cell.comboImage?.image = AppAssets.image(for: AddAccountSections.selfhosted.sectionContent[indexPath.row]) - - default: - return cell - } - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - - switch indexPath.section { - case AddAccountSections.local.rawValue: - let type = AddAccountSections.local.sectionContent[indexPath.row] - presentController(for: type) - case AddAccountSections.icloud.rawValue: - let type = AddAccountSections.icloud.sectionContent[indexPath.row] - presentController(for: type) - case AddAccountSections.web.rawValue: - let type = AddAccountSections.web.sectionContent[indexPath.row] - presentController(for: type) - case AddAccountSections.selfhosted.rawValue: - let type = AddAccountSections.selfhosted.sectionContent[indexPath.row] - presentController(for: type) - default: - return - } - } - - private func presentController(for accountType: AccountType) { - switch accountType { - case .onMyMac: - let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "LocalAccountNavigationViewController") as! UINavigationController - navController.modalPresentationStyle = .currentContext - let addViewController = navController.topViewController as! LocalAccountViewController - addViewController.delegate = self - present(navController, animated: true) - case .cloudKit: - let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "CloudKitAccountNavigationViewController") as! UINavigationController - navController.modalPresentationStyle = .currentContext - let addViewController = navController.topViewController as! CloudKitAccountViewController - addViewController.delegate = self - present(navController, animated: true) - case .feedbin: - let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "FeedbinAccountNavigationViewController") as! UINavigationController - navController.modalPresentationStyle = .currentContext - let addViewController = navController.topViewController as! FeedbinAccountViewController - addViewController.delegate = self - present(navController, animated: true) - case .feedly: - let addAccount = OAuthAccountAuthorizationOperation(accountType: .feedly) - addAccount.delegate = self - addAccount.presentationAnchor = self.view.window! - MainThreadOperationQueue.shared.add(addAccount) - case .newsBlur: - let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "NewsBlurAccountNavigationViewController") as! UINavigationController - navController.modalPresentationStyle = .currentContext - let addViewController = navController.topViewController as! NewsBlurAccountViewController - addViewController.delegate = self - present(navController, animated: true) - case .bazQux, .inoreader, .freshRSS, .theOldReader: - let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "ReaderAPIAccountNavigationViewController") as! UINavigationController - navController.modalPresentationStyle = .currentContext - let addViewController = navController.topViewController as! ReaderAPIAccountViewController - addViewController.accountType = accountType - addViewController.delegate = self - present(navController, animated: true) - } - } - - func dismiss() { - navigationController?.popViewController(animated: false) - } - -} - -extension AddAccountViewController: OAuthAccountAuthorizationOperationDelegate { - - func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didCreate account: Account) { - let rootViewController = view.window?.rootViewController - - account.refreshAll { result in - switch result { - case .success: - break - case .failure(let error): - guard let viewController = rootViewController else { - return - } - viewController.presentError(error) - } - } - - dismiss() - } - - func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didFailWith error: Error) { - presentError(error) - } -} diff --git a/iOS/Settings/AddExtensionPointViewController.swift b/iOS/Settings/AddExtensionPointViewController.swift deleted file mode 100644 index 249498dba..000000000 --- a/iOS/Settings/AddExtensionPointViewController.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// AddExtensionPointViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 4/16/20. -// Copyright © 2020 Ranchero Software. All rights reserved. -// - -import UIKit - -protocol AddExtensionPointDismissDelegate: UIViewController { - func dismiss() -} - -class AddExtensionPointViewController: UITableViewController, AddExtensionPointDismissDelegate { - - private var availableExtensionPointTypes = [ExtensionPoint.Type]() - - override func viewDidLoad() { - super.viewDidLoad() - availableExtensionPointTypes = ExtensionPointManager.shared.availableExtensionPointTypes.sorted(by: { $0.title < $1.title }) - } - - override func numberOfSections(in tableView: UITableView) -> Int { - 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return availableExtensionPointTypes.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsExtensionTableViewCell", for: indexPath) as! SettingsComboTableViewCell - - let extensionPointType = availableExtensionPointTypes[indexPath.row] - cell.comboNameLabel?.text = extensionPointType.title - cell.comboImage?.image = extensionPointType.image - - return cell - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - return NSLocalizedString("Feed Provider", comment: "Feed Provider Header") - } - - override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - return NSLocalizedString("Feed Providers allow you to subscribe to some pages as if they were RSS feeds.", comment: "Feed Provider Footer") - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let navController = UIStoryboard.settings.instantiateViewController(withIdentifier: "EnableExtensionPointNavigationViewController") as! UINavigationController - navController.modalPresentationStyle = .currentContext - let enableViewController = navController.topViewController as! EnableExtensionPointViewController - enableViewController.delegate = self - enableViewController.extensionPointType = availableExtensionPointTypes[indexPath.row] - present(navController, animated: true) - } - - func dismiss() { - navigationController?.popViewController(animated: false) - } - -} diff --git a/iOS/Settings/Appearance/ArticleThemeManagerView.swift b/iOS/Settings/Appearance/ArticleThemeManagerView.swift new file mode 100644 index 000000000..b2c411eae --- /dev/null +++ b/iOS/Settings/Appearance/ArticleThemeManagerView.swift @@ -0,0 +1,182 @@ +// +// ArticleThemeManagerView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 20/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI + +struct ArticleThemeManagerView: View { + + @StateObject private var themeManager = ArticleThemesManager.shared + @State private var showDeleteConfirmation: (String, Bool) = ("", false) + @State private var showImportThemeView: Bool = false + @State private var showImportConfirmationAlert: (ArticleTheme?, Bool) = (nil, false) + @State private var showImportErrorAlert: (Error?, Bool) = (nil, false) + @State private var showImportSuccessAlert: Bool = false + + var body: some View { + Form { + Section(header: Text("Built-in Themes", comment: "Section header for installed themes"), footer: Text("These themes cannot be deleted.", comment: "Section footer for installed themes.")) { + articleThemeRow(try! themeManager.articleThemeWithThemeName(AppDefaults.defaultThemeName)) + ForEach(0.. some View { + Button { + themeManager.currentThemeName = theme.name + } label: { + HStack { + VStack(alignment: .leading) { + Text(theme.name) + .foregroundColor(.primary) + Text("Created by \(theme.creatorName)", comment: "Article theme creator byline.") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + if themeManager.currentThemeName == theme.name { + Image(systemName: "checkmark") + .foregroundColor(Color(uiColor: AppAssets.primaryAccentColor)) + } + } + + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if theme.isAppTheme || theme.name == themeManager.currentThemeName { + + } else { + Button { + showDeleteConfirmation = (theme.name, true) + } label: { + Text("Delete", comment: "Button title") + Image(systemName: "trash") + } + .tint(.red) + } + } + } +} + +struct ArticleThemeImporterView_Previews: PreviewProvider { + static var previews: some View { + ArticleThemeManagerView() + } +} diff --git a/iOS/Settings/Appearance/ColorPaletteSelectorView.swift b/iOS/Settings/Appearance/ColorPaletteSelectorView.swift new file mode 100644 index 000000000..2ee8368dc --- /dev/null +++ b/iOS/Settings/Appearance/ColorPaletteSelectorView.swift @@ -0,0 +1,87 @@ +// +// ColorPaletteSelectorView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 13/11/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI + +struct ColorPaletteSelectorView: View { + + @StateObject private var appDefaults = AppDefaults.shared + + var body: some View { + HStack { + appLightButton() + Spacer() + appDarkButton() + Spacer() + appAutomaticButton() + } + } + + func appLightButton() -> some View { + VStack(spacing: 4) { + Image("app.appearance.light") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40.0, height: 40.0) + Text("Always Light", comment: "Button: always use light display mode") + .font(.subheadline) + if AppDefaults.userInterfaceColorPalette == .light { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color(uiColor: AppAssets.primaryAccentColor)) + } else { + Image(systemName: "circle") + } + }.onTapGesture { + AppDefaults.userInterfaceColorPalette = .light + } + } + + func appDarkButton() -> some View { + VStack(spacing: 4) { + Image("app.appearance.dark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40.0, height: 40.0) + Text("Always Dark", comment: "Button: always use dark display mode") + .font(.subheadline) + if AppDefaults.userInterfaceColorPalette == .dark { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color(uiColor: AppAssets.primaryAccentColor)) + } else { + Image(systemName: "circle") + } + }.onTapGesture { + AppDefaults.userInterfaceColorPalette = .dark + } + } + + func appAutomaticButton() -> some View { + VStack(spacing: 4) { + Image("app.appearance.automatic") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40.0, height: 40.0) + Text("Use Device", comment: "Button: always use device display mode") + .font(.subheadline) + if AppDefaults.userInterfaceColorPalette == .automatic { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color(uiColor: AppAssets.primaryAccentColor)) + } else { + Image(systemName: "circle") + } + }.onTapGesture { + AppDefaults.userInterfaceColorPalette = .automatic + } + } +} + +struct DisplayModeView_Previews: PreviewProvider { + static var previews: some View { + ColorPaletteSelectorView() + } +} diff --git a/iOS/Settings/Appearance/DisplayAndBehaviorsView.swift b/iOS/Settings/Appearance/DisplayAndBehaviorsView.swift new file mode 100644 index 000000000..8b3ee860a --- /dev/null +++ b/iOS/Settings/Appearance/DisplayAndBehaviorsView.swift @@ -0,0 +1,52 @@ +// +// DisplayAndBehaviorsView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 12/11/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI + +struct DisplayAndBehaviorsView: View { + + @StateObject private var appDefaults = AppDefaults.shared + + var body: some View { + List { + Section(header: Text("Application", comment: "Display & Behaviours: Application section header")) { + ColorPaletteSelectorView() + .listRowBackground(Color.clear) + } + + Section(header: Text("Timeline", comment: "Display & Behaviours: Timeline section header")) { + SettingsRow.sortOldestToNewest($appDefaults.timelineSortDirectionBool) + SettingsRow.groupByFeed($appDefaults.timelineGroupByFeed) + SettingsRow.confirmMarkAllAsRead($appDefaults.confirmMarkAllAsRead) + SettingsRow.markAsReadOnScroll($appDefaults.markArticlesAsReadOnScroll) + SettingsRow.refreshToClearReadArticles($appDefaults.refreshClearsReadArticles) + SettingsRow.timelineLayout + } + + Section(header: Text("Article", comment: "Display & Behaviours: Article section header")) { + SettingsRow.themeSelection + SettingsRow.openLinksInNetNewsWire(Binding( + get: { !appDefaults.useSystemBrowser }, + set: { appDefaults.useSystemBrowser = !$0 } + )) + // TODO: Add Reader Mode Defaults here. See #3684. + } + } + .navigationTitle(Text("Display & Behaviors", comment: "Navigation title for Display & Behaviours")) + .tint(Color(uiColor: AppAssets.primaryAccentColor)) + } + + + +} + +struct AppearanceManagementView_Previews: PreviewProvider { + static var previews: some View { + DisplayAndBehaviorsView() + } +} diff --git a/iOS/Settings/Appearance/TimelineCustomizerView.swift b/iOS/Settings/Appearance/TimelineCustomizerView.swift new file mode 100644 index 000000000..07404c7d9 --- /dev/null +++ b/iOS/Settings/Appearance/TimelineCustomizerView.swift @@ -0,0 +1,93 @@ +// +// TimelineCustomizerView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 20/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI + + +struct TimelineCustomizerView: View { + + @StateObject private var appDefaults = AppDefaults.shared + + var body: some View { + List { + Section(header: Text("Icon Size", comment: "Timline Customiser: Icon size section header")) { + ZStack { + TickMarkSliderView(minValue: 1, maxValue: 3, currentValue: Binding(get: { + Float(appDefaults.timelineIconSize.rawValue) + }, set: { AppDefaults.shared.timelineIconSize = IconSize(rawValue: Int($0))! })) + } + .customInsetGroupedRowStyle() + } + .listRowInsets(EdgeInsets(top: 0, leading: 30, bottom: 0, trailing: 30)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + + Section(header: Text("Number of Lines", comment: "Timeline customiser: Number of lines section header")) { + ZStack { + TickMarkSliderView(minValue: 1, maxValue: 5, currentValue: Binding(get: { + Float(appDefaults.timelineNumberOfLines) + }, set: { appDefaults.timelineNumberOfLines = Int($0) })) + } + .customInsetGroupedRowStyle() + } + .listRowInsets(EdgeInsets(top: 0, leading: 30, bottom: 0, trailing: 30)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + + Section { + timeLinePreviewRow + .listRowInsets(EdgeInsets(top: 8, leading: 4, bottom: 4, trailing: 24)) + } + } + .listStyle(.grouped) + .navigationTitle(Text("Timeline Layout", comment: "Navigation bar title for Timeline Layout")) + + } + + var timeLinePreviewRow: some View { + HStack(alignment: .top, spacing: 6) { + VStack { + Circle() + .foregroundColor(Color(uiColor: AppAssets.primaryAccentColor)) + .frame(width: 12, height: 12) + Spacer() + }.frame(width: 12) + VStack { + Image("faviconTemplateImage") + .renderingMode(.template) + .resizable() + .frame(width: appDefaults.timelineIconSize.size.width, height: appDefaults.timelineIconSize.size.height) + .foregroundColor(Color(uiColor: AppAssets.primaryAccentColor)) + Spacer() + }.frame(width: appDefaults.timelineIconSize.size.width) + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: "Enim ut tellus elementum sagittis vitae et. Nibh praesent tristique magna sit amet purus gravida quis blandit. Neque volutpat ac tincidunt vitae semper quis lectus nulla. Massa id neque aliquam vestibulum morbi blandit. Ultrices vitae auctor eu augue. Enim eu turpis egestas pretium aenean pharetra magna. Eget gravida cum sociis natoque. Sit amet consectetur adipiscing elit. Auctor eu augue ut lectus arcu bibendum. Maecenas volutpat blandit aliquam etiam erat velit. Ut pharetra sit amet aliquam id diam maecenas ultricies. In hac habitasse platea dictumst quisque sagittis purus sit amet.") + .bold() + .lineLimit(appDefaults.timelineNumberOfLines) + HStack { + Text("Feed name", comment: "Feed name placeholder used in timeline preview") + .foregroundColor(.secondary) + .font(.caption) + Spacer() + Text("08:51", comment: "Sample time used in timeline preview") + .foregroundColor(.secondary) + .font(.caption) + }.padding(0) + } + } + .edgesIgnoringSafeArea(.all) + .padding(.vertical, 4) + .padding(.leading, 4) + } +} + +struct TimelineCustomizerView_Previews: PreviewProvider { + static var previews: some View { + TimelineCustomizerView() + } +} diff --git a/iOS/Settings/ArticleThemesTableViewController.swift b/iOS/Settings/ArticleThemesTableViewController.swift deleted file mode 100644 index 3ab6d9557..000000000 --- a/iOS/Settings/ArticleThemesTableViewController.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// ArticleThemesTableViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 9/12/21. -// Copyright © 2021 Ranchero Software. All rights reserved. -// - -import Foundation -import UniformTypeIdentifiers -import RSCore -import UIKit - -class ArticleThemesTableViewController: UITableViewController, Logging { - - override func viewDidLoad() { - let importBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(importTheme(_:))); - importBarButtonItem.title = NSLocalizedString("Import Theme", comment: "Import Theme"); - navigationItem.rightBarButtonItem = importBarButtonItem - - NotificationCenter.default.addObserver(self, selector: #selector(articleThemeNamesDidChangeNotification(_:)), name: .ArticleThemeNamesDidChangeNotification, object: nil) - } - - // MARK: Notifications - - @objc func articleThemeNamesDidChangeNotification(_ note: Notification) { - tableView.reloadData() - } - - @objc func importTheme(_ sender: Any?) { - let docPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.nnwTheme], asCopy: true) - docPicker.delegate = self - docPicker.modalPresentationStyle = .formSheet - self.present(docPicker, animated: true) - } - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - return 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return ArticleThemesManager.shared.themeNames.count + 1 - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - - let themeName: String - if indexPath.row == 0 { - themeName = ArticleTheme.defaultTheme.name - } else { - themeName = ArticleThemesManager.shared.themeNames[indexPath.row - 1] - } - - cell.textLabel?.text = themeName - if themeName == ArticleThemesManager.shared.currentTheme.name { - cell.accessoryType = .checkmark - } else { - cell.accessoryType = .none - } - - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let cell = tableView.cellForRow(at: indexPath), let themeName = cell.textLabel?.text else { return } - ArticleThemesManager.shared.currentThemeName = themeName - navigationController?.popViewController(animated: true) - } - - override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - guard let cell = tableView.cellForRow(at: indexPath), - let themeName = cell.textLabel?.text else { return nil } - - guard let theme = try? ArticleThemesManager.shared.articleThemeWithThemeName(themeName), !theme.isAppTheme else { return nil } - - let deleteTitle = NSLocalizedString("Delete", comment: "Delete") - let deleteAction = UIContextualAction(style: .normal, title: deleteTitle) { [weak self] (action, view, completion) in - let title = NSLocalizedString("Delete Theme?", comment: "Delete Theme") - - let localizedMessageText = NSLocalizedString("Are you sure you want to delete the theme “%@”?.", comment: "Delete Theme Message") - let message = NSString.localizedStringWithFormat(localizedMessageText as NSString, themeName) as String - - let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - - let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel") - let cancelAction = UIAlertAction(title: cancelTitle, style: .cancel) { action in - completion(true) - } - alertController.addAction(cancelAction) - - let deleteTitle = NSLocalizedString("Delete", comment: "Delete") - let deleteAction = UIAlertAction(title: deleteTitle, style: .destructive) { action in - ArticleThemesManager.shared.deleteTheme(themeName: themeName) - completion(true) - } - alertController.addAction(deleteAction) - - self?.present(alertController, animated: true) - } - - deleteAction.image = AppAssets.trashImage - deleteAction.backgroundColor = UIColor.systemRed - - return UISwipeActionsConfiguration(actions: [deleteAction]) - } -} - -// MARK: UIDocumentPickerDelegate - -extension ArticleThemesTableViewController: UIDocumentPickerDelegate { - - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - guard let url = urls.first else { return } - ArticleThemeImporter.importTheme(controller: self, filename: url.standardizedFileURL.path) - } - -} diff --git a/iOS/Settings/ColorPaletteTableViewController.swift b/iOS/Settings/ColorPaletteTableViewController.swift deleted file mode 100644 index d64671c6f..000000000 --- a/iOS/Settings/ColorPaletteTableViewController.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// ColorPaletteTableViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 3/15/20. -// Copyright © 2020 Ranchero Software. All rights reserved. -// - -import UIKit - -class ColorPaletteTableViewController: UITableViewController { - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - return 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return UserInterfaceColorPalette.allCases.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - let rowColorPalette = UserInterfaceColorPalette.allCases[indexPath.row] - cell.textLabel?.text = String(describing: rowColorPalette) - if rowColorPalette == AppDefaults.userInterfaceColorPalette { - cell.accessoryType = .checkmark - } else { - cell.accessoryType = .none - } - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if let colorPalette = UserInterfaceColorPalette(rawValue: indexPath.row) { - AppDefaults.userInterfaceColorPalette = colorPalette - } - navigationController?.popViewController(animated: true) - } - -} diff --git a/iOS/Settings/EnableExtensionPointViewController.swift b/iOS/Settings/EnableExtensionPointViewController.swift deleted file mode 100644 index 64ecc1a72..000000000 --- a/iOS/Settings/EnableExtensionPointViewController.swift +++ /dev/null @@ -1,173 +0,0 @@ -// -// EnableExtensionPointViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 4/16/20. -// Copyright © 2020 Ranchero Software. All rights reserved. -// - -import UIKit -import AuthenticationServices -import Account -import OAuthSwift -import Secrets - -class EnableExtensionPointViewController: UITableViewController { - - @IBOutlet weak var extensionDescription: UILabel! - - private var callbackURL: URL? = nil - private var oauth: OAuthSwift? - - weak var delegate: AddExtensionPointDismissDelegate? - var extensionPointType: ExtensionPoint.Type? - - override func viewDidLoad() { - super.viewDidLoad() - navigationItem.title = extensionPointType?.title - extensionDescription.attributedText = extensionPointType?.description - tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") - } - - @IBAction func cancel(_ sender: Any) { - dismiss(animated: true, completion: nil) - delegate?.dismiss() - } - - @IBAction func enable(_ sender: Any) { - guard let extensionPointType = extensionPointType else { return } - - if let oauth1 = extensionPointType as? OAuth1SwiftProvider.Type { - enableOauth1(oauth1) - } else if let oauth2 = extensionPointType as? OAuth2SwiftProvider.Type { - enableOauth2(oauth2) - } else { - ExtensionPointManager.shared.activateExtensionPoint(extensionPointType) { result in - if case .failure(let error) = result { - self.presentError(error) - } - self.dismiss(animated: true, completion: nil) - self.delegate?.dismiss() - } - } - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - if section == 0 { - let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView - headerView.imageView.image = extensionPointType?.image - return headerView - } else { - return super.tableView(tableView, viewForHeaderInSection: section) - } - } - -} - -extension EnableExtensionPointViewController: OAuthSwiftURLHandlerType { - - public func handle(_ url: URL) { - let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackURL!.scheme, completionHandler: { (url, error) in - if let callbackedURL = url { - OAuth1Swift.handle(url: callbackedURL) - } - - guard let error = error else { return } - - self.oauth?.cancel() - self.oauth = nil - - DispatchQueue.main.async { - self.dismiss(animated: true, completion: nil) - self.delegate?.dismiss() - } - - if case ASWebAuthenticationSessionError.canceledLogin = error { - print("Login cancelled.") - } else { - self.presentError(error) - } - }) - - session.presentationContextProvider = self - if !session.start() { - print("Session failed to start!!!") - } - - } -} - -extension EnableExtensionPointViewController: ASWebAuthenticationPresentationContextProviding { - - public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - return view.window! - } - -} - -private extension EnableExtensionPointViewController { - - func enableOauth1(_ provider: OAuth1SwiftProvider.Type) { - callbackURL = provider.callbackURL - - let oauth1 = provider.oauth1Swift - self.oauth = oauth1 - oauth1.authorizeURLHandler = self - - oauth1.authorize(withCallbackURL: callbackURL!) { [weak self] result in - guard let self = self, let extensionPointType = self.extensionPointType else { return } - - switch result { - case .success(let tokenSuccess): - ExtensionPointManager.shared.activateExtensionPoint(extensionPointType, tokenSuccess: tokenSuccess) { result in - if case .failure(let error) = result { - self.presentError(error) - } - self.dismiss(animated: true, completion: nil) - self.delegate?.dismiss() - } - case .failure(let oauthSwiftError): - self.presentError(oauthSwiftError) - } - - self.oauth?.cancel() - self.oauth = nil - } - } - - func enableOauth2(_ provider: OAuth2SwiftProvider.Type) { - callbackURL = provider.callbackURL - - let oauth2 = provider.oauth2Swift - self.oauth = oauth2 - oauth2.authorizeURLHandler = self - - let oauth2Vars = provider.oauth2Vars - - oauth2.authorize(withCallbackURL: callbackURL!, scope: oauth2Vars.scope, state: oauth2Vars.state, parameters: oauth2Vars.params) { [weak self] result in - guard let self = self, let extensionPointType = self.extensionPointType else { return } - - switch result { - case .success(let tokenSuccess): - ExtensionPointManager.shared.activateExtensionPoint(extensionPointType, tokenSuccess: tokenSuccess) { result in - if case .failure(let error) = result { - self.presentError(error) - } - self.dismiss(animated: true, completion: nil) - self.delegate?.dismiss() - } - case .failure(let oauthSwiftError): - self.presentError(oauthSwiftError) - } - - self.oauth?.cancel() - self.oauth = nil - } - } - - -} diff --git a/iOS/Settings/General/SettingsRows.swift b/iOS/Settings/General/SettingsRows.swift new file mode 100644 index 000000000..4a6eb4e78 --- /dev/null +++ b/iOS/Settings/General/SettingsRows.swift @@ -0,0 +1,254 @@ +// +// SettingsRows.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 12/11/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account +import UniformTypeIdentifiers + + +// MARK: - Rows + +struct SettingsRow { + + /// This row, when tapped, will open iOS System Settings. + static var openSystemSettings: some View { + Label { + Text("Open System Settings", comment: "Button: opens device Settings app.") + } icon: { + Image("system.settings") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 25.0, height: 25.0) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .onTapGesture { + UIApplication.shared.open(URL(string: "\(UIApplication.openSettingsURLString)")!) + } + } + + /// This row, when tapped, will push the New Article Notifications + /// screen in to view. + static var configureNewArticleNotifications: some View { + NavigationLink(destination: NewArticleNotificationsView()) { + Label { + Text("New Article Notifications", comment: "Button: opens New Article Notifications view") + } icon: { + Image("notifications.sounds") + .resizable() + .frame(width: 25.0, height: 25.0) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + + /// This row, when tapped, will push the the Add Account screen + /// in to view. + static var addAccount: some View { + NavigationLink(destination: AccountsManagementView()) { + Label { + Text("Manage Accounts", comment: "Button: opens Accounts Management view") + } icon: { + Image("app.account") + .resizable() + .frame(width: 25.0, height: 25.0) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + + + /// Toggle for determining if articles are marked as read when scrolling the timeline view. + /// - Parameter preference: `Binding` + /// - Returns: `some View` + static func markAsReadOnScroll(_ preference: Binding) -> some View { + Toggle(isOn: preference) { + Text("Mark As Read on Scroll", comment: "Mark As Read on Scroll") + } + } + + /// This row, when tapped, will push the the Manage Extension screen + /// in to view. + static var manageExtensions: some View { + NavigationLink(destination: ExtensionsManagementView()) { + Label { + Text("Manage Extensions", comment: "Button: opens Extensions Management view") + } icon: { + Image("app.extension") + .resizable() + .frame(width: 25.0, height: 25.0) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + + /// This row, when tapped, will present the Import + /// Subscriptions Action Sheet. + static func importOPML(showImportActionSheet: Binding) -> some View { + Button { + showImportActionSheet.wrappedValue.toggle() + } label: { + Label { + Text("Import Subscriptions", comment: "Button: opens import subscriptions view") + .foregroundColor(.primary) + + } icon: { + Image("app.import.opml") + .resizable() + .frame(width: 25.0, height: 25.0) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + + /// This row, when tapped, will present the Export + /// Subscriptions Action Sheet. + static func exportOPML(showExportActionSheet: Binding) -> some View { + Button { + showExportActionSheet.wrappedValue.toggle() + } label: { + Label { + Text("Export Subscriptions", comment: "Button: opens Export Subscriptions view") + .foregroundColor(.primary) + + } icon: { + Image("app.export.opml") + .resizable() + .frame(width: 25.0, height: 25.0) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + + /// Returns a `Toggle` which triggers changes to the user's sort order preference. + /// - Parameter preference: `Binding` + /// - Returns: `Toggle` + static func sortOldestToNewest(_ preference: Binding) -> some View { + Toggle(isOn: preference) { + Text("Sort Oldest to Newest", comment: "Toggle: Sort articles from oldest to newest when enabled.") + } + } + + /// Returns a `Toggle` which triggers changes to the user's grouping preference. + /// - Parameter preference: `Binding` + /// - Returns: `Toggle` + static func groupByFeed(_ preference: Binding) -> some View { + Toggle(isOn: preference) { + Text("Group by Feed", comment: "Toggle: groups articles by feed when enabled.") + } + } + + /// Returns a `Toggle` which triggers changes to the user's refresh to clear preferences. + /// - Parameter preference: `Binding` + /// - Returns: `Toggle` + static func refreshToClearReadArticles(_ preference: Binding) -> some View { + Toggle(isOn: preference) { + Text("Refresh to Clear Read Articles", comment: "Toggle: when enabled, articles will be cleared when the timeline is refreshed") + } + } + + /// This row, when tapped, will push the the Timeline Layout screen + /// in to view. + static var timelineLayout: some View { + NavigationLink { + TimelineCustomizerView() + } label: { + Text("Timeline Layout", comment: "Button: opens the timeline customiser") + } + } + + /// This row, when tapped, will push the the Theme Selector screen + /// in to view. + static var themeSelection: some View { + NavigationLink(destination: ArticleThemeManagerView()) { + HStack { + Text("Article Theme", comment: "Button: opens the Article Theme manager view") + Spacer() + Text(ArticleThemesManager.shared.currentTheme.name) + .font(.callout) + .foregroundColor(.secondary) + } + } + } + + /// Returns a `Toggle` which triggers changes to the user's mark all as read preferences. + /// - Parameter preference: `Binding` + /// - Returns: `Toggle` + static func confirmMarkAllAsRead(_ preference: Binding) -> some View { + Toggle(isOn: preference) { + Text("Confirm Mark All as Read", comment: "Toggle: when enabled, the app will confirm whether to mark all items as read") + } + } + + /// Returns a `Toggle` which triggers changes to the user's link opening behaviour. + /// - Parameter preference: `Binding` + /// - Returns: `Toggle` + static func openLinksInNetNewsWire(_ preference: Binding) -> some View { + Toggle(isOn: preference) { + Text("Open Links in NetNewsWire", comment: "Toggle: when enabled, links will open in NetNewsWire") + } + } + + // TODO: Add Reader Mode Defaults here. See #3684. + + /// This row, when tapped, will push the New Article Notifications + /// screen in to view. + static func configureAppearance(_ isShown: Binding) -> some View { + NavigationLink(destination: DisplayAndBehaviorsView(), isActive: isShown) { + Label { + Text("Display & Behaviours", comment: "Button: opens the Display and Appearance view.") + } icon: { + Image("app.appearance") + .resizable() + .frame(width: 25.0, height: 25.0) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + + /// Sets the help sheet the user wishes to see. + /// - Parameters: + /// - sheet: The sheet provided to create the view. + /// - selectedSheet: A `Binding` to the currently selected sheet. This is set, followed by `show`. + /// - show: A `Binding` to `Bool` which triggers the sheet to display. + /// - Returns: `View` + static func showHelpSheet(sheet: HelpSheet, selectedSheet: Binding, _ show: Binding) -> some View { + Label { + Text(sheet.description) + } icon: { + Image(systemName: sheet.systemImage) + .resizable() + .renderingMode(.template) + .symbolRenderingMode(.hierarchical) + .foregroundColor(Color(uiColor: AppAssets.primaryAccentColor)) + .aspectRatio(contentMode: .fit) + .frame(width: 25.0, height: 25.0) + } + .onTapGesture { + selectedSheet.wrappedValue = sheet + show.wrappedValue.toggle() + } + } + + static var aboutNetNewsWire: some View { + NavigationLink { + AboutView() + } label: { + Label { + Text("About", comment: "Button: opens the NetNewsWire about view.") + } icon: { + Image(systemName: "info.circle.fill") + .resizable() + .renderingMode(.template) + .symbolRenderingMode(.hierarchical) + .foregroundColor(Color(uiColor: AppAssets.primaryAccentColor)) + .aspectRatio(contentMode: .fit) + .frame(width: 25.0, height: 25.0) + } + } + } +} diff --git a/iOS/Settings/General/SettingsView.swift b/iOS/Settings/General/SettingsView.swift new file mode 100644 index 000000000..e0fc89d62 --- /dev/null +++ b/iOS/Settings/General/SettingsView.swift @@ -0,0 +1,160 @@ +// +// SettingsView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 12/11/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account +import UniformTypeIdentifiers +import UserNotifications + +struct SettingsView: View { + + @Environment(\.dismiss) var dismiss + @Environment(\.scenePhase) var scenePhase + @StateObject private var appDefaults = AppDefaults.shared + @StateObject private var viewModel = SettingsViewModel() + + @Binding var isConfigureAppearanceShown: Bool + + var body: some View { + NavigationView { + List { + // Device Permissions + Section(header: Text("Device Permissions", comment: "Settings: Device Permissions section header."), + footer: Text("Configure NetNewsWire's access to Siri, background app refresh, mobile data, and more.", comment: "Settings: Device Permissions section footer.")) { + SettingsRow.openSystemSettings + } + + // Account/Extensions/OPML Management + Section(header: Text("Accounts & Extensions", comment: "Settings: Accounts and Extensions section header."), + footer: Text("Add, delete, enable, or disable accounts and extensions.", comment: "Settings: Accounts and Extensions section footer.")) { + SettingsRow.addAccount + SettingsRow.manageExtensions + SettingsRow.importOPML(showImportActionSheet: $viewModel.showImportActionSheet) + .confirmationDialog(Text("Choose an account to receive the imported feeds and folders", comment: "Import OPML confirmation title."), + isPresented: $viewModel.showImportActionSheet, + titleVisibility: .visible) { + ForEach(AccountManager.shared.sortedActiveAccounts, id: \.self) { account in + Button(account.nameForDisplay) { + viewModel.importAccount = account + viewModel.showImportView = true + } + } + } + + SettingsRow.exportOPML(showExportActionSheet: $viewModel.showExportActionSheet) + .confirmationDialog(Text("Choose an account with the subscriptions to export", comment: "Export OPML confirmation title."), + isPresented: $viewModel.showExportActionSheet, + titleVisibility: .visible) { + ForEach(AccountManager.shared.sortedAccounts, id: \.self) { account in + Button(account.nameForDisplay) { + do { + let document = try OPMLDocument(account) + viewModel.exportDocument = document + viewModel.showExportView = true + } catch { + viewModel.importExportError = error + viewModel.showImportExportError = true + } + } + } + } + } + + // Appearance + Section(header: Text("Appearance", comment: "Settings: Appearance section header."), + footer: Text("Manage the look, feel, and behavior of NetNewsWire.", comment: "Settings: Appearance section footer.")) { + SettingsRow.configureAppearance($isConfigureAppearanceShown) + if viewModel.notificationPermissions == .authorized { + SettingsRow.configureNewArticleNotifications + } + } + + // Help + Section { + ForEach(0.. [WebFeed] { + return Array(account.flattenedWebFeeds()).sorted(by: { $0.nameForDisplay.caseInsensitiveCompare($1.nameForDisplay) == .orderedAscending }) + } + + +} + +fileprivate struct WebFeedToggle: View { + + @ObservedObject var webfeed: WebFeed + + var body: some View { + Toggle(isOn: Binding( + get: { webfeed.isNotifyAboutNewArticles ?? false }, + set: { webfeed.isNotifyAboutNewArticles = $0 })) { + Label { + Text(webfeed.nameForDisplay) + } icon: { + Image(uiImage: IconImageCache.shared.imageFor(webfeed.feedID!)!.image) + .resizable() + .frame(width: 25, height: 25) + .cornerRadius(4) + } + } + } + +} + + +struct NewArticleNotificationsView_Previews: PreviewProvider { + static var previews: some View { + NewArticleNotificationsView() + } +} diff --git a/iOS/Settings/NotificationsTableViewCell.swift b/iOS/Settings/NotificationsTableViewCell.swift deleted file mode 100644 index 3e3683ccf..000000000 --- a/iOS/Settings/NotificationsTableViewCell.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// NotificationsTableViewCell.swift -// NetNewsWire-iOS -// -// Created by Stuart Breckenridge on 26/01/2022. -// Copyright © 2022 Ranchero Software. All rights reserved. -// - -import UIKit -import Account -import UserNotifications - - -class NotificationsTableViewCell: VibrantBasicTableViewCell { - - @IBOutlet weak var notificationsSwitch: UISwitch! - @IBOutlet weak var notificationsLabel: UILabel! - @IBOutlet weak var notificationsImageView: UIImageView! - weak var feed: WebFeed? - - - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } - - func configure(_ webFeed: WebFeed) { - self.feed = webFeed - var isOn = false - if webFeed.isNotifyAboutNewArticles == nil { - isOn = false - } else { - isOn = webFeed.isNotifyAboutNewArticles! - } - notificationsSwitch.isOn = isOn - notificationsSwitch.addTarget(self, action: #selector(toggleWebFeedNotification(_:)), for: .touchUpInside) - notificationsLabel.text = webFeed.nameForDisplay - notificationsImageView.image = IconImageCache.shared.imageFor(webFeed.feedID!)?.image - notificationsImageView.layer.cornerRadius = 4 - } - - @objc - private func toggleWebFeedNotification(_ sender: Any) { - guard let feed = feed else { - return - } - if feed.isNotifyAboutNewArticles == nil { - feed.isNotifyAboutNewArticles = true - } - else { - feed.isNotifyAboutNewArticles!.toggle() - } - } - -} diff --git a/iOS/Settings/NotificationsViewController.swift b/iOS/Settings/NotificationsViewController.swift deleted file mode 100644 index 1c4d78524..000000000 --- a/iOS/Settings/NotificationsViewController.swift +++ /dev/null @@ -1,306 +0,0 @@ -// -// NotificationsViewController.swift -// NetNewsWire-iOS -// -// Created by Stuart Breckenridge on 26/01/2022. -// Copyright © 2022 Ranchero Software. All rights reserved. -// - -import UIKit -import Account -import UserNotifications - -class NotificationsViewController: UIViewController { - - @IBOutlet weak var notificationsTableView: UITableView! - - private lazy var searchController: UISearchController = { - let searchController = UISearchController(searchResultsController: nil) - searchController.searchBar.placeholder = NSLocalizedString("Find a feed", comment: "Find a feed") - searchController.searchBar.searchBarStyle = .minimal - searchController.delegate = self - searchController.searchBar.delegate = self - searchController.searchBar.sizeToFit() - searchController.obscuresBackgroundDuringPresentation = false - searchController.hidesNavigationBarDuringPresentation = false - self.definesPresentationContext = true - return searchController - }() - private var status: UNAuthorizationStatus = .notDetermined - private var newArticleNotificationFilter: Bool = false { - didSet { - filterButton.menu = notificationFilterMenu() - } - } - private var filterButton: UIBarButtonItem! - - override func viewDidLoad() { - super.viewDidLoad() - title = NSLocalizedString("New Article Notifications", comment: "Notifications") - - navigationItem.searchController = searchController - notificationsTableView.isPrefetchingEnabled = false - - filterButton = UIBarButtonItem( - title: nil, - image: AppAssets.moreImage, - primaryAction: nil, - menu: notificationFilterMenu()) - - navigationItem.rightBarButtonItem = filterButton - - reloadNotificationTableView() - - NotificationCenter.default.addObserver(self, selector: #selector(updateCellsFrom(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(reloadVisibleCells(_:)), name: .FaviconDidBecomeAvailable, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(reloadNotificationTableView(_:)), name: UIScene.willEnterForegroundNotification, object: nil) - } - - @objc - private func reloadNotificationTableView(_ sender: Any? = nil) { - UNUserNotificationCenter.current().getNotificationSettings { settings in - DispatchQueue.main.async { - self.status = settings.authorizationStatus - if self.status != .authorized { - self.filterButton.isEnabled = false - self.newArticleNotificationFilter = false - } - self.notificationsTableView.reloadData() - } - } - } - - @objc - private func updateCellsFrom(_ notification: Notification) { - guard let webFeed = notification.userInfo?[UserInfoKey.webFeed] as? WebFeed else { return } - if let visibleIndexPaths = notificationsTableView.indexPathsForVisibleRows { - for path in visibleIndexPaths { - if let cell = notificationsTableView.cellForRow(at: path) as? NotificationsTableViewCell { - if cell.feed! == webFeed { - cell.configure(webFeed) - return - } - } - } - } - } - - @objc - private func reloadVisibleCells(_ notification: Notification) { - guard let faviconURLString = notification.userInfo?["faviconURL"] as? String, - let faviconHost = URL(string: faviconURLString)?.host else { - return - } - - for cell in notificationsTableView.visibleCells { - if let notificationCell = cell as? NotificationsTableViewCell { - if let feedURLHost = URL(string: notificationCell.feed!.url)?.host { - if faviconHost == feedURLHost { - notificationCell.configure(notificationCell.feed!) - return - } - } - } - } - } - - - private func notificationFilterMenu() -> UIMenu { - - if filterButton != nil { - if newArticleNotificationFilter { - filterButton.image = AppAssets.moreImageFill - } else { - filterButton.image = AppAssets.moreImage - } - } - - - let filterMenu = UIMenu(title: "", - image: nil, - identifier: nil, - options: [.displayInline], - children: [ - UIAction( - title: NSLocalizedString("Show Feeds with Notifications Enabled", comment: "Feeds with Notifications"), - image: nil, - identifier: nil, - discoverabilityTitle: nil, - attributes: [], - state: newArticleNotificationFilter ? .on : .off, - handler: { [weak self] _ in - self?.newArticleNotificationFilter.toggle() - self?.notificationsTableView.reloadData() - })]) - - - var menus = [UIMenuElement]() - menus.append(filterMenu) - - for account in AccountManager.shared.sortedActiveAccounts { - let accountMenu = UIMenu(title: account.nameForDisplay, image: nil, identifier: nil, options: .singleSelection, children: [enableAllAction(for: account), disableAllAction(for: account)]) - menus.append(accountMenu) - } - - let combinedMenu = UIMenu(title: "", - image: nil, - identifier: nil, - options: .displayInline, - children: menus) - - return combinedMenu - } - - private func enableAllAction(for account: Account) -> UIAction { - let action = UIAction(title: NSLocalizedString("Enable All Notifications", comment: "Enable All"), - image: nil, - identifier: nil, - discoverabilityTitle: nil, - attributes: [], - state: .off) { [weak self] _ in - for feed in account.flattenedWebFeeds() { - feed.isNotifyAboutNewArticles = true - } - self?.notificationsTableView.reloadData() - self?.filterButton.menu = self?.notificationFilterMenu() - } - return action - } - - private func disableAllAction(for account: Account) -> UIAction { - let action = UIAction(title: NSLocalizedString("Disable All Notifications", comment: "Disable All"), - image: nil, - identifier: nil, - discoverabilityTitle: nil, - attributes: [], - state: .off) { [weak self] _ in - for feed in account.flattenedWebFeeds() { - feed.isNotifyAboutNewArticles = false - } - self?.notificationsTableView.reloadData() - self?.filterButton.menu = self?.notificationFilterMenu() - } - return action - } - - // MARK: - Feed Filtering - - private func sortedWebFeedsForAccount(_ account: Account) -> [WebFeed] { - return Array(account.flattenedWebFeeds()).sorted(by: { $0.nameForDisplay.caseInsensitiveCompare($1.nameForDisplay) == .orderedAscending }) - } - - private func filteredWebFeeds(_ searchText: String? = "", account: Account) -> [WebFeed] { - sortedWebFeedsForAccount(account).filter { feed in - return feed.nameForDisplay.lowercased().contains(searchText!.lowercased()) - } - } - - private func feedsWithNotificationsEnabled(_ account: Account) -> [WebFeed] { - sortedWebFeedsForAccount(account).filter { feed in - return feed.isNotifyAboutNewArticles == true - } - } - -} - -// MARK: - UITableViewDataSource -extension NotificationsViewController: UITableViewDataSource { - - func numberOfSections(in tableView: UITableView) -> Int { - if status == .denied { return 1 } - return 1 + AccountManager.shared.activeAccounts.count - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if section == 0 { - if status == .denied { return 1 } - return 0 - } - if searchController.isActive { - return filteredWebFeeds(searchController.searchBar.text, account: AccountManager.shared.sortedActiveAccounts[section - 1]).count - } else if newArticleNotificationFilter == true { - return feedsWithNotificationsEnabled(AccountManager.shared.sortedActiveAccounts[section - 1]).count - } else { - return AccountManager.shared.sortedActiveAccounts[section - 1].flattenedWebFeeds().count - } - - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if indexPath.section == 0 { - let openSettingsCell = tableView.dequeueReusableCell(withIdentifier: "OpenSettingsCell") as! VibrantBasicTableViewCell - return openSettingsCell - } else { - if searchController.isActive { - let cell = tableView.dequeueReusableCell(withIdentifier: "NotificationsCell") as! NotificationsTableViewCell - let account = AccountManager.shared.sortedActiveAccounts[indexPath.section - 1] - cell.configure(filteredWebFeeds(searchController.searchBar.text, account: account)[indexPath.row]) - return cell - } else if newArticleNotificationFilter == true { - let cell = tableView.dequeueReusableCell(withIdentifier: "NotificationsCell") as! NotificationsTableViewCell - let account = AccountManager.shared.sortedActiveAccounts[indexPath.section - 1] - cell.configure(feedsWithNotificationsEnabled(account)[indexPath.row]) - return cell - } else { - let cell = tableView.dequeueReusableCell(withIdentifier: "NotificationsCell") as! NotificationsTableViewCell - let account = AccountManager.shared.sortedActiveAccounts[indexPath.section - 1] - cell.configure(sortedWebFeedsForAccount(account)[indexPath.row]) - return cell - } - } - } - - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - if section == 0 { return nil } - return AccountManager.shared.sortedActiveAccounts[section - 1].nameForDisplay - } - - func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - if section == 0 { - if status == .denied { - return NSLocalizedString("Notification permissions are currently denied. Enable notifications in the Settings app.", comment: "Notifications denied.") - } - } - return nil - } -} - - -// MARK: - UITableViewDelegate -extension NotificationsViewController: UITableViewDelegate { - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - if indexPath.section == 0 { - UIApplication.shared.open(URL(string: "\(UIApplication.openSettingsURLString)")!) - } - } - -} - - -// MARK: - UISearchControllerDelegate -extension NotificationsViewController: UISearchControllerDelegate { - - func didDismissSearchController(_ searchController: UISearchController) { - print(#function) - searchController.isActive = false - notificationsTableView.reloadData() - } - -} - -// MARK: - UISearchBarDelegate -extension NotificationsViewController: UISearchBarDelegate { - - func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { - searchController.isActive = true - newArticleNotificationFilter = false - notificationsTableView.reloadData() - } - - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - notificationsTableView.reloadData() - } - -} diff --git a/iOS/Settings/Settings.storyboard b/iOS/Settings/Settings.storyboard deleted file mode 100644 index 9db7de044..000000000 --- a/iOS/Settings/Settings.storyboard +++ /dev/null @@ -1,1381 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOS/Settings/SettingsComboTableViewCell.swift b/iOS/Settings/SettingsComboTableViewCell.swift deleted file mode 100644 index 9e0200a27..000000000 --- a/iOS/Settings/SettingsComboTableViewCell.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// SettingsAccountTableViewCell.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 10/23/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import UIKit - -class SettingsComboTableViewCell: VibrantTableViewCell { - - @IBOutlet weak var comboImage: UIImageView! - @IBOutlet weak var comboNameLabel: UILabel! - - override func updateVibrancy(animated: Bool) { - super.updateVibrancy(animated: animated) - updateLabelVibrancy(comboNameLabel, color: labelColor, animated: animated) - - let tintColor = isHighlighted || isSelected ? AppAssets.vibrantTextColor : UIColor.label - if animated { - UIView.animate(withDuration: Self.duration) { - self.comboImage?.tintColor = tintColor - } - } else { - self.comboImage?.tintColor = tintColor - } - } - -} diff --git a/iOS/Settings/SettingsComboTableViewCell.xib b/iOS/Settings/SettingsComboTableViewCell.xib deleted file mode 100644 index 4cf193f27..000000000 --- a/iOS/Settings/SettingsComboTableViewCell.xib +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOS/Settings/SettingsTableViewCell.xib b/iOS/Settings/SettingsTableViewCell.xib deleted file mode 100644 index 27afad7ce..000000000 --- a/iOS/Settings/SettingsTableViewCell.xib +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/iOS/Settings/SettingsViewController.swift b/iOS/Settings/SettingsViewController.swift deleted file mode 100644 index aff4f26ea..000000000 --- a/iOS/Settings/SettingsViewController.swift +++ /dev/null @@ -1,529 +0,0 @@ -// -// SettingsViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 4/24/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import UIKit -import Account -import CoreServices -import SafariServices -import SwiftUI -import UniformTypeIdentifiers -import UserNotifications -import RSCore - -class SettingsViewController: UITableViewController, Logging { - - private weak var opmlAccount: Account? - - @IBOutlet weak var timelineSortOrderSwitch: UISwitch! - @IBOutlet weak var groupByFeedSwitch: UISwitch! - @IBOutlet weak var refreshClearsReadArticlesSwitch: UISwitch! - @IBOutlet weak var markArticlesAsReadOnScrollSwitch: UISwitch! - @IBOutlet weak var articleThemeDetailLabel: UILabel! - @IBOutlet weak var confirmMarkAllAsReadSwitch: UISwitch! - @IBOutlet weak var colorPaletteDetailLabel: UILabel! - @IBOutlet weak var openLinksInNetNewsWire: UISwitch! - - var scrollToArticlesSection = false - weak var presentingParentController: UIViewController? - - var notificationStatus: UNAuthorizationStatus = .notDetermined - - override func viewDidLoad() { - // This hack mostly works around a bug in static tables with dynamic type. See: https://spin.atomicobject.com/2018/10/15/dynamic-type-static-uitableview/ - NotificationCenter.default.removeObserver(tableView!, name: UIContentSizeCategory.didChangeNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil) - - NotificationCenter.default.addObserver(self, selector: #selector(accountsDidChange), name: .UserDidAddAccount, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(accountsDidChange), name: .UserDidDeleteAccount, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange), name: .DisplayNameDidChange, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(activeExtensionPointsDidChange), name: .ActiveExtensionPointsDidChange, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(refreshNotificationStatus(_:)), name: UIScene.willEnterForegroundNotification, object: nil) - - - tableView.register(UINib(nibName: "SettingsComboTableViewCell", bundle: nil), forCellReuseIdentifier: "SettingsComboTableViewCell") - tableView.register(UINib(nibName: "SettingsTableViewCell", bundle: nil), forCellReuseIdentifier: "SettingsTableViewCell") - - refreshNotificationStatus() - - tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 44 - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - if AppDefaults.shared.timelineSortDirection == .orderedAscending { - timelineSortOrderSwitch.isOn = true - } else { - timelineSortOrderSwitch.isOn = false - } - - if AppDefaults.shared.timelineGroupByFeed { - groupByFeedSwitch.isOn = true - } else { - groupByFeedSwitch.isOn = false - } - - if AppDefaults.shared.refreshClearsReadArticles { - refreshClearsReadArticlesSwitch.isOn = true - } else { - refreshClearsReadArticlesSwitch.isOn = false - } - - if AppDefaults.shared.markArticlesAsReadOnScroll { - markArticlesAsReadOnScrollSwitch.isOn = true - } else { - markArticlesAsReadOnScrollSwitch.isOn = false - } - - articleThemeDetailLabel.text = ArticleThemesManager.shared.currentTheme.name - - if AppDefaults.shared.confirmMarkAllAsRead { - confirmMarkAllAsReadSwitch.isOn = true - } else { - confirmMarkAllAsReadSwitch.isOn = false - } - - colorPaletteDetailLabel.text = String(describing: AppDefaults.userInterfaceColorPalette) - openLinksInNetNewsWire.isOn = !AppDefaults.shared.useSystemBrowser - - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - self.tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - - if scrollToArticlesSection { - tableView.scrollToRow(at: IndexPath(row: 0, section: 4), at: .top, animated: true) - scrollToArticlesSection = false - } - } - - @objc - func refreshNotificationStatus(_ sender: Any? = nil) { - UNUserNotificationCenter.current().getNotificationSettings { settings in - DispatchQueue.main.async { - self.notificationStatus = settings.authorizationStatus - self.tableView.reloadData() - } - } - } - - // MARK: UITableView - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - - switch section { - case 0: - if notificationStatus == .authorized { return 2 } - return 1 - case 1: - return AccountManager.shared.accounts.count + 1 - case 2: - return ExtensionPointManager.shared.activeExtensionPoints.count + 1 - case 3: - let defaultNumberOfRows = super.tableView(tableView, numberOfRowsInSection: section) - if AccountManager.shared.activeAccounts.isEmpty || AccountManager.shared.anyAccountHasNetNewsWireNewsSubscription() { - return defaultNumberOfRows - 1 - } - return defaultNumberOfRows - case 5: - return 3 - default: - return super.tableView(tableView, numberOfRowsInSection: section) - } - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - let cell: UITableViewCell - switch indexPath.section { - case 1: - - let sortedAccounts = AccountManager.shared.sortedAccounts - if indexPath.row == sortedAccounts.count { - cell = tableView.dequeueReusableCell(withIdentifier: "SettingsTableViewCell", for: indexPath) - cell.textLabel?.text = NSLocalizedString("Add Account", comment: "Accounts") - } else { - let acctCell = tableView.dequeueReusableCell(withIdentifier: "SettingsComboTableViewCell", for: indexPath) as! SettingsComboTableViewCell - acctCell.applyThemeProperties() - let account = sortedAccounts[indexPath.row] - acctCell.comboImage?.image = AppAssets.image(for: account.type) - acctCell.comboNameLabel?.text = account.nameForDisplay - cell = acctCell - } - - case 2: - - let extensionPoints = Array(ExtensionPointManager.shared.activeExtensionPoints.values) - if indexPath.row == extensionPoints.count { - cell = tableView.dequeueReusableCell(withIdentifier: "SettingsTableViewCell", for: indexPath) - cell.textLabel?.text = NSLocalizedString("Add Extension", comment: "Extensions") - } else { - let acctCell = tableView.dequeueReusableCell(withIdentifier: "SettingsComboTableViewCell", for: indexPath) as! SettingsComboTableViewCell - acctCell.applyThemeProperties() - let extensionPoint = extensionPoints[indexPath.row] - acctCell.comboImage?.image = extensionPoint.image - acctCell.comboNameLabel?.text = extensionPoint.title - cell = acctCell - } - default: - cell = super.tableView(tableView, cellForRowAt: indexPath) - - } - - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - - switch indexPath.section { - case 0: - if indexPath.row == 0 { - UIApplication.shared.open(URL(string: "\(UIApplication.openSettingsURLString)")!) - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - } else { - let controller = UIStoryboard.settings.instantiateController(ofType: NotificationsViewController.self) - self.navigationController?.pushViewController(controller, animated: true) - } - case 1: - let sortedAccounts = AccountManager.shared.sortedAccounts - if indexPath.row == sortedAccounts.count { - let controller = UIStoryboard.settings.instantiateController(ofType: AddAccountViewController.self) - self.navigationController?.pushViewController(controller, animated: true) - } else { - let controller = UIStoryboard.inspector.instantiateController(ofType: AccountInspectorViewController.self) - controller.account = sortedAccounts[indexPath.row] - self.navigationController?.pushViewController(controller, animated: true) - } - case 2: - let extensionPoints = Array(ExtensionPointManager.shared.activeExtensionPoints.values) - if indexPath.row == extensionPoints.count { - let controller = UIStoryboard.settings.instantiateController(ofType: AddExtensionPointViewController.self) - self.navigationController?.pushViewController(controller, animated: true) - } else { - let controller = UIStoryboard.inspector.instantiateController(ofType: ExtensionPointInspectorViewController.self) - controller.extensionPoint = extensionPoints[indexPath.row] - self.navigationController?.pushViewController(controller, animated: true) - } - case 3: - switch indexPath.row { - case 0: - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - if let sourceView = tableView.cellForRow(at: indexPath) { - let sourceRect = tableView.rectForRow(at: indexPath) - importOPML(sourceView: sourceView, sourceRect: sourceRect) - } - case 1: - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - if let sourceView = tableView.cellForRow(at: indexPath) { - let sourceRect = tableView.rectForRow(at: indexPath) - exportOPML(sourceView: sourceView, sourceRect: sourceRect) - } - case 2: - addFeed() - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - default: - break - } - case 4: - switch indexPath.row { - case 4: - let timeline = UIStoryboard.settings.instantiateController(ofType: TimelineCustomizerViewController.self) - self.navigationController?.pushViewController(timeline, animated: true) - default: - break - } - case 5: - switch indexPath.row { - case 0: - let articleThemes = UIStoryboard.settings.instantiateController(ofType: ArticleThemesTableViewController.self) - self.navigationController?.pushViewController(articleThemes, animated: true) - default: - break - } - case 6: - let colorPalette = UIStoryboard.settings.instantiateController(ofType: ColorPaletteTableViewController.self) - self.navigationController?.pushViewController(colorPalette, animated: true) - case 7: - switch indexPath.row { - case 0: - openURL("https://netnewswire.com/help/ios/6.1/en/") - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - case 1: - openURL("https://netnewswire.com/") - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - case 2: - openURL(URL.releaseNotes.absoluteString) - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - case 3: - openURL("https://github.com/brentsimmons/NetNewsWire/blob/main/Technotes/HowToSupportNetNewsWire.markdown") - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - case 4: - openURL("https://github.com/brentsimmons/NetNewsWire") - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - case 5: - openURL("https://github.com/brentsimmons/NetNewsWire/issues") - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - case 6: - openURL("https://github.com/brentsimmons/NetNewsWire/tree/main/Technotes") - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - case 7: - openURL("https://netnewswire.com/slack") - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - case 8: - let hosting = UIHostingController(rootView: AboutView()) - self.navigationController?.pushViewController(hosting, animated: true) - default: - break - } - default: - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - } - } - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return false - } - - override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { - return false - } - - override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - return .none - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension - } - - override func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int { - return super.tableView(tableView, indentationLevelForRowAt: IndexPath(row: 0, section: 1)) - } - - // MARK: Actions - - @IBAction func done(_ sender: Any) { - dismiss(animated: true) - } - - @IBAction func switchTimelineOrder(_ sender: Any) { - if timelineSortOrderSwitch.isOn { - AppDefaults.shared.timelineSortDirection = .orderedAscending - } else { - AppDefaults.shared.timelineSortDirection = .orderedDescending - } - } - - @IBAction func switchGroupByFeed(_ sender: Any) { - if groupByFeedSwitch.isOn { - AppDefaults.shared.timelineGroupByFeed = true - } else { - AppDefaults.shared.timelineGroupByFeed = false - } - } - - @IBAction func switchClearsReadArticles(_ sender: Any) { - if refreshClearsReadArticlesSwitch.isOn { - AppDefaults.shared.refreshClearsReadArticles = true - } else { - AppDefaults.shared.refreshClearsReadArticles = false - } - } - - @IBAction func switchMarkArticlesAsReadOnScroll(_ sender: Any) { - if markArticlesAsReadOnScrollSwitch.isOn { - AppDefaults.shared.markArticlesAsReadOnScroll = true - } else { - AppDefaults.shared.markArticlesAsReadOnScroll = false - } - } - - @IBAction func switchConfirmMarkAllAsRead(_ sender: Any) { - if confirmMarkAllAsReadSwitch.isOn { - AppDefaults.shared.confirmMarkAllAsRead = true - } else { - AppDefaults.shared.confirmMarkAllAsRead = false - } - } - - @IBAction func switchBrowserPreference(_ sender: Any) { - if openLinksInNetNewsWire.isOn { - AppDefaults.shared.useSystemBrowser = false - } else { - AppDefaults.shared.useSystemBrowser = true - } - } - - - // MARK: Notifications - - @objc func contentSizeCategoryDidChange() { - tableView.reloadData() - } - - @objc func accountsDidChange() { - tableView.reloadData() - } - - @objc func displayNameDidChange() { - tableView.reloadData() - } - - @objc func activeExtensionPointsDidChange() { - tableView.reloadData() - } - - @objc func browserPreferenceDidChange() { - tableView.reloadData() - } - -} - -// MARK: OPML Document Picker - -extension SettingsViewController: UIDocumentPickerDelegate { - - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - for url in urls { - opmlAccount?.importOPML(url) { result in - switch result { - case .success: - break - case .failure: - let title = NSLocalizedString("Import Failed", comment: "Import Failed") - let message = NSLocalizedString("We were unable to process the selected file. Please ensure that it is a properly formatted OPML file.", comment: "Import Failed Message") - self.presentError(title: title, message: message) - } - } - } - } - -} - -// MARK: Private - -private extension SettingsViewController { - - func addFeed() { - self.dismiss(animated: true) - - let addNavViewController = UIStoryboard.add.instantiateViewController(withIdentifier: "AddWebFeedViewControllerNav") as! UINavigationController - let addViewController = addNavViewController.topViewController as! AddFeedViewController - addViewController.initialFeed = AccountManager.netNewsWireNewsURL - addViewController.initialFeedName = NSLocalizedString("NetNewsWire News", comment: "NetNewsWire News") - addNavViewController.modalPresentationStyle = .formSheet - addNavViewController.preferredContentSize = AddFeedViewController.preferredContentSizeForFormSheetDisplay - - presentingParentController?.present(addNavViewController, animated: true) - } - - func importOPML(sourceView: UIView, sourceRect: CGRect) { - switch AccountManager.shared.activeAccounts.count { - case 0: - presentError(title: "Error", message: NSLocalizedString("You must have at least one active account.", comment: "Missing active account")) - case 1: - opmlAccount = AccountManager.shared.activeAccounts.first - importOPMLDocumentPicker() - default: - importOPMLAccountPicker(sourceView: sourceView, sourceRect: sourceRect) - } - } - - func importOPMLAccountPicker(sourceView: UIView, sourceRect: CGRect) { - let title = NSLocalizedString("Choose an account to receive the imported feeds and folders", comment: "Import Account") - let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) - - if let popoverController = alert.popoverPresentationController { - popoverController.sourceView = view - popoverController.sourceRect = sourceRect - } - - for account in AccountManager.shared.sortedActiveAccounts { - let action = UIAlertAction(title: account.nameForDisplay, style: .default) { [weak self] action in - self?.opmlAccount = account - self?.importOPMLDocumentPicker() - } - alert.addAction(action) - } - - let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel") - alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel)) - - self.present(alert, animated: true) - } - - func importOPMLDocumentPicker() { - - let utiArray = UTType.types(tag: "opml", tagClass: .filenameExtension, conformingTo: nil) - - let docPicker = UIDocumentPickerViewController(forOpeningContentTypes: utiArray, asCopy: true) - docPicker.delegate = self - docPicker.modalPresentationStyle = .formSheet - self.present(docPicker, animated: true) - } - - func exportOPML(sourceView: UIView, sourceRect: CGRect) { - if AccountManager.shared.accounts.count == 1 { - opmlAccount = AccountManager.shared.accounts.first! - exportOPMLDocumentPicker() - } else { - exportOPMLAccountPicker(sourceView: sourceView, sourceRect: sourceRect) - } - } - - func exportOPMLAccountPicker(sourceView: UIView, sourceRect: CGRect) { - let title = NSLocalizedString("Choose an account with the subscriptions to export", comment: "Export Account") - let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) - - if let popoverController = alert.popoverPresentationController { - popoverController.sourceView = view - popoverController.sourceRect = sourceRect - } - - for account in AccountManager.shared.sortedAccounts { - let action = UIAlertAction(title: account.nameForDisplay, style: .default) { [weak self] action in - self?.opmlAccount = account - self?.exportOPMLDocumentPicker() - } - alert.addAction(action) - } - - let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel") - alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel)) - - self.present(alert, animated: true) - } - - func exportOPMLDocumentPicker() { - guard let account = opmlAccount else { return } - - let accountName = account.nameForDisplay.replacingOccurrences(of: " ", with: "").trimmingCharacters(in: .whitespaces) - let filename = "Subscriptions-\(accountName).opml" - let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(filename) - let opmlString = OPMLExporter.OPMLString(with: account, title: filename) - do { - try opmlString.write(to: tempFile, atomically: true, encoding: String.Encoding.utf8) - } catch { - self.presentError(title: "OPML Export Error", message: error.localizedDescription) - logger.error("OPML Export Error: \(error.localizedDescription, privacy: .public)") - } - - let docPicker = UIDocumentPickerViewController(forExporting: [tempFile], asCopy: true) - docPicker.modalPresentationStyle = .formSheet - self.present(docPicker, animated: true) - } - - func openURL(_ urlString: String) { - let vc = SFSafariViewController(url: URL(string: urlString)!) - vc.modalPresentationStyle = .pageSheet - present(vc, animated: true) - } - -} diff --git a/iOS/Settings/TimelineCustomizerViewController.swift b/iOS/Settings/TimelineCustomizerViewController.swift deleted file mode 100644 index f9d15bb9a..000000000 --- a/iOS/Settings/TimelineCustomizerViewController.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// TimelineCustomizerViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 11/8/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import UIKit - -class TimelineCustomizerViewController: UIViewController { - - @IBOutlet weak var iconSizeSliderContainerView: UIView! - @IBOutlet weak var iconSizeSlider: TickMarkSlider! - @IBOutlet weak var numberOfLinesSliderContainerView: UIView! - @IBOutlet weak var numberOfLinesSlider: TickMarkSlider! - - @IBOutlet weak var previewWidthConstraint: NSLayoutConstraint! - @IBOutlet weak var previewHeightConstraint: NSLayoutConstraint! - - @IBOutlet weak var previewContainerView: UIView! - var previewController: TimelinePreviewTableViewController { - return children.first as! TimelinePreviewTableViewController - } - - override func viewDidLoad() { - super.viewDidLoad() - - iconSizeSliderContainerView.layer.cornerRadius = 10 - iconSizeSlider.value = Float(AppDefaults.shared.timelineIconSize.rawValue) - iconSizeSlider.addTickMarks() - - numberOfLinesSliderContainerView.layer.cornerRadius = 10 - numberOfLinesSlider.value = Float(AppDefaults.shared.timelineNumberOfLines) - numberOfLinesSlider.addTickMarks() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewDidAppear(animated) - updatePreviewBorder() - updatePreview() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - updatePreviewBorder() - updatePreview() - } - - @IBAction func iconSizeChanged(_ sender: Any) { - guard let iconSize = IconSize(rawValue: Int(iconSizeSlider.value.rounded())) else { return } - AppDefaults.shared.timelineIconSize = iconSize - updatePreview() - } - - @IBAction func numberOfLinesChanged(_ sender: Any) { - AppDefaults.shared.timelineNumberOfLines = Int(numberOfLinesSlider.value.rounded()) - updatePreview() - } - -} - -// MARK: Private - -private extension TimelineCustomizerViewController { - - func updatePreview() { - let previewWidth: CGFloat = { - if traitCollection.userInterfaceIdiom == .phone { - return view.bounds.width - } else { - return view.bounds.width / 1.5 - } - }() - - previewWidthConstraint.constant = previewWidth - previewHeightConstraint.constant = previewController.heightFor(width: previewWidth) - - previewController.reload() - } - - func updatePreviewBorder() { - if traitCollection.userInterfaceStyle == .dark { - previewContainerView.layer.borderColor = UIColor.black.cgColor - previewContainerView.layer.borderWidth = 1 - } else { - previewContainerView.layer.borderWidth = 0 - } - } - -} diff --git a/iOS/Settings/TimelinePreviewTableViewController.swift b/iOS/Settings/TimelinePreviewTableViewController.swift deleted file mode 100644 index ad65dd052..000000000 --- a/iOS/Settings/TimelinePreviewTableViewController.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// TimelinePreviewTableViewController.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 11/8/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import UIKit -import Articles - -class TimelinePreviewTableViewController: UIViewController, UITableViewDelegate, UITableViewDataSource { - - @IBOutlet weak var tableView: UITableView! - - override func viewDidLoad() { - super.viewDidLoad() - - tableView.delegate = self - tableView.dataSource = self - - } - - func heightFor(width: CGFloat) -> CGFloat { - if UIApplication.shared.preferredContentSizeCategory.isAccessibilityCategory { - let layout = MasterTimelineAccessibilityCellLayout(width: width, insets: tableView.safeAreaInsets, cellData: prototypeCellData) - return layout.height - } else { - let layout = MasterTimelineDefaultCellLayout(width: width, insets: tableView.safeAreaInsets, cellData: prototypeCellData) - return layout.height - } - } - - // MARK: - Table view data source - - func numberOfSections(in tableView: UITableView) -> Int { - return 1 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return 1 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MasterTimelineTableViewCell - cell.cellData = prototypeCellData - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - } - // MARK: API - - func reload() { - tableView.reloadData() - } - -} - -// MARK: Private - -private extension TimelinePreviewTableViewController { - - var prototypeCellData: MasterTimelineCellData { - let longTitle = "Enim ut tellus elementum sagittis vitae et. Nibh praesent tristique magna sit amet purus gravida quis blandit. Neque volutpat ac tincidunt vitae semper quis lectus nulla. Massa id neque aliquam vestibulum morbi blandit. Ultrices vitae auctor eu augue. Enim eu turpis egestas pretium aenean pharetra magna. Eget gravida cum sociis natoque. Sit amet consectetur adipiscing elit. Auctor eu augue ut lectus arcu bibendum. Maecenas volutpat blandit aliquam etiam erat velit. Ut pharetra sit amet aliquam id diam maecenas ultricies. In hac habitasse platea dictumst quisque sagittis purus sit amet." - - let prototypeID = "prototype" - let status = ArticleStatus(articleID: prototypeID, read: false, starred: false, dateArrived: Date()) - let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status) - - let iconImage = IconImage(AppAssets.faviconTemplateImage.withTintColor(AppAssets.secondaryAccentColor)) - - return MasterTimelineCellData(article: prototypeArticle, showFeedName: .feed, feedName: "Feed Name", byline: nil, iconImage: iconImage, showIcon: true, featuredImage: nil, numberOfLines: AppDefaults.shared.timelineNumberOfLines, iconSize: AppDefaults.shared.timelineIconSize, hideSeparator: false) - } - -} diff --git a/iOS/SwiftUI Extensions/AccountSectionHeader.swift b/iOS/SwiftUI Extensions/AccountSectionHeader.swift new file mode 100644 index 000000000..e0f3aed72 --- /dev/null +++ b/iOS/SwiftUI Extensions/AccountSectionHeader.swift @@ -0,0 +1,61 @@ +// +// AccountSectionHeader.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 18/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account + +struct AccountSectionHeader: View { + + var accountType: AccountType + + var body: some View { + Section(header: headerImage) {} + } + + var headerImage: some View { + HStack { + Spacer() + Image(uiImage: imageToUse()) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 48, height: 48) + Spacer() + } + } + + private func imageToUse() -> UIImage { + switch accountType { + case .onMyMac: + if UIDevice.current.userInterfaceIdiom == .pad { return AppAssets.accountLocalPadImage } + return AppAssets.accountLocalPhoneImage + case .cloudKit: + return AppAssets.accountCloudKitImage + case .feedly: + return AppAssets.accountFeedlyImage + case .feedbin: + return AppAssets.accountFeedbinImage + case .newsBlur: + return AppAssets.accountNewsBlurImage + case .freshRSS: + return AppAssets.accountFreshRSSImage + case .inoreader: + return AppAssets.accountInoreaderImage + case .bazQux: + return AppAssets.accountBazQuxImage + case .theOldReader: + return AppAssets.accountTheOldReaderImage + } + } + +} + +struct AccountHeader_Previews: PreviewProvider { + static var previews: some View { + AccountSectionHeader(accountType: .onMyMac) + } +} diff --git a/iOS/SwiftUI Extensions/CustomInsetGroupedRowStyle.swift b/iOS/SwiftUI Extensions/CustomInsetGroupedRowStyle.swift new file mode 100644 index 000000000..4ca621bec --- /dev/null +++ b/iOS/SwiftUI Extensions/CustomInsetGroupedRowStyle.swift @@ -0,0 +1,34 @@ +// +// CustomInsetGroupedRowStyle.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 22/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI + +struct CustomInsetGroupedRowStyle: ViewModifier { + + func body(content: Content) -> some View { + content + .padding(.horizontal, 16) + .padding(.vertical, 8) + .listRowInsets(EdgeInsets(top: 0, leading: 15, bottom: 0, trailing: 15)) + .background( + RoundedRectangle(cornerRadius: 8) + .foregroundColor(Color(uiColor: UIColor.secondarySystemGroupedBackground)) + ) + } + +} + +extension View { + + /// This function dismisses a view when the user launches from + /// an external action, for example, opening the app from the widget. + /// - Returns: `View` + func customInsetGroupedRowStyle() -> some View { + modifier(CustomInsetGroupedRowStyle()) + } +} diff --git a/iOS/SwiftUI Extensions/ExtensionSectionHeader.swift b/iOS/SwiftUI Extensions/ExtensionSectionHeader.swift new file mode 100644 index 000000000..3af4ad578 --- /dev/null +++ b/iOS/SwiftUI Extensions/ExtensionSectionHeader.swift @@ -0,0 +1,30 @@ +// +// ExtensionSectionHeader.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 19/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Account + +struct ExtensionSectionHeader: View { + + var extensionPoint: ExtensionPoint.Type + + var body: some View { + Section(header: headerImage) {} + } + + var headerImage: some View { + HStack { + Spacer() + Image(uiImage: extensionPoint.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 48, height: 48) + Spacer() + } + } +} diff --git a/iOS/SwiftUI Extensions/InjectedNavigationView.swift b/iOS/SwiftUI Extensions/InjectedNavigationView.swift new file mode 100644 index 000000000..9c10b55bd --- /dev/null +++ b/iOS/SwiftUI Extensions/InjectedNavigationView.swift @@ -0,0 +1,30 @@ +// +// InjectedNavigationView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 15/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI + +struct InjectedNavigationView: View { + + @Environment(\.dismiss) var dismiss + var injectedView: any View + + var body: some View { + NavigationView { + AnyView(injectedView) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(role: .cancel) { + dismiss() + } label: { + Text("Done", comment: "Button title") + } + } + } + }.navigationViewStyle(.stack) + } +} diff --git a/iOS/SwiftUI Extensions/View+DismissOnAccountAdd.swift b/iOS/SwiftUI Extensions/View+DismissOnAccountAdd.swift new file mode 100644 index 000000000..154ae86f0 --- /dev/null +++ b/iOS/SwiftUI Extensions/View+DismissOnAccountAdd.swift @@ -0,0 +1,31 @@ +// +// View+DismissOnAccountAdd.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 18/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI + +struct DismissOnAccountAdd: ViewModifier { + + @Environment(\.dismiss) private var dismiss + + func body(content: Content) -> some View { + content + .onReceive(NotificationCenter.default.publisher(for: .UserDidAddAccount)) { _ in + dismiss() + } + } + +} + +extension View { + + /// Convenience modifier to dismiss a view when an account has been added. + /// - Returns: `View` + func dismissOnAccountAdd() -> some View { + modifier(DismissOnAccountAdd()) + } +} diff --git a/iOS/SwiftUI Extensions/View+DismissOnExternalContext.swift b/iOS/SwiftUI Extensions/View+DismissOnExternalContext.swift new file mode 100644 index 000000000..82bce90d1 --- /dev/null +++ b/iOS/SwiftUI Extensions/View+DismissOnExternalContext.swift @@ -0,0 +1,33 @@ +// +// View+DismissOnExternalContext.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 18/12/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI + + +struct DismissOnExternalContext: ViewModifier { + + @Environment(\.dismiss) private var dismiss + + func body(content: Content) -> some View { + content + .onReceive(NotificationCenter.default.publisher(for: .LaunchedFromExternalAction)) { _ in + dismiss() + } + } + +} + +extension View { + + /// This function dismisses a view when the user launches from + /// an external action, for example, opening the app from the widget. + /// - Returns: `View` + func dismissOnExternalContextLaunch() -> some View { + modifier(DismissOnExternalContext()) + } +} diff --git a/iOS/UIKit Extensions/SafariView.swift b/iOS/UIKit Extensions/SafariView.swift new file mode 100644 index 000000000..0b2e2179a --- /dev/null +++ b/iOS/UIKit Extensions/SafariView.swift @@ -0,0 +1,24 @@ +// +// SafariView.swift +// NetNewsWire-iOS +// +// Created by Stuart Breckenridge on 12/11/2022. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import SwiftUI +import SafariServices + +struct SafariView: UIViewControllerRepresentable { + + let url: URL + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> SFSafariViewController { + return SFSafariViewController(url: url) + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext) { + + } + +} diff --git a/iOS/UIKit Extensions/TickMarkSlider.swift b/iOS/UIKit Extensions/TickMarkSlider.swift index 5a7aa73ba..d2fca5c48 100644 --- a/iOS/UIKit Extensions/TickMarkSlider.swift +++ b/iOS/UIKit Extensions/TickMarkSlider.swift @@ -7,6 +7,53 @@ // import UIKit +import SwiftUI + +struct TickMarkSliderView: UIViewRepresentable { + + var minValue: Int + var maxValue: Int + @Binding var currentValue: Float + + func makeUIView(context: Context) -> TickMarkSlider { + let slider = TickMarkSlider() + slider.minimumValue = Float(minValue) + slider.maximumValue = Float(maxValue) + slider.value = currentValue + slider.addTickMarks() + return slider + } + + func updateUIView(_ uiView: TickMarkSlider, context: Context) { + uiView.addTarget( + context.coordinator, + action: #selector(Coordinator.valueChanged(_:)), + for: .valueChanged + ) + } + + func makeCoordinator() -> Coordinator { + Coordinator(value: $currentValue) + } + + class Coordinator: NSObject { + var value: Binding + + init(value: Binding) { + self.value = value + } + + // Create a valueChanged(_:) action + @objc func valueChanged(_ sender: Any) { + if let slider = sender as? UISlider { + self.value.wrappedValue = Float(slider.value.rounded()) + } + } + + } + + typealias UIViewType = TickMarkSlider +} class TickMarkSlider: UISlider { diff --git a/iOS/UIKit Extensions/UIStoryboard-Extensions.swift b/iOS/UIKit Extensions/UIStoryboard-Extensions.swift index 56fc2b195..9d20ea374 100644 --- a/iOS/UIKit Extensions/UIStoryboard-Extensions.swift +++ b/iOS/UIKit Extensions/UIStoryboard-Extensions.swift @@ -7,8 +7,9 @@ // import UIKit +import RSCore -extension UIStoryboard { +extension UIStoryboard: Logging { static let preferredContentSizeForFormSheetDisplay = CGSize(width: 460.0, height: 400.0) @@ -40,7 +41,7 @@ extension UIStoryboard { let storyboardId = String(describing: type) guard let viewController = instantiateViewController(withIdentifier: storyboardId) as? T else { - print("Unable to load view with Scene Identifier: \(storyboardId)") + logger.error("Unable to load view with Scene Identifier: \(storyboardId)") fatalError() } diff --git a/iOS/UIKit Extensions/UIViewController-Extensions.swift b/iOS/UIKit Extensions/UIViewController-Extensions.swift index 1ed607502..e82395f25 100644 --- a/iOS/UIKit Extensions/UIViewController-Extensions.swift +++ b/iOS/UIKit Extensions/UIViewController-Extensions.swift @@ -13,9 +13,7 @@ import Account extension UIViewController { func presentError(_ error: Error, dismiss: (() -> Void)? = nil) { - if let accountError = error as? AccountError, accountError.isCredentialsError { - presentAccountError(accountError, dismiss: dismiss) - } else if let decodingError = error as? DecodingError { + if let decodingError = error as? DecodingError { let errorTitle = NSLocalizedString("Error", comment: "Error") var informativeText: String = "" switch decodingError { @@ -53,38 +51,3 @@ extension UIViewController { } } - -private extension UIViewController { - - func presentAccountError(_ error: AccountError, dismiss: (() -> Void)? = nil) { - let title = NSLocalizedString("Account Error", comment: "Account Error") - let alertController = UIAlertController(title: title, message: error.localizedDescription, preferredStyle: .alert) - - if error.account?.type == .feedbin { - - let credentialsTitle = NSLocalizedString("Update Credentials", comment: "Update Credentials") - let credentialsAction = UIAlertAction(title: credentialsTitle, style: .default) { [weak self] _ in - dismiss?() - - let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "FeedbinAccountNavigationViewController") as! UINavigationController - navController.modalPresentationStyle = .formSheet - let addViewController = navController.topViewController as! FeedbinAccountViewController - addViewController.account = error.account - self?.present(navController, animated: true) - } - - alertController.addAction(credentialsAction) - alertController.preferredAction = credentialsAction - - } - - let dismissTitle = NSLocalizedString("OK", comment: "OK") - let dismissAction = UIAlertAction(title: dismissTitle, style: .default) { _ in - dismiss?() - } - alertController.addAction(dismissAction) - - self.present(alertController, animated: true, completion: nil) - } - -}