diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9bea433 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +.DS_Store diff --git a/ITGlueContacts/ITGlueContacts.xcodeproj/project.pbxproj b/ITGlueContacts/ITGlueContacts.xcodeproj/project.pbxproj new file mode 100644 index 0000000..df19968 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts.xcodeproj/project.pbxproj @@ -0,0 +1,499 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + AE13490F22B5050D00501497 /* ContactsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE13490E22B5050D00501497 /* ContactsViewController.swift */; }; + AE13491122B507E200501497 /* ContactCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE13491022B507E200501497 /* ContactCell.swift */; }; + AE13491422B514AA00501497 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE13491322B514AA00501497 /* Constants.swift */; }; + AE13491922B6207E00501497 /* String+FullRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE13491822B6207E00501497 /* String+FullRange.swift */; }; + AE31A74222C34229003F52C6 /* UIViewController+Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE31A74122C34229003F52C6 /* UIViewController+Alert.swift */; }; + AE64ABC922B74F5E00704F77 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE64ABC822B74F5E00704F77 /* SearchResultsViewController.swift */; }; + AE64ABCD22B7604400704F77 /* ContactCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = AE64ABCC22B7604400704F77 /* ContactCell.xib */; }; + AE64ABCF22B7634C00704F77 /* RegisterTableViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE64ABCE22B7634C00704F77 /* RegisterTableViewCells.swift */; }; + AE64ABD422BA038800704F77 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE64ABD322BA038800704F77 /* SettingsViewController.swift */; }; + AE64ABD622BA10FE00704F77 /* KeychainItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE64ABD522BA10FE00704F77 /* KeychainItem.swift */; }; + AE64ABD822BDF84700704F77 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE64ABD722BDF84700704F77 /* UserDefaults.swift */; }; + AE655B0C22B329E600EB5AB7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B0B22B329E600EB5AB7 /* AppDelegate.swift */; }; + AE655B1122B329E600EB5AB7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AE655B0F22B329E600EB5AB7 /* Main.storyboard */; }; + AE655B1322B329E700EB5AB7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AE655B1222B329E700EB5AB7 /* Assets.xcassets */; }; + AE655B1622B329E700EB5AB7 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AE655B1422B329E700EB5AB7 /* LaunchScreen.storyboard */; }; + AE655B1E22B32BD700EB5AB7 /* ITGlueAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B1D22B32BD700EB5AB7 /* ITGlueAPI.swift */; }; + AE655B2122B32C3800EB5AB7 /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B2022B32C3800EB5AB7 /* Contact.swift */; }; + AE655B2322B32C7300EB5AB7 /* ContactAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B2222B32C7300EB5AB7 /* ContactAttributes.swift */; }; + AE655B2522B32CA400EB5AB7 /* Email.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B2422B32CA400EB5AB7 /* Email.swift */; }; + AE655B2722B32CC000EB5AB7 /* Label.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B2622B32CC000EB5AB7 /* Label.swift */; }; + AE655B2922B32CD200EB5AB7 /* Phone.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B2822B32CD200EB5AB7 /* Phone.swift */; }; + AE655B2B22B32CF900EB5AB7 /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B2A22B32CF900EB5AB7 /* Location.swift */; }; + AE655B2D22B32D4E00EB5AB7 /* LocationAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B2C22B32D4E00EB5AB7 /* LocationAttributes.swift */; }; + AE655B2F22B32DA800EB5AB7 /* ContactData.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B2E22B32DA800EB5AB7 /* ContactData.swift */; }; + AE655B3122B32DCA00EB5AB7 /* LocationData.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B3022B32DCA00EB5AB7 /* LocationData.swift */; }; + AE655B3422B32EB300EB5AB7 /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B3322B32EB300EB5AB7 /* DataSource.swift */; }; + AE655B3622B33C8F00EB5AB7 /* AppleContacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE655B3522B33C8F00EB5AB7 /* AppleContacts.swift */; }; + AE952BC422C1D6C30093E015 /* Date+CurrentTimeZoneDateString.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE952BC322C1D6C30093E015 /* Date+CurrentTimeZoneDateString.swift */; }; + AE952BC622C1E6200093E015 /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE952BC522C1E6200093E015 /* WelcomeViewController.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + AE13490E22B5050D00501497 /* ContactsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsViewController.swift; sourceTree = ""; }; + AE13491022B507E200501497 /* ContactCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCell.swift; sourceTree = ""; }; + AE13491322B514AA00501497 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + AE13491822B6207E00501497 /* String+FullRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+FullRange.swift"; sourceTree = ""; }; + AE31A74122C34229003F52C6 /* UIViewController+Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Alert.swift"; sourceTree = ""; }; + AE64ABC822B74F5E00704F77 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = ""; }; + AE64ABCC22B7604400704F77 /* ContactCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ContactCell.xib; sourceTree = ""; }; + AE64ABCE22B7634C00704F77 /* RegisterTableViewCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterTableViewCells.swift; sourceTree = ""; }; + AE64ABD322BA038800704F77 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; + AE64ABD522BA10FE00704F77 /* KeychainItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainItem.swift; sourceTree = ""; }; + AE64ABD722BDF84700704F77 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + AE655B0822B329E600EB5AB7 /* ITGlueContacts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ITGlueContacts.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AE655B0B22B329E600EB5AB7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AE655B1022B329E600EB5AB7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + AE655B1222B329E700EB5AB7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AE655B1522B329E700EB5AB7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + AE655B1722B329E700EB5AB7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AE655B1D22B32BD700EB5AB7 /* ITGlueAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ITGlueAPI.swift; sourceTree = ""; }; + AE655B2022B32C3800EB5AB7 /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; + AE655B2222B32C7300EB5AB7 /* ContactAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactAttributes.swift; sourceTree = ""; }; + AE655B2422B32CA400EB5AB7 /* Email.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Email.swift; sourceTree = ""; }; + AE655B2622B32CC000EB5AB7 /* Label.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Label.swift; sourceTree = ""; }; + AE655B2822B32CD200EB5AB7 /* Phone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Phone.swift; sourceTree = ""; }; + AE655B2A22B32CF900EB5AB7 /* Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Location.swift; sourceTree = ""; }; + AE655B2C22B32D4E00EB5AB7 /* LocationAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationAttributes.swift; sourceTree = ""; }; + AE655B2E22B32DA800EB5AB7 /* ContactData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactData.swift; sourceTree = ""; }; + AE655B3022B32DCA00EB5AB7 /* LocationData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationData.swift; sourceTree = ""; }; + AE655B3322B32EB300EB5AB7 /* DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSource.swift; sourceTree = ""; }; + AE655B3522B33C8F00EB5AB7 /* AppleContacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleContacts.swift; sourceTree = ""; }; + AE952BC322C1D6C30093E015 /* Date+CurrentTimeZoneDateString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+CurrentTimeZoneDateString.swift"; sourceTree = ""; }; + AE952BC522C1E6200093E015 /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AE655B0522B329E600EB5AB7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AE13490D22B504E200501497 /* ViewControllers */ = { + isa = PBXGroup; + children = ( + AE13490E22B5050D00501497 /* ContactsViewController.swift */, + AE64ABC822B74F5E00704F77 /* SearchResultsViewController.swift */, + AE64ABD322BA038800704F77 /* SettingsViewController.swift */, + AE952BC522C1E6200093E015 /* WelcomeViewController.swift */, + ); + path = ViewControllers; + sourceTree = ""; + }; + AE13491222B5149400501497 /* Helpers */ = { + isa = PBXGroup; + children = ( + AE13491322B514AA00501497 /* Constants.swift */, + AE64ABCE22B7634C00704F77 /* RegisterTableViewCells.swift */, + AE655B3322B32EB300EB5AB7 /* DataSource.swift */, + AE655B1D22B32BD700EB5AB7 /* ITGlueAPI.swift */, + AE655B3522B33C8F00EB5AB7 /* AppleContacts.swift */, + AE64ABD522BA10FE00704F77 /* KeychainItem.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + AE13491522B51BA100501497 /* Extensions */ = { + isa = PBXGroup; + children = ( + AE13491822B6207E00501497 /* String+FullRange.swift */, + AE64ABD722BDF84700704F77 /* UserDefaults.swift */, + AE952BC322C1D6C30093E015 /* Date+CurrentTimeZoneDateString.swift */, + AE31A74122C34229003F52C6 /* UIViewController+Alert.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + AE64ABD022B8FBC800704F77 /* Views */ = { + isa = PBXGroup; + children = ( + AE13491022B507E200501497 /* ContactCell.swift */, + AE64ABCC22B7604400704F77 /* ContactCell.xib */, + ); + path = Views; + sourceTree = ""; + }; + AE64ABD122B8FC3300704F77 /* Controllers */ = { + isa = PBXGroup; + children = ( + AE655B0B22B329E600EB5AB7 /* AppDelegate.swift */, + ); + path = Controllers; + sourceTree = ""; + }; + AE64ABD222B8FC5A00704F77 /* Resources */ = { + isa = PBXGroup; + children = ( + AE655B1222B329E700EB5AB7 /* Assets.xcassets */, + AE655B1422B329E700EB5AB7 /* LaunchScreen.storyboard */, + AE655B0F22B329E600EB5AB7 /* Main.storyboard */, + AE655B1722B329E700EB5AB7 /* Info.plist */, + ); + path = Resources; + sourceTree = ""; + }; + AE655AFF22B329E600EB5AB7 = { + isa = PBXGroup; + children = ( + AE655B0A22B329E600EB5AB7 /* ITGlueContacts */, + AE655B0922B329E600EB5AB7 /* Products */, + ); + sourceTree = ""; + }; + AE655B0922B329E600EB5AB7 /* Products */ = { + isa = PBXGroup; + children = ( + AE655B0822B329E600EB5AB7 /* ITGlueContacts.app */, + ); + name = Products; + sourceTree = ""; + }; + AE655B0A22B329E600EB5AB7 /* ITGlueContacts */ = { + isa = PBXGroup; + children = ( + AE64ABD122B8FC3300704F77 /* Controllers */, + AE13491522B51BA100501497 /* Extensions */, + AE13491222B5149400501497 /* Helpers */, + AE655B1F22B32C1400EB5AB7 /* Models */, + AE64ABD222B8FC5A00704F77 /* Resources */, + AE13490D22B504E200501497 /* ViewControllers */, + AE64ABD022B8FBC800704F77 /* Views */, + ); + path = ITGlueContacts; + sourceTree = ""; + }; + AE655B1F22B32C1400EB5AB7 /* Models */ = { + isa = PBXGroup; + children = ( + AE655B2622B32CC000EB5AB7 /* Label.swift */, + AE655B2822B32CD200EB5AB7 /* Phone.swift */, + AE655B2422B32CA400EB5AB7 /* Email.swift */, + AE655B2E22B32DA800EB5AB7 /* ContactData.swift */, + AE655B2022B32C3800EB5AB7 /* Contact.swift */, + AE655B2222B32C7300EB5AB7 /* ContactAttributes.swift */, + AE655B3022B32DCA00EB5AB7 /* LocationData.swift */, + AE655B2A22B32CF900EB5AB7 /* Location.swift */, + AE655B2C22B32D4E00EB5AB7 /* LocationAttributes.swift */, + ); + path = Models; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AE655B0722B329E600EB5AB7 /* ITGlueContacts */ = { + isa = PBXNativeTarget; + buildConfigurationList = AE655B1A22B329E700EB5AB7 /* Build configuration list for PBXNativeTarget "ITGlueContacts" */; + buildPhases = ( + AE655B0422B329E600EB5AB7 /* Sources */, + AE655B0522B329E600EB5AB7 /* Frameworks */, + AE655B0622B329E600EB5AB7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ITGlueContacts; + productName = ITGlueContacts; + productReference = AE655B0822B329E600EB5AB7 /* ITGlueContacts.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AE655B0022B329E600EB5AB7 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1020; + LastUpgradeCheck = 1020; + ORGANIZATIONNAME = "IT Glue"; + TargetAttributes = { + AE655B0722B329E600EB5AB7 = { + CreatedOnToolsVersion = 10.2.1; + }; + }; + }; + buildConfigurationList = AE655B0322B329E600EB5AB7 /* Build configuration list for PBXProject "ITGlueContacts" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AE655AFF22B329E600EB5AB7; + productRefGroup = AE655B0922B329E600EB5AB7 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AE655B0722B329E600EB5AB7 /* ITGlueContacts */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AE655B0622B329E600EB5AB7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AE64ABCD22B7604400704F77 /* ContactCell.xib in Resources */, + AE655B1622B329E700EB5AB7 /* LaunchScreen.storyboard in Resources */, + AE655B1322B329E700EB5AB7 /* Assets.xcassets in Resources */, + AE655B1122B329E600EB5AB7 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AE655B0422B329E600EB5AB7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AE655B3122B32DCA00EB5AB7 /* LocationData.swift in Sources */, + AE13491422B514AA00501497 /* Constants.swift in Sources */, + AE64ABD422BA038800704F77 /* SettingsViewController.swift in Sources */, + AE31A74222C34229003F52C6 /* UIViewController+Alert.swift in Sources */, + AE952BC422C1D6C30093E015 /* Date+CurrentTimeZoneDateString.swift in Sources */, + AE655B2922B32CD200EB5AB7 /* Phone.swift in Sources */, + AE655B2122B32C3800EB5AB7 /* Contact.swift in Sources */, + AE952BC622C1E6200093E015 /* WelcomeViewController.swift in Sources */, + AE655B3422B32EB300EB5AB7 /* DataSource.swift in Sources */, + AE655B2322B32C7300EB5AB7 /* ContactAttributes.swift in Sources */, + AE655B2D22B32D4E00EB5AB7 /* LocationAttributes.swift in Sources */, + AE655B2522B32CA400EB5AB7 /* Email.swift in Sources */, + AE655B2B22B32CF900EB5AB7 /* Location.swift in Sources */, + AE64ABCF22B7634C00704F77 /* RegisterTableViewCells.swift in Sources */, + AE13491122B507E200501497 /* ContactCell.swift in Sources */, + AE655B2F22B32DA800EB5AB7 /* ContactData.swift in Sources */, + AE64ABD822BDF84700704F77 /* UserDefaults.swift in Sources */, + AE64ABC922B74F5E00704F77 /* SearchResultsViewController.swift in Sources */, + AE655B1E22B32BD700EB5AB7 /* ITGlueAPI.swift in Sources */, + AE655B3622B33C8F00EB5AB7 /* AppleContacts.swift in Sources */, + AE13491922B6207E00501497 /* String+FullRange.swift in Sources */, + AE655B2722B32CC000EB5AB7 /* Label.swift in Sources */, + AE64ABD622BA10FE00704F77 /* KeychainItem.swift in Sources */, + AE655B0C22B329E600EB5AB7 /* AppDelegate.swift in Sources */, + AE13490F22B5050D00501497 /* ContactsViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + AE655B0F22B329E600EB5AB7 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + AE655B1022B329E600EB5AB7 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + AE655B1422B329E700EB5AB7 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + AE655B1522B329E700EB5AB7 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + AE655B1822B329E700EB5AB7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.2; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AE655B1922B329E700EB5AB7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AE655B1B22B329E700EB5AB7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = ITGlueContacts/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.itglue.ITGlueContacts; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + AE655B1C22B329E700EB5AB7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = ITGlueContacts/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 12.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.itglue.ITGlueContacts; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AE655B0322B329E600EB5AB7 /* Build configuration list for PBXProject "ITGlueContacts" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AE655B1822B329E700EB5AB7 /* Debug */, + AE655B1922B329E700EB5AB7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AE655B1A22B329E700EB5AB7 /* Build configuration list for PBXNativeTarget "ITGlueContacts" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AE655B1B22B329E700EB5AB7 /* Debug */, + AE655B1C22B329E700EB5AB7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AE655B0022B329E600EB5AB7 /* Project object */; +} diff --git a/ITGlueContacts/ITGlueContacts.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ITGlueContacts/ITGlueContacts.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..9b06de1 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ITGlueContacts/ITGlueContacts.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ITGlueContacts/ITGlueContacts.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ITGlueContacts/ITGlueContacts.xcodeproj/project.xcworkspace/xcuserdata/mpage.xcuserdatad/UserInterfaceState.xcuserstate b/ITGlueContacts/ITGlueContacts.xcodeproj/project.xcworkspace/xcuserdata/mpage.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..f934852 Binary files /dev/null and b/ITGlueContacts/ITGlueContacts.xcodeproj/project.xcworkspace/xcuserdata/mpage.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ITGlueContacts/ITGlueContacts.xcodeproj/xcuserdata/mpage.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/ITGlueContacts/ITGlueContacts.xcodeproj/xcuserdata/mpage.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..fe2b454 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts.xcodeproj/xcuserdata/mpage.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,5 @@ + + + diff --git a/ITGlueContacts/ITGlueContacts.xcodeproj/xcuserdata/mpage.xcuserdatad/xcschemes/xcschememanagement.plist b/ITGlueContacts/ITGlueContacts.xcodeproj/xcuserdata/mpage.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..576d3a3 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts.xcodeproj/xcuserdata/mpage.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + ITGlueContacts.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/ITGlueContacts/ITGlueContacts/Controllers/AppDelegate.swift b/ITGlueContacts/ITGlueContacts/Controllers/AppDelegate.swift new file mode 100644 index 0000000..8e04dd2 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Controllers/AppDelegate.swift @@ -0,0 +1,55 @@ +// +// AppDelegate.swift +// ITGlueContacts +// +// Created by Michael Page on 14/6/19. +// + +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let viewController: UIViewController + + if UserDefaults.standard.displayedAppIntro() { + // Welcome screen has been previously displayed. + viewController = storyboard.instantiateInitialViewController()! + } else { + // User has not been shown the welcome screen. + viewController = storyboard.instantiateViewController(withIdentifier: "WelcomeViewController") + } + + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = viewController + + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } +} diff --git a/ITGlueContacts/ITGlueContacts/Extensions/Date+CurrentTimeZoneDateString.swift b/ITGlueContacts/ITGlueContacts/Extensions/Date+CurrentTimeZoneDateString.swift new file mode 100644 index 0000000..2b964b1 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Extensions/Date+CurrentTimeZoneDateString.swift @@ -0,0 +1,19 @@ +// +// Date+CurrentTimeZoneDateString.swift +// ITGlueContacts +// +// Created by Michael Page on 25/6/19. +// + +import Foundation + +extension Date { + // Output a date string, based on the user's current time zone. Example: "26 Jun 2019 at 3:19 pm" + func currentTimeZoneDateString() -> String { + let format = DateFormatter() + format.timeZone = .current + format.dateStyle = .medium + format.timeStyle = .short + return format.string(from: self) + } +} diff --git a/ITGlueContacts/ITGlueContacts/Extensions/String+FullRange.swift b/ITGlueContacts/ITGlueContacts/Extensions/String+FullRange.swift new file mode 100644 index 0000000..535850a --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Extensions/String+FullRange.swift @@ -0,0 +1,15 @@ +// +// String+FullRange.swift +// ITGlueContacts +// +// Created by Michael Page on 16/6/19. +// + +import Foundation + +extension String { + // Returns NSRange of a string. + func fullRange() -> NSRange { + return NSMakeRange(0, count) + } +} diff --git a/ITGlueContacts/ITGlueContacts/Extensions/UIViewController+Alert.swift b/ITGlueContacts/ITGlueContacts/Extensions/UIViewController+Alert.swift new file mode 100644 index 0000000..4fdb45a --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Extensions/UIViewController+Alert.swift @@ -0,0 +1,20 @@ +// +// UIViewController+Alert.swift +// ITGlueContacts +// +// Created by Michael Page on 26/6/19. +// + +import UIKit + +extension UIViewController { + // Display a basic alert on the main thread. + func alert(title: String?, message: String?) { + DispatchQueue.main.async { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + let dismissAction = UIAlertAction(title: "Close", style: .default) + alert.addAction(dismissAction) + self.present(alert, animated: true) + } + } +} diff --git a/ITGlueContacts/ITGlueContacts/Extensions/UserDefaults.swift b/ITGlueContacts/ITGlueContacts/Extensions/UserDefaults.swift new file mode 100644 index 0000000..977d2e0 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Extensions/UserDefaults.swift @@ -0,0 +1,33 @@ +// +// UserDefaults.swift +// ITGlueContacts +// +// Created by Michael Page on 22/6/19. +// + +import Foundation + +// UserDefaults is used for storing app settings. +enum UserDefaultsKeys: String { + case displayedAppIntro, connectToEuropeanUnionEndpoint +} + +extension UserDefaults { + func setDisplayedAppIntro(_ value: Bool) { + set(value, forKey: UserDefaultsKeys.displayedAppIntro.rawValue) + } + + // Has the user been shown the welcome screen yet? + func displayedAppIntro() -> Bool { + return bool(forKey: UserDefaultsKeys.displayedAppIntro.rawValue) + } + + func setConnectToEuropeanUnionEndpoint(_ value: Bool) { + set(value, forKey: UserDefaultsKeys.connectToEuropeanUnionEndpoint.rawValue) + } + + // Should the app use the EU API endpoint? + func connectToEuropeanUnionEndpoint() -> Bool { + return bool(forKey: UserDefaultsKeys.connectToEuropeanUnionEndpoint.rawValue) + } +} diff --git a/ITGlueContacts/ITGlueContacts/Helpers/AppleContacts.swift b/ITGlueContacts/ITGlueContacts/Helpers/AppleContacts.swift new file mode 100644 index 0000000..208f472 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Helpers/AppleContacts.swift @@ -0,0 +1,422 @@ +// +// AppleContacts.swift +// ITGlueContacts +// +// Created by Michael Page on 17/5/19. +// + +import Contacts +import Foundation + +enum AppleContactsError: Error { + case missingRequiredContainer, unableToIdentifyContainer, unableToSearchContainers, unsupportedContactsContainer, unableToAddGroup, unableToSearchGroups, missingRequiredGroup, unableToAddContact, unableToSearchContacts, unableToUpdateExistingContact +} + +enum AppleContactsTaskStatus { + case complete +} + +class AppleContacts { + private let contactStore = CNContactStore() + // Regular contact groups are used with CardDAV and local accounts. + private let groupName = "IT Glue" + // Whereas containers are used as groups with Exchange accounts. + private var containerName: String { + return groupName + } + + // Main function, goes through each contact and adds it to Apple Contacts. + func importAllITGlueContacts(completionHandler: @escaping (Result) -> Void) throws { + for contact in DataSource.shared.allContacts { + do { + try addOrUpdateITGlueContact(contact) + } catch let error as AppleContactsError { + completionHandler(.failure(error)) + } + } + completionHandler(.success(.complete)) + } + + // Adds or updates a single contact to Apple Contacts. + func addOrUpdateITGlueContact(_ itGlueContact: Contact) throws { + guard let existingAppleContactsContact = try returnExistingContact(itGlueContact) else { + // IT Glue Contact does not currently exist in Contacts. Create a new contact. + try addContact(itGlueContact) + return + } + try updateExistingContact(itGlueContact, existingAppleContactsContact: existingAppleContactsContact) + } +} + +// MARK: - Containers + +extension AppleContacts { + // Returns the Apple Contacts container set under iOS Settings > Contacts > Default Account. + func returnDefaultContainer() throws -> CNContainer? { + var defaultContainer: CNContainer? + // Get the current default container identifier. + let defaultContainerIdentifier = contactStore.defaultContainerIdentifier() + // Create a search predicate to find the container with the default identifier. + let predicateForMatchingContainerIdentifier = CNContainer.predicateForContainers(withIdentifiers: [defaultContainerIdentifier]) + do { + // Search for the container. + let matchingContainers = try contactStore.containers(matching: predicateForMatchingContainerIdentifier) + defaultContainer = matchingContainers.first(where: { $0.identifier == defaultContainerIdentifier }) + } catch { + print("Error: Unable to search containers.") + throw AppleContactsError.unableToSearchContainers + } + return defaultContainer + } + + // Returns the default container type (CardDAV, Exchange, local or unassigned). + func defaultContainerType() -> CNContainerType { + var defaultContainer: CNContainer? + do { + defaultContainer = try returnDefaultContainer() + } catch { + print("Error: Unable to find default contacts container!") + } + return defaultContainer?.type ?? CNContainerType.unassigned + } + + // Unlike CardDAV which creates an "IT Glue" group, Exchange relies on an "IT Glue" container. This function returns that container. + func returnITGlueContainer() throws -> CNContainer? { + var matchingContainer: CNContainer? + + // Get all existing containers. + let allContainers = try contactStore.containers(matching: nil) + // Find any containers that match the specified container name. + let matchingContainers = allContainers.filter({ $0.name == containerName }) + switch matchingContainers.count { + case 0: + print("Error: Unable to find '\(containerName)' container!") + throw AppleContactsError.missingRequiredContainer + case 1: + matchingContainer = matchingContainers.first + default: + print("Error: Multiple '\(containerName)' contains found.") + throw AppleContactsError.unableToIdentifyContainer + } + + guard matchingContainer != nil else { + print("Warning: Unable to find an existing container for: \(containerName).") + return nil + } + + return matchingContainer + } + + // Returns the container that a contact belongs to. + func returnContactContainer(_ contact: CNContact) throws -> CNContainer? { + var contactContainer: CNContainer? + let predicateForMatchingContainerIdentifier = CNContainer.predicateForContainerOfContact(withIdentifier: contact.identifier) + do { + let matchingContainers = try contactStore.containers(matching: predicateForMatchingContainerIdentifier) + contactContainer = matchingContainers.first + } catch { + print("Error: Unable to search containers.") + throw AppleContactsError.unableToSearchContainers + } + return contactContainer + } +} + +// MARK: - Groups + +extension AppleContacts { + // Creates a new Apple Contacts group with the provided group name. + func addGroup(_ groupName: String) throws -> CNGroup? { + let group = CNMutableGroup() + group.name = groupName + + let saveRequest = CNSaveRequest() + + // Add the group to the default contacts container. + saveRequest.add(group, toContainerWithIdentifier: nil) + + do { + // Execute save request. + try contactStore.execute(saveRequest) + // Return the newly created group. + return try returnGroup(groupName) + } catch { + print("Error: Unable to create new group: \(error.localizedDescription)") + throw AppleContactsError.unableToAddGroup + } + } + + // Returns an existing Apple Contacts group if it exists. + func returnGroup(_ groupName: String) throws -> CNGroup? { + var matchingGroup: CNGroup? + + do { + // Get all existing groups. + let allGroups = try contactStore.groups(matching: nil) + // Find the first group that matches group name. + matchingGroup = allGroups.first(where: { $0.name == groupName }) + } catch { + print("Error: Unable to search Apple Contacts groups!") + throw AppleContactsError.unableToSearchGroups + } + + guard matchingGroup != nil else { + print("Warning: Unable to find an existing Apple Contacts group for: \(groupName).") + return nil + } + + return matchingGroup + } + + // Returns an existing contact group if it exists, otherwise it will add a new one. + func returnExistingOrNewlyCreatedGroup(_ groupName: String) throws -> CNGroup? { + do { + // Check if group already exists in Apple Contacts. + if let existingContactsGroup = try returnGroup(groupName) { + return existingContactsGroup + } + } catch { + print("Error: Unable to search Apple Contacts groups!") + throw AppleContactsError.unableToSearchGroups + } + + do { + // Group does not exist in Apple Contacts, add it. + let newContactsGroup = try addGroup(groupName) + return newContactsGroup + } catch { + print("Error: Unable to create a new group named: '\(groupName)'.") + throw AppleContactsError.unableToAddGroup + } + } +} + +// MARK: - Contacts + +extension AppleContacts { + // Adds a new IT Glue contact into Apple Contacts. + func addContact(_ newContact: Contact) throws { + // Create a mutable copy of the contact (required by CNSaveRequest). + let mutableContact = newContact.cnContactValue.mutableCopy() as! CNMutableContact + + // Create a CNSaveRequest. + let saveRequest = CNSaveRequest() + + switch defaultContainerType() { + case .cardDAV, .local: + // Ensure the the specified group ("IT Glue") exists, if not create it. + guard let contactGroup = try returnExistingOrNewlyCreatedGroup(groupName) else { + print("Error: Unable to locate or create group (\(groupName) in Contacts!)") + throw AppleContactsError.missingRequiredGroup + } + // As Exchange does not support the contact type attribute. It is only set for CardDAV and local contacts. + mutableContact.contactType = newContact.contactType + // Add contact to contacts. + saveRequest.add(mutableContact, toContainerWithIdentifier: nil) + // Add the new contact to the IT Glue contact group. + saveRequest.addMember(mutableContact, to: contactGroup) + case .exchange: + guard let itGlueContainer = try returnITGlueContainer() else { + print("Error: Unable to locate a (\(containerName) container in Contacts!)") + throw AppleContactsError.missingRequiredContainer + } + // Add contact to contacts in the Exchange 'IT Glue' container. + saveRequest.add(mutableContact, toContainerWithIdentifier: itGlueContainer.identifier) + default: + print("Error: Unsupported contacts container detected.") + throw AppleContactsError.unsupportedContactsContainer + } + + do { + // Execute save request. + try contactStore.execute(saveRequest) + } catch { + print("Error: Adding contact: \(error.localizedDescription)") + throw AppleContactsError.unableToAddContact + } + } + + // Searches Apple Contacts for an existing contact with a matching name of the provided IT Glue Contact. + func returnExistingContact(_ itGlueContact: Contact) throws -> CNContact? { + // Returns the contact name, formatted with the specified formatter (default value is CNContactFormatterStyleFullName). + let contactFormatter = CNContactFormatter() + guard let contactName = contactFormatter.string(from: itGlueContact.cnContactValue) else { + return nil + } + + // Create a search predicate to find contacts with matching name. + let predicateForMatchingName = CNContact.predicateForContacts(matchingName: contactName) + + // Contact keys (attributes) to fetch, app will crash when attempting to read data from a key that is not fetched. + let keysToFetch = [CNContactJobTitleKey, CNContactOrganizationNameKey, CNContactGivenNameKey, CNContactFamilyNameKey, CNContactEmailAddressesKey, CNContactPhoneNumbersKey, CNContactNoteKey, CNContactPostalAddressesKey, CNContactUrlAddressesKey, CNContainerTypeKey, CNContactTypeKey] as [CNKeyDescriptor] + + var possibleMatchingContacts = [CNContact]() + do { + // Perform search. + possibleMatchingContacts = try contactStore.unifiedContacts(matching: predicateForMatchingName, keysToFetch: keysToFetch) + } catch { + print("Error: Unable to search Apple Contacts!") + throw AppleContactsError.unableToSearchContacts + } + + var matchingContact: CNContact? + // When searching for an organization location the search results will also include contacts of people that belong to that organization. Therefore we need to filter results differently for an organization location contact. + if itGlueContact.isAnOrganizationLocation { + // Find the first occurrence where first and last name are unset and the organization name matches. + matchingContact = possibleMatchingContacts.first(where: { $0.givenName == "" && $0.familyName == "" && $0.organizationName == itGlueContact.attributes.organizationName }) + } else { + // Check if first or last name match or if they are unset (a single named contact). + matchingContact = possibleMatchingContacts.first(where: { ($0.givenName == itGlueContact.attributes.firstName || $0.givenName == "") && ($0.familyName == itGlueContact.attributes.lastName || $0.familyName == "") }) + } + + guard matchingContact != nil else { + print("Warning: Was unable to find an existing contact for: \(itGlueContact.attributes.fullName) \(itGlueContact.attributes.organizationName).") + return nil + } + + return matchingContact + } + + // Function updates an existing Apple Contacts contact with data from an IT Glue contact. + func updateExistingContact(_ itGlueContact: Contact, existingAppleContactsContact: CNContact) throws { + // Create a mutable copy of the existing contact. + let mutableContact = existingAppleContactsContact.mutableCopy() as! CNMutableContact + + // If job title is missing or has changed. + if let jobTitle = itGlueContact.attributes.jobTitle, mutableContact.jobTitle != jobTitle { + // Update job title. + mutableContact.jobTitle = jobTitle + } + + // If organization name is missing or has changed. + if mutableContact.organizationName != itGlueContact.attributes.organizationName { + // Update organization name. + mutableContact.organizationName = itGlueContact.attributes.organizationName + } + + // Populate a string array of existing phone numbers. + let existingPhoneNumbers = mutableContact.phoneNumbers.map({ $0.value.stringValue }) + + // For each phone number provided by the IT Glue API. + for itGlueProvidedPhoneEntry in itGlueContact.attributes.contactPhones { + // Ensure itGlueProvidedPhoneEntry.numberAndExtensionNumber is not nil, ensure number does not already exist in the contact, ensure a cnLabeledValue can be generated from that phone number. + guard let itGlueProvidedPhoneNumber = itGlueProvidedPhoneEntry.numberAndExtensionNumber, !existingPhoneNumbers.contains(itGlueProvidedPhoneNumber), let cnLabeledValue = itGlueProvidedPhoneEntry.cnLabeledValue else { + // Continue to the next phone number. + continue + } + + // Phone number is new, add it to the contact. + mutableContact.phoneNumbers.append(cnLabeledValue) + } + + // Populate a string array of existing email addresses. + let existingEmailAddresses = mutableContact.emailAddresses.map({ $0.value as String }) + + // For each email address provided by the IT Glue API. + for itGlueProvidedEmailEntry in itGlueContact.attributes.contactEmails { + // Ensure itGlueProvidedEmailEntry.address is not nil, ensure email address does not already exist in the contact, ensure a cnLabeledValue can be generated from that email address. + guard let itGlueProvidedEmailAddress = itGlueProvidedEmailEntry.address, !existingEmailAddresses.contains(itGlueProvidedEmailAddress), let cnLabeledValue = itGlueProvidedEmailEntry.cnLabeledValue else { + // Continue to the next email address. + continue + } + + // Email address is new, add it to the contact. + mutableContact.emailAddresses.append(cnLabeledValue) + } + + // Boolean to track if the contact already has a work postal address. + var workPostalAddressAlreadyPresent = false + + // Declare an empty array of postal addresses. + var updatedPostalAddresses = [CNLabeledValue]() + + // For each existing postal address provided by iOS Contacts. + for existingPostalAddress in mutableContact.postalAddresses { + // Check if the existing postal address label is equal to "work" (or the localized equivalent), ensure the IT Glue API provided postal address can be converted into a cnLabeledValue. + if existingPostalAddress.label == Label.work.localizedString, let workPostalAddress = itGlueContact.attributes.location?.cnLabeledValue { + // Update boolean to signify that the contact already had a work postal address. + workPostalAddressAlreadyPresent = true + + // Regardless of whether the work address changed, replace it with the IT Glue API provided postal address. + updatedPostalAddresses.append(workPostalAddress) + continue + } else { + // Existing postal address label did not equal "work" (or the localized equivalent), add non-work address. + updatedPostalAddresses.append(existingPostalAddress) + } + } + + // If there was no postal address with label equal to "work" (or the localized equivalent). + if !workPostalAddressAlreadyPresent { + // Ensure the IT Glue API provided postal address can be converted into a cnLabeledValue. + if let workPostalAddress = itGlueContact.attributes.location?.cnLabeledValue { + // Add the postal address to the array of updated postal addresses. + updatedPostalAddresses.append(workPostalAddress) + } + } + + // Update contact postal addresses. + mutableContact.postalAddresses = updatedPostalAddresses + + // If the notes field has changed. + if let notes = itGlueContact.attributes.notes, mutableContact.note != notes { + // Update notes. + mutableContact.note = notes + } + + // Ensure an IT Glue resource URL is set. Find where the existing contact is stored (CardDAV, Exchange, local) as this impacts what data can be stored. + if let resourceURL = itGlueContact.attributes.resourceURL, let contactContainer = try returnContactContainer(mutableContact) { + let cnLabeledValue = CNLabeledValue(label: "IT Glue", value: resourceURL as NSString) + + // As Exchange contacts can only store a single URL and does not support the contact type attribute. + if contactContainer.type == .exchange { + mutableContact.urlAddresses = [cnLabeledValue] + } else { + // If the contact type has changed. + if mutableContact.contactType != itGlueContact.contactType { + // Update contact type. + mutableContact.contactType = itGlueContact.contactType + } + + // Boolean to track if the contact already has an IT Glue URL set. + var itGlueURLAddressAlreadyPresent = false + + // To avoid removing non IT Glue URLs, loop through existing URLs. + mutableContact.urlAddresses = mutableContact.urlAddresses.map({ urlAddress in + // If URL label is "IT Glue". + if urlAddress.label == "IT Glue" { + // Note that the IT Glue URL is already present. + itGlueURLAddressAlreadyPresent = true + // If the resource URL has changed. + if urlAddress.value as String != resourceURL { + // Return updated value. + return cnLabeledValue + } + } + // Not an IT Glue URL, return as is. + return urlAddress + }) + + // If no IT Glue URL was found. + if !itGlueURLAddressAlreadyPresent { + // Add the IT Glue URL. + mutableContact.urlAddresses.append(cnLabeledValue) + } + } + } + + // Create a save request. + let saveRequest = CNSaveRequest() + + // Update exiting contact (do not create duplicate contacts). + saveRequest.update(mutableContact) + + do { + // Execute save request (write to disk). + try contactStore.execute(saveRequest) + } catch { + print("Error: While trying to update existing contact! \(mutableContact.familyName) \(error.localizedDescription)") + throw AppleContactsError.unableToUpdateExistingContact + } + } +} diff --git a/ITGlueContacts/ITGlueContacts/Helpers/Constants.swift b/ITGlueContacts/ITGlueContacts/Helpers/Constants.swift new file mode 100644 index 0000000..782cb6a --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Helpers/Constants.swift @@ -0,0 +1,14 @@ +// +// Constants.swift +// ITGlueContacts +// +// Created by Michael Page on 15/6/19. +// + +import Foundation + +struct Constants { + struct Notifications { + static let allContactDataUpdated = Notification(name: Notification.Name(rawValue: "AllContactDataUpdated")) + } +} diff --git a/ITGlueContacts/ITGlueContacts/Helpers/DataSource.swift b/ITGlueContacts/ITGlueContacts/Helpers/DataSource.swift new file mode 100644 index 0000000..0c9b790 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Helpers/DataSource.swift @@ -0,0 +1,172 @@ +// +// DataSource.swift +// ITGlueContacts +// +// Created by Michael Page on 15/5/19. +// + +import Foundation + +enum DataSourceError: Error { + case invalidData, networkIssue +} + +enum DataSourceTaskStatus { + case complete +} + +class DataSource { + static let shared = DataSource() + + var lastUpdateTimestamp: Date? { + didSet { + populateAllContactsDictionary() + } + } + + // Regular contacts (people). + var regularContacts = [Contact]() + // Contacts generated from an organization's location data. + var organizationLocationContacts = [Contact]() + // Both regular & organization location contacts. + var allContacts: [Contact] { + return regularContacts + organizationLocationContacts + } + + var locations = [Location]() + // Stores a dictionary of contacts under associated character index, concept similar to: ["A": ["Alice", "Anna"], "C": ["Carla"]] + var allContactsDictionary = [String: [Contact]]() + var allContactsSections: [String] { + var sorted = Array(allContactsDictionary.keys).sorted() + let numbersIndex = sorted.firstIndex(of: "#") + let numbersArray = sorted.remove(at: numbersIndex!) + sorted.append(numbersArray) + return sorted + } + + func populateAllContactsDictionary() { + let defaultIndex: [String: [Contact]] = ["A": [], "B": [], "C": [], "D": [], "E": [], "F": [], "G": [], "H": [], "I": [], "J": [], "K": [], "L": [], "M": [], "N": [], "O": [], "P": [], "Q": [], "R": [], "S": [], "T": [], "U": [], "V": [], "W": [], "X": [], "Y": [], "Z": [], "#": []] + + // Clear out existing all contacts dictionary. + allContactsDictionary = defaultIndex + + // Loop through each contact. + allContacts.forEach { contact in + var firstCharacter = String() + if contact.isAnOrganizationLocation { + // Get first character of organization name. + firstCharacter = String(contact.attributes.organizationName.prefix(1)).uppercased() + } else { + // Get first character of full name. + firstCharacter = String(contact.attributes.fullName.prefix(1)).uppercased() + } + + if firstCharacter.rangeOfCharacter(from: .letters) != nil { + // If a dictionary index already exists for that character. + if allContactsDictionary[firstCharacter] != nil { + // Add contact under existing associated character index. + allContactsDictionary[firstCharacter]?.append(contact) + } else { + // Add contact under new associated character index. + allContactsDictionary[firstCharacter] = [contact] + } + } else { + // If first character is numeric or not a letter add it to # index. + allContactsDictionary["#"]?.append(contact) + } + } + + // Sort contact arrays by name. + for (character, contacts) in allContactsDictionary { + allContactsDictionary[character] = contacts.sorted() + } + } + + // Needed for adding work addresses to contacts. + func appendLocationsToContacts() { + // Create an empty array of contacts. + var updatedContacts = [Contact]() + + // Loop through each regular contact. + for contact in regularContacts { + // Create a mutable copy of the contact. + var updatedContact = contact + + // Search for a location that matches the contact's location ID. Copy the location data from that match to the contact's location value. This is needed to set the contact's work address. + updatedContact.attributes.location = locations.first(where: { $0.id == contact.attributes.locationID }) + + // Add the updated contact to the updated contacts array. + updatedContacts.append(updatedContact) + } + + // Update the regularContacts array with contacts that may now contain work addresses. + regularContacts = updatedContacts + } + + // Function loops through organization locations and creates an organization contact for each location. + func createOrganizationLocationContacts() { + // Create an empty array of organization location contacts. + var organizationLocationContacts = [Contact]() + + // Loop through each location. + for location in locations { + // Create a contact based on that location. + let organizationLocationContact = Contact(location: location) + // Add the contact to the organization location contacts array. + organizationLocationContacts.append(organizationLocationContact) + } + + // Update the self.organizationLocationContacts with the new contacts array. + self.organizationLocationContacts = organizationLocationContacts + } + + // Function updates self.locations and self.regularContacts from IT Glue API. + func updateData(completionHandler: @escaping (Result) -> Void) { + // Get all organization locations from IT Glue API. + ITGlueAPI().getITGlueData(.locations) { result in + + switch result { + case let .success(resultTuple): + let (_, locations) = resultTuple + if locations != nil { + // Update self.locations with IT Glue provided locations. + self.locations = locations! + self.createOrganizationLocationContacts() + } + + // Get all contacts (regardless of organization) from IT Glue API. + ITGlueAPI().getITGlueData(.contacts) { result in + switch result { + case let .success(resultTuple): + let (contacts, _) = resultTuple + if contacts != nil { + // Update self.regularContacts with IT Glue provided contacts. + self.regularContacts = contacts! + self.appendLocationsToContacts() + self.lastUpdateTimestamp = Date() + } + // Notify function caller that locations and contacts have finished updating. + completionHandler(Result.success(.complete)) + case let .failure(error): + print(error.localizedDescription) + switch error { + case .invalidData: + completionHandler(.failure(.invalidData)) + case .networkIssue: + completionHandler(.failure(.networkIssue)) + } + } + } + + case let .failure(error): + print(error.localizedDescription) + switch error { + case .invalidData: + completionHandler(.failure(.invalidData)) + case .networkIssue: + completionHandler(.failure(.networkIssue)) + } + } + } + } +} diff --git a/ITGlueContacts/ITGlueContacts/Helpers/ITGlueAPI.swift b/ITGlueContacts/ITGlueContacts/Helpers/ITGlueAPI.swift new file mode 100644 index 0000000..7954af3 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Helpers/ITGlueAPI.swift @@ -0,0 +1,78 @@ +// +// ITGlueAPI.swift +// ITGlueContacts +// +// Created by Michael Page on 14/6/19. +// + +import Foundation + +enum ITGlueAPIRequest: String { + case contacts, locations +} + +enum ITGlueAPIError: Error { + case invalidData, networkIssue +} + +class ITGlueAPI { + let apiBaseURL = UserDefaults.standard.connectToEuropeanUnionEndpoint() ? "https://api.eu.itglue.com" : "https://api.itglue.com" + var apiKey = String() + + init(apiKey: String = "") { + do { + self.apiKey = try KeychainItem().read() + } catch { + print("Error: Unable to read API key from Keychain.") + } + } + + // Contact IT Glue API for all contacts/locations, from all organizations. + func getITGlueData(_ itGlueAPIRequest: ITGlueAPIRequest, completionHandler: @escaping (Result<(contacts: [Contact]?, locations: [Location]?), ITGlueAPIError>) -> Void) { + // Ensure a valid URL is created. + let apiRequestURL = apiBaseURL + "/\(itGlueAPIRequest.rawValue)?page[size]=1000" + guard let url = URL(string: apiRequestURL) else { + return + } + + // Create the request, with the API key in the header. + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + + let task = URLSession.shared.dataTask(with: request) { data, _, error in + // Ensure data is not nil and error is nil. + guard let dataResponse = data, error == nil else { + print(error?.localizedDescription ?? "Response Error") + completionHandler(.failure(.networkIssue)) + return + } + + do { + let decoder = JSONDecoder() + let resultTuple: ([Contact]?, [Location]?) + switch itGlueAPIRequest { + case .contacts: + // Decode JSON into a ContactData object. + let contactData = try decoder.decode(ContactData.self, from: dataResponse) + // Extract contacts from ContactData object. + let contacts = contactData.data + // Set the contacts portion of the result tuple. + resultTuple = (contacts: contacts, locations: nil) + case .locations: + let locationData = try decoder.decode(LocationData.self, from: dataResponse) + let locations = locationData.data + resultTuple = (contacts: nil, locations: locations) + } + // Return the contacts/locations to the function caller. + completionHandler(.success(resultTuple)) + } catch let parsingError { + // Unable to decode dataResponse. + print("Error", parsingError) + completionHandler(.failure(.invalidData)) + } + } + // Execute task. + task.resume() + } +} diff --git a/ITGlueContacts/ITGlueContacts/Helpers/KeychainItem.swift b/ITGlueContacts/ITGlueContacts/Helpers/KeychainItem.swift new file mode 100644 index 0000000..1f0b2e7 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Helpers/KeychainItem.swift @@ -0,0 +1,107 @@ +// +// KeychainItem.swift +// ITGlueContacts +// +// Created by Michael Page on 19/6/19. +// + +import Foundation + +// Keychain error cases. +enum KeychainError: Error { + case missingKeychainItem, badKeychainItemData, unknownError +} + +struct KeychainItem { + // Service identifier for storing items (secrets) in the Keychain. + let service = "ITGlueContactsService" + + // Formulate a Keychain query with required attributes. + private func keychainQuery(service: String) -> [String: AnyObject] { + var query: [String: AnyObject] = [:] + + // Set service associated with the Keychain item. + query[kSecAttrService as String] = service as AnyObject + + // Set the Keychain item class to generic password. + query[kSecClass as String] = kSecClassGenericPassword + + return query + } + + func read() throws -> String { + // Search for an existing Keychain item. + var query = keychainQuery(service: service) + + // Return only a single match. + query[kSecMatchLimit as String] = kSecMatchLimitOne + + // Include the attributes of the Keychain item. + query[kSecReturnAttributes as String] = kCFBooleanTrue + + // Include the data of the Keychain item. + query[kSecReturnData as String] = kCFBooleanTrue + + // A variable for storing the query result (Keychain item). + var queryResult: AnyObject? + + // Attempt to extract Keychain item and store the result in queryResult. + let queryStatus = SecItemCopyMatching(query as CFDictionary, &queryResult) + + // Ensure a Keychain item was found. + guard queryStatus != errSecItemNotFound else { + throw KeychainError.missingKeychainItem + } + + // Ensure query status does not contain any other error. + guard queryStatus == noErr else { + throw KeychainError.unknownError + } + + // Extract the password from query result. + guard let existingItem = queryResult as? [String: AnyObject], let keychainItemData = existingItem[kSecValueData as String] as? Data, let password = String(data: keychainItemData, encoding: .utf8) else { + // Keychain item data was malformed. + throw KeychainError.badKeychainItemData + } + + return password + } + + func write(_ password: String) throws { + // Encode the provided password. + let keychainItemData = password.data(using: .utf8) + + // Create a Keychain query. + var query = keychainQuery(service: service) + + do { + // Test if a Keychain item for this service already exists. + try _ = read() + + // A Keychain item for this service already exists. + // Set the Keychain item data. + var attributesToUpdate: [String: AnyObject] = [:] + attributesToUpdate[kSecValueData as String] = keychainItemData as AnyObject? + + // Execute the query to update an existing item in the Keychain. + let queryStatus = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) + + // Ensure query status did not contain an error. + guard queryStatus == noErr else { + throw KeychainError.unknownError + } + } catch KeychainError.missingKeychainItem { + // A Keychain item for this service does not currently exist. + // Set the Keychain item data. + query[kSecValueData as String] = keychainItemData as AnyObject? + + // Execute the query to save a new item to Keychain. + let queryStatus = SecItemAdd(query as CFDictionary, nil) + + // Ensure query status did not contain an error. + guard queryStatus == noErr else { + throw KeychainError.unknownError + } + } + } +} diff --git a/ITGlueContacts/ITGlueContacts/Helpers/RegisterTableViewCells.swift b/ITGlueContacts/ITGlueContacts/Helpers/RegisterTableViewCells.swift new file mode 100644 index 0000000..b718275 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Helpers/RegisterTableViewCells.swift @@ -0,0 +1,22 @@ +// +// RegisterTableViewCells.swift +// ITGlueContacts +// +// Created by Michael Page on 17/6/19. +// + +import UIKit + +// Required for displaying custom table view cells. +enum TableViewCellIdentifier: String { + case contactCell = "ContactCell" +} + +func registerTableViewCells(tableView: UITableView, cellIdentifiers: [TableViewCellIdentifier]) { + for cellIdentifier in cellIdentifiers { + // Load the cell nib. + let cellNib = UINib(nibName: cellIdentifier.rawValue, bundle: nil) + // Register cell nib to make dequeueReusableCell(withIdentifier) use it with associated identifier. + tableView.register(cellNib, forCellReuseIdentifier: cellIdentifier.rawValue) + } +} diff --git a/ITGlueContacts/ITGlueContacts/Models/Contact.swift b/ITGlueContacts/ITGlueContacts/Models/Contact.swift new file mode 100644 index 0000000..4c97d7e --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Models/Contact.swift @@ -0,0 +1,137 @@ +// +// Contact.swift +// ITGlueContacts +// +// Created by Michael Page on 15/5/19. +// + +import Contacts +import Foundation + +struct Contact: Codable { + let id: String + var attributes: ContactAttributes +} + +extension Contact { + var isAnOrganizationLocation: Bool { + // If contact has neither a first or last name, it is an organization. + return attributes.firstName == nil && attributes.lastName == nil + } + + var contactType: CNContactType { + return isAnOrganizationLocation ? .organization : .person + } +} + +extension Contact: Comparable { + // Required for contact sorting. + static func < (lhs: Contact, rhs: Contact) -> Bool { + let lhsName = lhs.isAnOrganizationLocation ? lhs.attributes.organizationName : lhs.attributes.fullName + let rhsName = rhs.isAnOrganizationLocation ? rhs.attributes.organizationName : rhs.attributes.fullName + // Lowercased to allow for fair comparison of names with capitalization. + return lhsName.lowercased() < rhsName.lowercased() + } + + static func == (lhs: Contact, rhs: Contact) -> Bool { + let lhsName = lhs.isAnOrganizationLocation ? lhs.attributes.organizationName : lhs.attributes.fullName + let rhsName = rhs.isAnOrganizationLocation ? rhs.attributes.organizationName : rhs.attributes.fullName + return lhsName == rhsName + } +} + +extension Contact { + var cnContactValue: CNContact { + // Create a mutable contact. + let contact = CNMutableContact() + + // Set contact attributes. + contact.givenName = attributes.firstName ?? "" + contact.familyName = attributes.lastName ?? "" + contact.jobTitle = attributes.jobTitle ?? "" + contact.organizationName = attributes.organizationName + + // For each phone number. + for phone in attributes.contactPhones { + // Ensure phone number is not nil. + guard let cnLabeledValue = phone.cnLabeledValue else { + continue + } + // Add phone number to contact. + contact.phoneNumbers.append(cnLabeledValue) + } + + // For each email address. + for email in attributes.contactEmails { + // Ensure email address is not nil. + guard let cnLabeledValue = email.cnLabeledValue else { + continue + } + // Add email address to contact. + contact.emailAddresses.append(cnLabeledValue) + } + + // Set work address. + if let postalAddress = attributes.location?.cnLabeledValue { + contact.postalAddresses = [postalAddress] + } + + contact.note = attributes.notes ?? "" + + // Set IT Glue rosource URL. + if let resourceURL = attributes.resourceURL { + let cnLabeledValue = CNLabeledValue(label: "IT Glue", value: resourceURL as NSString) + contact.urlAddresses = [cnLabeledValue] + } + + // Return contact. + return contact.copy() as! CNContact + } + + // Initializer for creating a contact object from an existing iOS contact. + init(contact: CNContact) { + id = contact.identifier + + var contactEmails = [Email]() + for contactEmailAddress in contact.emailAddresses { + let email = Email(address: contactEmailAddress.value as String, label: contactEmailAddress.label) + contactEmails.append(email) + } + + var contactPhones = [Phone]() + for contactPhone in contact.phoneNumbers { + // Extension numbers are stored after a phone number and are seperated by a comma. + let phoneNumberComponents = contactPhone.value.stringValue.components(separatedBy: ",") + guard let phoneNumber = phoneNumberComponents.first else { + continue + } + let phone = Phone(number: phoneNumber, extensionNumber: phoneNumberComponents[1], label: contactPhone.label) + contactPhones.append(phone) + } + + var resourceURL: String? + if let firstURL = contact.urlAddresses.first?.value as String? { + resourceURL = firstURL + } + + attributes = ContactAttributes(organizationName: contact.organizationName, jobTitle: contact.jobTitle, firstName: contact.givenName, lastName: contact.familyName, contactEmails: contactEmails, contactPhones: contactPhones, notes: contact.note, locationID: nil, location: nil, resourceURL: resourceURL) + } + + // Initializer for creating a contact object from an organization's location (e.g. "Tesla - Gigafactory"). + init(location: Location) { + id = location.id + + var contactPhones = [Phone]() + + if let phoneNumber = location.attributes.phone { + let phone = Phone(number: phoneNumber, extensionNumber: nil, label: Label.main.rawValue) + contactPhones.append(phone) + } + + // Sets orgnization location contacts name to "Tesla - Gigafactory", with the fallback to "Tesla". + let name = location.attributes.organizationNameAndLocationName ?? location.attributes.organizationName + + // Set attributes. + attributes = ContactAttributes(organizationName: name, jobTitle: nil, firstName: nil, lastName: nil, contactEmails: [], contactPhones: contactPhones, notes: location.attributes.notes, locationID: location.id, location: location, resourceURL: location.attributes.resourceURL) + } +} diff --git a/ITGlueContacts/ITGlueContacts/Models/ContactAttributes.swift b/ITGlueContacts/ITGlueContacts/Models/ContactAttributes.swift new file mode 100644 index 0000000..dfeaee1 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Models/ContactAttributes.swift @@ -0,0 +1,68 @@ +// +// ContactAttributes.swift +// ITGlueContacts +// +// Created by Michael Page on 15/5/19. +// + +import Foundation + +struct ContactAttributes: Codable { + var organizationName: String + var jobTitle: String? + var firstName: String? + var lastName: String? + var fullName: String { + return [firstName, lastName].compactMap { $0 }.joined(separator: " ") + } + + var contactEmails: [Email] + var contactPhones: [Phone] + var notes: String? + private var locationIDInt: Int? + var locationID: String? { + get { + if let locationIDInt = locationIDInt { + return String(locationIDInt) + } + return nil + } + set { + if let newValue = newValue { + locationIDInt = Int(newValue) + } else { + locationIDInt = nil + } + } + } + + var location: Location? + var resourceURL: String? + var organizationNameAndLocationName: String? + + private enum CodingKeys: String, CodingKey { + case organizationName = "organization-name" + case jobTitle = "title" + case firstName = "first-name" + case lastName = "last-name" + case contactEmails = "contact-emails" + case contactPhones = "contact-phones" + case notes + case locationIDInt = "location-id" + case location + case resourceURL = "resource-url" + } + + init(organizationName: String, jobTitle: String?, firstName: String?, lastName: String?, contactEmails: [Email], contactPhones: [Phone], notes: String?, locationID: String?, location: Location?, resourceURL: String?) { + self.organizationName = organizationName + self.jobTitle = jobTitle + self.firstName = firstName + self.lastName = lastName + self.contactEmails = contactEmails + self.contactPhones = contactPhones + self.notes = notes + self.locationID = locationID + self.location = location + self.resourceURL = resourceURL + } +} diff --git a/ITGlueContacts/ITGlueContacts/Models/ContactData.swift b/ITGlueContacts/ITGlueContacts/Models/ContactData.swift new file mode 100644 index 0000000..b7f33d6 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Models/ContactData.swift @@ -0,0 +1,12 @@ +// +// ContactData.swift +// ITGlueContacts +// +// Created by Michael Page on 15/5/19. +// + +import Foundation + +struct ContactData: Codable { + let data: [Contact] +} diff --git a/ITGlueContacts/ITGlueContacts/Models/Email.swift b/ITGlueContacts/ITGlueContacts/Models/Email.swift new file mode 100644 index 0000000..c62b865 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Models/Email.swift @@ -0,0 +1,34 @@ +// +// Email.swift +// ITGlueContacts +// +// Created by Michael Page on 15/5/19. +// + +import Contacts +import Foundation + +struct Email: Codable { + let address: String? + let label: Label + var cnLabeledValue: CNLabeledValue? { + guard let address = address else { + return nil + } + return CNLabeledValue(label: label.localizedString, value: address as NSString) + } + + private enum CodingKeys: String, CodingKey { + case address = "value" + case label = "label-name" + } + + init(address: String, label: String?) { + self.address = address + if let label = label { + self.label = Label(rawValue: label) ?? Label.other + } else { + self.label = Label.other + } + } +} diff --git a/ITGlueContacts/ITGlueContacts/Models/Label.swift b/ITGlueContacts/ITGlueContacts/Models/Label.swift new file mode 100644 index 0000000..67c9f3c --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Models/Label.swift @@ -0,0 +1,34 @@ +// +// Label.swift +// ITGlueContacts +// +// Created by Michael Page on 15/5/19. +// + +import Contacts +import Foundation + +enum Label: String, Codable { + case work = "Work" + case home = "Home" + case mobile = "Mobile" + case fax = "Fax" + case main = "Main" + case other = "Other" + var localizedString: String { + switch self { + case .work: + return CNLabelWork + case .home: + return CNLabelHome + case .mobile: + return CNLabelPhoneNumberMobile + case .fax: + return CNLabelPhoneNumberWorkFax + case .main: + return CNLabelPhoneNumberMain + case .other: + return CNLabelOther + } + } +} diff --git a/ITGlueContacts/ITGlueContacts/Models/Location.swift b/ITGlueContacts/ITGlueContacts/Models/Location.swift new file mode 100644 index 0000000..03bdc49 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Models/Location.swift @@ -0,0 +1,24 @@ +// +// Location.swift +// ITGlueContacts +// +// Created by Michael Page on 15/5/19. +// + +import Contacts +import Foundation + +struct Location: Codable { + let id: String + var attributes: LocationAttributes + var cnLabeledValue: CNLabeledValue { + let address = CNMutablePostalAddress() + address.street = [attributes.address1, attributes.address2].compactMap { $0 }.joined(separator: ", ") + address.city = attributes.city ?? "" + address.state = attributes.region ?? "" + address.postalCode = attributes.postalCode ?? "" + address.country = attributes.country ?? "" + + return CNLabeledValue(label: CNLabelWork, value: address) + } +} diff --git a/ITGlueContacts/ITGlueContacts/Models/LocationAttributes.swift b/ITGlueContacts/ITGlueContacts/Models/LocationAttributes.swift new file mode 100644 index 0000000..ee26aff --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Models/LocationAttributes.swift @@ -0,0 +1,62 @@ +// +// LocationAttributes.swift +// ITGlueContacts +// +// Created by Michael Page on 15/5/19. +// + +import Foundation + +struct LocationAttributes: Codable { + var id: Int + var organizationName: String + var organizationLocationName: String? + var organizationNameAndLocationName: String? { + // Ensure organization location name is set. + guard let organizationLocationName = organizationLocationName else { + return nil + } + // Append location to end of organization name (e.g. "Tesla - Gigafactory"). + return "\(organizationName) - \(organizationLocationName)" + } + + var address1: String? + var address2: String? + var city: String? + var region: String? + var postalCode: String? + var country: String? + var phone: String? + var notes: String? + var resourceURL: String? + + private enum CodingKeys: String, CodingKey { + case id = "organization-id" + case organizationName = "organization-name" + case organizationLocationName = "name" + case address1 = "address-1" + case address2 = "address-2" + case city + case region = "region-name" + case postalCode = "postal-code" + case country = "country-name" + case phone + case notes + case resourceURL = "resource-url" + } + + init(id: Int, organizationName: String, organizationLocationName: String?, address1: String?, address2: String?, city: String?, region: String?, postalCode: String?, country: String?, phone: String?, notes: String?, resourceURL: String?) { + self.id = id + self.organizationName = organizationName + self.organizationLocationName = organizationLocationName + self.address1 = address1 + self.address2 = address2 + self.city = city + self.region = region + self.postalCode = postalCode + self.country = country + self.phone = phone + self.notes = notes + self.resourceURL = resourceURL + } +} diff --git a/ITGlueContacts/ITGlueContacts/Models/LocationData.swift b/ITGlueContacts/ITGlueContacts/Models/LocationData.swift new file mode 100644 index 0000000..c98d7db --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Models/LocationData.swift @@ -0,0 +1,12 @@ +// +// LocationData.swift +// ITGlueContacts +// +// Created by Michael Page on 15/5/19. +// + +import Foundation + +struct LocationData: Codable { + let data: [Location] +} diff --git a/ITGlueContacts/ITGlueContacts/Models/Phone.swift b/ITGlueContacts/ITGlueContacts/Models/Phone.swift new file mode 100644 index 0000000..a8ca57e --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Models/Phone.swift @@ -0,0 +1,48 @@ +// +// Phone.swift +// ITGlueContacts +// +// Created by Michael Page on 15/5/19. +// + +import Contacts +import Foundation + +struct Phone: Codable { + let number: String? + let extensionNumber: String? + var numberAndExtensionNumber: String? { + guard let number = number else { + return nil + } + if let extensionNumber = extensionNumber { + return "\(number),\(extensionNumber)" + } else { + return number + } + } + + let label: Label + var cnLabeledValue: CNLabeledValue? { + guard let numberAndExtensionNumber = numberAndExtensionNumber else { + return nil + } + return CNLabeledValue(label: label.localizedString, value: CNPhoneNumber(stringValue: numberAndExtensionNumber)) + } + + private enum CodingKeys: String, CodingKey { + case number = "value" + case extensionNumber = "extension" + case label = "label-name" + } + + init(number: String, extensionNumber: String?, label: String?) { + self.number = number + self.extensionNumber = extensionNumber + if let label = label { + self.label = Label(rawValue: label) ?? Label.other + } else { + self.label = Label.other + } + } +} diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f3496d1 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,110 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "IT Glue Contacts Icon-20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "IT Glue Contacts Icon-20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "IT Glue Contacts Icon-29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "IT Glue Contacts Icon-29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "IT Glue Contacts Icon-40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "IT Glue Contacts Icon-40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "IT Glue Contacts Icon-60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "IT Glue Contacts Icon-60@3x.png", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "IT Glue Contacts Icon-76.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "IT Glue Contacts Icon-76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "IT Glue Contacts Icon-83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "IT Glue Contacts Icon-1024.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-1024.png b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-1024.png new file mode 100644 index 0000000..75f076f Binary files /dev/null and b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-1024.png differ diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-20@2x.png b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-20@2x.png new file mode 100644 index 0000000..005e582 Binary files /dev/null and b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-20@2x.png differ diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-20@3x.png b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-20@3x.png new file mode 100644 index 0000000..022908b Binary files /dev/null and b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-20@3x.png differ diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-29@2x.png b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-29@2x.png new file mode 100644 index 0000000..1089f69 Binary files /dev/null and b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-29@2x.png differ diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-29@3x.png b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-29@3x.png new file mode 100644 index 0000000..305b43f Binary files /dev/null and b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-29@3x.png differ diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-40@2x.png b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-40@2x.png new file mode 100644 index 0000000..6d26dfb Binary files /dev/null and b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-40@2x.png differ diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-40@3x.png b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-40@3x.png new file mode 100644 index 0000000..c9a2f6f Binary files /dev/null and b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-40@3x.png differ diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-60@2x.png b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-60@2x.png new file mode 100644 index 0000000..c9a2f6f Binary files /dev/null and b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-60@2x.png differ diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-60@3x.png b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-60@3x.png new file mode 100644 index 0000000..2384599 Binary files /dev/null and b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-60@3x.png differ diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-76.png b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-76.png new file mode 100644 index 0000000..40b550d Binary files /dev/null and b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-76.png differ diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-76@2x.png b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-76@2x.png new file mode 100644 index 0000000..8f7630f Binary files /dev/null and b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-76@2x.png differ diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-83.5@2x.png b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-83.5@2x.png new file mode 100644 index 0000000..050aba5 Binary files /dev/null and b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/AppIcon.appiconset/IT Glue Contacts Icon-83.5@2x.png differ diff --git a/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/Contents.json b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ITGlueContacts/ITGlueContacts/Resources/Base.lproj/LaunchScreen.storyboard b/ITGlueContacts/ITGlueContacts/Resources/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..bfa3612 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Resources/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ITGlueContacts/ITGlueContacts/Resources/Base.lproj/Main.storyboard b/ITGlueContacts/ITGlueContacts/Resources/Base.lproj/Main.storyboard new file mode 100644 index 0000000..c6569af --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Resources/Base.lproj/Main.storyboard @@ -0,0 +1,327 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Import is one-way, from IT Glue Contacts into Apple Contacts. Existing contacts with matching names will have additional IT Glue information added. New contacts are added to the specified account in: Settings > Contacts > Default Account. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ITGlueContacts/ITGlueContacts/Resources/Info.plist b/ITGlueContacts/ITGlueContacts/Resources/Info.plist new file mode 100644 index 0000000..cbfb419 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Resources/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + IT Glue Contacts + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSContactsUsageDescription + Access to Contacts is needed to add and update existing contacts. + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ITGlueContacts/ITGlueContacts/ViewControllers/ContactsViewController.swift b/ITGlueContacts/ITGlueContacts/ViewControllers/ContactsViewController.swift new file mode 100644 index 0000000..a563f86 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/ViewControllers/ContactsViewController.swift @@ -0,0 +1,222 @@ +// +// ContactsViewController.swift +// ITGlueContacts +// +// Created by Michael Page on 15/6/19. +// + +import ContactsUI +import UIKit + +class ContactsViewController: UIViewController { + @IBOutlet var tableView: UITableView! + @IBOutlet var settingsButton: UIBarButtonItem! + + // Overlaid search results table view. + private var searchResultsViewController: SearchResultsViewController! + + // Search controller needed for filtering. + private var searchController: UISearchController! + + // An array of filtered search results. + private var searchResults = [Contact]() + + // An array of unique starting characters of each contact's name. Used to define table sections. + private var tableSections = [String]() + + // A dictionary containing all contacts, keys are the same unique starting characters from tableSections. + private var contactsDictionary = [String: [Contact]]() + + override func viewDidLoad() { + super.viewDidLoad() + + prepareSearchController() + + prepareNavigationController() + + registerTableViewCells(tableView: tableView, cellIdentifiers: [.contactCell]) + + // Add observer that is triggered when new data has loaded. + NotificationCenter.default.addObserver(self, selector: #selector(allContactDataUpdated(_:)), name: Constants.Notifications.allContactDataUpdated.name, object: nil) + + // Fetch contacts. + fetchContactData() + } + + // Triggered when new data has loaded. + @objc private func allContactDataUpdated(_ notification: Notification) { + tableSections = DataSource.shared.allContactsSections + contactsDictionary = DataSource.shared.allContactsDictionary + + // Reload the table view on the main thread. + DispatchQueue.main.async { + self.tableView.reloadData() + } + } + + private func fetchContactData() { + // Get the latest contact data. + DataSource.shared.updateData { result in + var title = String() + var message = String() + + switch result { + case .success: + // Trigger a reload of the main table view. + NotificationCenter.default.post(Constants.Notifications.allContactDataUpdated) + case let .failure(error): + print(error.localizedDescription) + title = "Error Updating Contacts" + switch error { + case .invalidData: + message = "Failed to obtain valid data from IT Glue. Please check IT Glue API key." + case .networkIssue: + message = "Unable to communicate with IT Glue, please check your network connection." + } + self.alert(title: title, message: message) + } + } + } + + private func prepareNavigationController() { + // Allows the navigation bar title ("IT Glue Contacts") to be displayed in large text at the top of the table view. + navigationController?.navigationBar.prefersLargeTitles = true + // Set back button to "Contacts". + let backBarButtonItem = UIBarButtonItem() + backBarButtonItem.title = "Contacts" + navigationItem.backBarButtonItem = backBarButtonItem + } + + private func prepareSearchController() { + // Initialize SearchResultsViewController. + searchResultsViewController = SearchResultsViewController() + // Make ContactsViewController (self) the delegate of searchResultsViewController's table view. + // This causes searchResultsViewController's table view to run ContactsViewController's didSelectRowAt method. + searchResultsViewController.tableView.delegate = self + + // Initialize UISearchController and set searchResultsViewController as the results view controller. + searchController = UISearchController(searchResultsController: searchResultsViewController) + // Make ContactsViewController (self) the delegate of searchController's searchResultsUpdater. + // As ContactsViewController is a subclass of UISearchResultsUpdating, it is notified when the updateSearchResults is called. + searchController.searchResultsUpdater = self + // Stop auto capitalization of search bar input. + searchController.searchBar.autocapitalizationType = .none + + // Add search to the navigation controller. + navigationItem.searchController = searchController + navigationItem.hidesSearchBarWhenScrolling = false + + // Required to present searchResultsViewController correctly on top of ContactsViewController. + definesPresentationContext = true + } +} + +extension ContactsViewController: UISearchResultsUpdating { + func updateSearchResults(for searchController: UISearchController) { + // Clear out any previous search results. + searchResults.removeAll() + + // Ensure search bar text is set. + guard var searchBarText = searchController.searchBar.text else { + return + } + + // Remove any leading and trailing whitespace. + searchBarText = searchBarText.trimmingCharacters(in: .whitespacesAndNewlines) + + // After removing leading and trailing whitespace, ensure search bar text is not empty. + if !searchBarText.isEmpty { + // Filter through all contacts to generate search results. + searchResults = DataSource.shared.allContacts.filter({ (contact) -> Bool in + let contactName = contact.attributes.fullName + let contactOrganizationName = contact.attributes.organizationName + + // Split search text into an array of items (probably words). + let searchItems = searchBarText.components(separatedBy: " ") + + var matchFound = false + + // Loop through each search item. + for searchItem in searchItems { + // Sets matchFound to true if the search item is found in the contact's name or organization name. + matchFound = contactName.range(of: searchItem, options: [.caseInsensitive]) != nil || contactOrganizationName.range(of: searchItem, options: [.caseInsensitive]) != nil + // As soon as a match is found end the loop. + if matchFound { + break + } + } + + return matchFound + }) + } + + if let resultsController = searchController.searchResultsController as? SearchResultsViewController { + // Update the results controller with the search results. + resultsController.searchResults = searchResults + resultsController.tableView.reloadData() + } + } +} + +extension ContactsViewController: UITableViewDelegate, UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + // Creates a table section for each unique starting character. + return tableSections.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + // Create a new cell. + let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCellIdentifier.contactCell.rawValue, for: indexPath) as! ContactCell + // Get the current section character. + let sectionCharacter = tableSections[indexPath.section] + // Get contacts under that section character. + let sectionContacts = contactsDictionary[sectionCharacter] ?? [] + // Set the contact for the current section row. + let contact = sectionContacts[indexPath.row] + cell.configure(for: contact) + return cell + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + // The current section character. + let sectionCharacter = tableSections[section] + // Contacts under section character. + let sectionContacts = contactsDictionary[sectionCharacter] ?? [] + // Return the number of contacts under that section character. + return sectionContacts.count + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + // Creates a header for each section (character). + return tableSections[section] + } + + func sectionIndexTitles(for tableView: UITableView) -> [String]? { + // Display an index list on the right of the table view. + return tableSections + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let selectedContact: Contact? + + if tableView == self.tableView { + // The current section character. + let sectionCharacter = tableSections[indexPath.section] + // Contacts under section character. + let sectionContacts = contactsDictionary[sectionCharacter] ?? [] + // Selected contact. + selectedContact = sectionContacts[indexPath.row] + } else { + selectedContact = searchResults[indexPath.row] + } + + if let selectedContact = selectedContact { + // Create a standard CNContactViewController to display the contact data. + let contactViewContoller = CNContactViewController(forUnknownContact: selectedContact.cnContactValue) + navigationController?.pushViewController(contactViewContoller, animated: true) + } + + // Deselect row on tap. + tableView.deselectRow(at: indexPath, animated: true) + } +} diff --git a/ITGlueContacts/ITGlueContacts/ViewControllers/SearchResultsViewController.swift b/ITGlueContacts/ITGlueContacts/ViewControllers/SearchResultsViewController.swift new file mode 100644 index 0000000..6135389 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/ViewControllers/SearchResultsViewController.swift @@ -0,0 +1,31 @@ +// +// SearchResultsViewController.swift +// ITGlueContacts +// +// Created by Michael Page on 17/6/19. +// + +import ContactsUI +import UIKit + +class SearchResultsViewController: UITableViewController { + var searchResults = [Contact]() + + override func viewDidLoad() { + super.viewDidLoad() + + // Register contact cell. + registerTableViewCells(tableView: tableView, cellIdentifiers: [.contactCell]) + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return searchResults.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCellIdentifier.contactCell.rawValue, for: indexPath) as! ContactCell + let contact = searchResults[indexPath.row] + cell.configure(for: contact) + return cell + } +} diff --git a/ITGlueContacts/ITGlueContacts/ViewControllers/SettingsViewController.swift b/ITGlueContacts/ITGlueContacts/ViewControllers/SettingsViewController.swift new file mode 100644 index 0000000..8668508 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/ViewControllers/SettingsViewController.swift @@ -0,0 +1,146 @@ +// +// SettingsViewController.swift +// ITGlueContacts +// +// Created by Michael Page on 19/6/19. +// + +import UIKit + +class SettingsViewController: UITableViewController { + @IBAction func didTapUpdateContactsNowButton(_ sender: Any) { + fetchContactData() + } + + @IBAction func didTapUpdateITGlueAPIKeyButton(_ sender: Any) { + presentSetITGlueAPIKeyAlert() + } + + @IBAction func didTapImportITGlueContactsIntoAppleContactsButton(_ sender: Any) { + presentImportITGlueContactsAlert() + } + + @IBOutlet var connectToEuropeanUnionEndpointSwitch: UISwitch! + @IBAction func didToggleConnectToEuropeanUnionEndpointSwitch(_ sender: UISwitch) { + UserDefaults.standard.setConnectToEuropeanUnionEndpoint(sender.isOn) + } + + override func viewDidLoad() { + super.viewDidLoad() + + // Update the connectToEuropeanUnionEndpointSwitch to the current setting. + connectToEuropeanUnionEndpointSwitch.isOn = UserDefaults.standard.connectToEuropeanUnionEndpoint() + } + + private func setITGlueAPIKey(_ apiKey: String) { + do { + try KeychainItem().write(apiKey) + } catch { + let title = "Keychain Error" + let message = "Unknown error while attempting to store the IT Glue API key." + alert(title: title, message: message) + } + } + + private func presentSetITGlueAPIKeyAlert() { + let alert = UIAlertController(title: "IT Glue API Key", message: "Please paste in your IT Glue API key:", preferredStyle: .alert) + alert.addTextField { textField in + textField.placeholder = "IT Glue API key" + } + let saveAction = UIAlertAction(title: "Save", style: .default) { _ in + if let userInput = alert.textFields?.first?.text { + self.setITGlueAPIKey(userInput) + } + } + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) + alert.addAction(saveAction) + alert.addAction(cancelAction) + present(alert, animated: true) + } + + private func presentImportITGlueContactsAlert() { + if #available(iOS 13, *) { + self.alert(title: "Unsupported iOS Version", message: "A bug in iOS 13 prevents this feature from functioning correctly. The issue has been reported to Apple.") + return + } + + let alert = UIAlertController(title: "Import IT Glue Contacts", message: "Apple contacts with matching names will have their job title, organization name, work postal address and notes replaced with the respective IT Glue data. Existing phone numbers and email addresses are not removed. Would you like to proceed?", preferredStyle: .alert) + let importAction = UIAlertAction(title: "Import", style: .destructive) { _ in + do { + try AppleContacts().importAllITGlueContacts { result in + switch result { + case .success: + print("Success: Completed importing all locally cached IT Glue Contacts into Apple Contacts.") + self.alert(title: "Import Complete", message: nil) + case let .failure(appleContactsError): + let title = "Failed to Import Contacts" + switch appleContactsError { + case .missingRequiredGroup, .missingRequiredContainer, .unableToAddGroup: + self.alert(title: title, message: "Please create an \"IT Glue\" contacts group, with the Contacts Mac app and try again.") + case .unsupportedContactsContainer: + self.alert(title: title, message: "Please ensure an iCloud, CardDAV or Exchange account is set under: Settings > Contacts > Default Account.") + case .unableToIdentifyContainer: + self.alert(title: title, message: "Multiple \"IT Glue\" contact groups found. There should only be one, please remove duplicate groups.") + case .unableToSearchContacts: + self.alert(title: title, message: "Unable to access contacts, please enable contact access in Settings > IT Glue Contacts") + default: + self.alert(title: title, message: "Error: \(appleContactsError)") + } + } + } + } catch { + self.alert(title: "Error", message: "Unable to import contacts!") + } + } + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) + alert.addAction(importAction) + alert.addAction(cancelAction) + present(alert, animated: true) + } + + private func fetchContactData() { + // Get the latest contact data. + DataSource.shared.updateData { result in + var title = String() + var message = String() + + switch result { + case .success: + title = "Contact Update Complete" + // Trigger a reload of the main table view. + NotificationCenter.default.post(Constants.Notifications.allContactDataUpdated) + case let .failure(error): + print(error.localizedDescription) + title = "Error Updating Contacts" + switch error { + case .invalidData: + message = "Failed to obtain valid data from IT Glue. Please check IT Glue API key." + case .networkIssue: + message = "Unable to communicate with IT Glue, please check your network connection." + } + } + + self.alert(title: title, message: message) + DispatchQueue.main.async { + // Reload the table to update the footer last sync timestamp. + self.tableView.reloadData() + } + } + } +} + +extension SettingsViewController { + // Override will display footer view to update footer of first cell with last sync timestamp. + override func tableView(_ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int) { + if section == 0 { + let footer = view as! UITableViewHeaderFooterView + let footerText = "Last sync: \(DataSource.shared.lastUpdateTimestamp?.currentTimeZoneDateString() ?? "Never")" + footer.textLabel?.text = footerText + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + // Deselect row on tap. + tableView.deselectRow(at: indexPath, animated: true) + } +} diff --git a/ITGlueContacts/ITGlueContacts/ViewControllers/WelcomeViewController.swift b/ITGlueContacts/ITGlueContacts/ViewControllers/WelcomeViewController.swift new file mode 100644 index 0000000..8bb2487 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/ViewControllers/WelcomeViewController.swift @@ -0,0 +1,42 @@ +// +// WelcomeViewController.swift +// ITGlueContacts +// +// Created by Michael Page on 25/6/19. +// + +import UIKit + +class WelcomeViewController: UIViewController { + @IBOutlet var itGlueAPIKeyTextField: UITextField! + @IBOutlet var continueButton: UIButton! + @IBAction func editingChangedITGlueAPIKeyTextField(_ textField: UITextField) { + guard let apiKey = textField.text else { + return + } + + if savedITGlueAPIKey(apiKey: apiKey) { + continueButton.isEnabled = true + UserDefaults.standard.setDisplayedAppIntro(true) + } else { + continueButton.isEnabled = false + } + } + + override func viewDidLoad() { + super.viewDidLoad() + // To stop keyboard appearing, when attempting to paste in IT Glue API key. + itGlueAPIKeyTextField.inputView = UIView() + } + + func savedITGlueAPIKey(apiKey: String) -> Bool { + do { + // First write the API key to Keychain + try KeychainItem().write(apiKey) + } catch { + alert(title: "Keychain Error", message: "Unknown error while attempting to store the IT Glue API key.") + return false + } + return true + } +} diff --git a/ITGlueContacts/ITGlueContacts/Views/ContactCell.swift b/ITGlueContacts/ITGlueContacts/Views/ContactCell.swift new file mode 100644 index 0000000..de128f8 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Views/ContactCell.swift @@ -0,0 +1,47 @@ +// +// ContactCell.swift +// ITGlueContacts +// +// Created by Michael Page on 15/6/19. +// + +import UIKit + +class ContactCell: UITableViewCell { + @IBOutlet var contactTitleLabel: UILabel! + @IBOutlet var contactSubtitleLabel: UILabel! + + func configure(for contact: Contact) { + var contactTitle = String() + var contactSubtitle = String() + // Range of text to make bold (just last name or entire organization name). + var boldStringRange = NSRange() + + if contact.isAnOrganizationLocation { + // Set contact title to organization and location name. + contactTitle = contact.attributes.location?.attributes.organizationNameAndLocationName ?? contact.attributes.organizationName + // Bold organization name. + boldStringRange = contact.attributes.organizationName.fullRange() + } else { + // Set contact title to full name. + contactTitle = contact.attributes.fullName + // Get length of last name. + let lastNameLength = contact.attributes.lastName?.count ?? 0 + // Get length of full name. + let fullNameLength = contact.attributes.fullName.count + // Only bold the last name. + boldStringRange = NSMakeRange((fullNameLength - lastNameLength), lastNameLength) + // Set contact subtitle to organization and location name. + contactSubtitle = contact.attributes.location?.attributes.organizationNameAndLocationName ?? contact.attributes.organizationName + } + + let attributedString = NSMutableAttributedString(string: contactTitle) + let attributes = [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 17)] + attributedString.setAttributes(attributes, range: boldStringRange) + + // "Contact Name" or "Organization Name - Location Name" + contactTitleLabel.attributedText = attributedString + // "Organization Name - Location Name" or do not display + contactSubtitleLabel.text = contactSubtitle + } +} diff --git a/ITGlueContacts/ITGlueContacts/Views/ContactCell.xib b/ITGlueContacts/ITGlueContacts/Views/ContactCell.xib new file mode 100644 index 0000000..9f4b5f8 --- /dev/null +++ b/ITGlueContacts/ITGlueContacts/Views/ContactCell.xib @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 830936c..614538f 100644 --- a/README.md +++ b/README.md @@ -1 +1,55 @@ -# api-contest +

+
+ IT Glue Contacts +
+ IT Glue Contacts +
+

+

IT Glue Contacts saves time by streamlining access to client contact details.

+ +

The IT Glue Contacts iPhone app retrieves contacts from IT Glue, collates them with additional organization information and presents the data in the form of an intuitive Apple Contacts design for ease of use.

+ +

Prior to this app, looking up contact information had to be done through the IT Glue website or iPhone app and involved navigating through many search results to find the desired contact details.

+ +

In order to have immediate access to contact details in the Apple Contacts app, contact information needed to be entered manually as it cannot be directly imported from the IT Glue iPhone app into Apple Contacts. As client details are updated in IT Glue, the static information added to Apple Contacts would become out-of-date and be missing information. The IT Glue Contacts app eliminates the tedious task of keeping Apple Contacts up-to-date and removes the need to manually create individual contact entries.

+ +## Demo +

+ Searching For & Viewing Contacts
+ Searcing for contacts + Example contact
+

+ +## Key Features + +* 100% modern Swift code with no third-party dependencies (no external code) +* Designed to look and feel like Apple’s Contacts app + - Easy to use search + - Tap to directly message, call and email contacts + - Tap postal addresses to get directions + - Link to directly access a contact on the IT Glue website + - Ability to import individual contacts into Apple Contacts as well as share them via iMessage, AirDrop, and email + - Supports IT Glue phone numbers with extensions +* Ability to bulk import all IT Glue contacts into Apple contacts + - Adds additional information to existing contacts + - Importing into Apple contacts allows incoming calls and messages to be identified +* Generates contact entries for organization locations, with a location's phone number, address and notes +* Employees assigned a location in IT Glue have that location as their work address in IT Glue Contacts +* Changes made in IT Glue are updated in IT Glue Contacts + - App data is read-only and does not modify data in IT Glue +* Allows staff to keep personal contacts and business IT Glue contacts separate + - Bulk contacts imported into Apple Contacts are added to an "IT Glue" group, that can be hidden +* European IT Glue users are able to use the EU IT Glue API endpoint +* The IT Glue API key is stored in the Keychain + +## Installation + +1. Download the project and open it in [Xcode](https://apps.apple.com/us/app/id497799835). The app target can be set to the simulator or [a real iPhone](https://www.twilio.com/blog/2018/07/how-to-test-your-ios-application-on-a-real-device.html). Tip: Once the app has been installed on a real iPhone it will continue to work untethered. + +2. The first time IT Glue Contacts is opened a valid IT Glue API key is requested. The keyboard has been intentionally disabled on this screen as the API key should be pasted in, not typed. If you are using the simulator, copy the API key to your Mac's clipboard > select the simulator > Edit > Send Pasteboard > tap the white field under Step 3 and paste. On a real iPhone the [Universal Clipboard](https://support.apple.com/kb/ph25168?locale=en_US) is the easiest way to share your Mac's clipboard with your iPhone. Alternatively the API key can be transfered in a note or via another app.
+
+ +3. Tapping Settings in the app displays the following; the timestamp of the most recent sync, the option for European users to use the EU IT Glue endpoint, update the IT Glue API key in use and import all IT Glue contacts into Apple Contacts.
+
+ + diff --git a/READMEMEDIA/APIKey.png b/READMEMEDIA/APIKey.png new file mode 100644 index 0000000..1222842 Binary files /dev/null and b/READMEMEDIA/APIKey.png differ diff --git a/READMEMEDIA/ContactView.gif b/READMEMEDIA/ContactView.gif new file mode 100644 index 0000000..7a3d6a1 Binary files /dev/null and b/READMEMEDIA/ContactView.gif differ diff --git a/READMEMEDIA/RoundedAppIcon.png b/READMEMEDIA/RoundedAppIcon.png new file mode 100644 index 0000000..c3a2ed5 Binary files /dev/null and b/READMEMEDIA/RoundedAppIcon.png differ diff --git a/READMEMEDIA/Search.gif b/READMEMEDIA/Search.gif new file mode 100644 index 0000000..1a96386 Binary files /dev/null and b/READMEMEDIA/Search.gif differ diff --git a/READMEMEDIA/Settings.png b/READMEMEDIA/Settings.png new file mode 100644 index 0000000..34593f3 Binary files /dev/null and b/READMEMEDIA/Settings.png differ