Compare commits

...

No commits in common. "d9ca737896be1ab442d8a49ae330dc5fe3a0ee10" and "2ca66371232cb78474ddfa359a7cb424130aa723" have entirely different histories.

29 changed files with 2 additions and 1909 deletions

70
.gitignore vendored
View File

@ -1,70 +0,0 @@
# Build artifacts
build/
DerivedData/
*.xcworkspace
!Pods/Manifest.lock
# Xcode
xcuserdata/
*.xccheckout
*.xcscmblueprint
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcodeproj/xcshareddata/.*
# Swift Package Manager
.swiftpm
.build/
Package.resolved
# CocoaPods
Pods/
Podfile.lock
# Carthage
Carthage/Build/
# Fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/report.html
# Temporary files
*.tmp
*.swp
*.swm
*.swo
*.DS_Store
# Logs
*.log
# User-specific files
*.moved-aside
*.dat
*.plist
*.orig
*.lock
# Shared frameworks
Carthage/Checkouts/
Carthage/Build/
# Simulator
ios.simruntime/
# Bundle artifacts
*.ipa
*.dSYM.zip
*.dSYM
*.xcarchive
# Playgrounds
timeline.xctimeline
playground.xcworkspace
# SwiftUI Previews
*.preview-thumbnails

View File

@ -1,577 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
EA668AC12D3F04D600E021EA /* README.MD in Resources */ = {isa = PBXBuildFile; fileRef = EA668AC02D3F04D600E021EA /* README.MD */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
EA668AA02D3F037F00E021EA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = EA668A812D3F037E00E021EA /* Project object */;
proxyType = 1;
remoteGlobalIDString = EA668A882D3F037E00E021EA;
remoteInfo = EmployeeDirectory;
};
EA668AAA2D3F037F00E021EA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = EA668A812D3F037E00E021EA /* Project object */;
proxyType = 1;
remoteGlobalIDString = EA668A882D3F037E00E021EA;
remoteInfo = EmployeeDirectory;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
EA668A892D3F037E00E021EA /* EmployeeDirectory.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EmployeeDirectory.app; sourceTree = BUILT_PRODUCTS_DIR; };
EA668A9F2D3F037F00E021EA /* EmployeeDirectoryTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EmployeeDirectoryTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EA668AA92D3F037F00E021EA /* EmployeeDirectoryUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EmployeeDirectoryUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EA668AC02D3F04D600E021EA /* README.MD */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.MD; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EA668AB12D3F037F00E021EA /* Exceptions for "EmployeeDirectory" folder in "EmployeeDirectory" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = EA668A882D3F037E00E021EA /* EmployeeDirectory */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
EA668A8B2D3F037E00E021EA /* EmployeeDirectory */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EA668AB12D3F037F00E021EA /* Exceptions for "EmployeeDirectory" folder in "EmployeeDirectory" target */,
);
path = EmployeeDirectory;
sourceTree = "<group>";
};
EA668AA22D3F037F00E021EA /* EmployeeDirectoryTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = EmployeeDirectoryTests;
sourceTree = "<group>";
};
EA668AAC2D3F037F00E021EA /* EmployeeDirectoryUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = EmployeeDirectoryUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
EA668A862D3F037E00E021EA /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA668A9C2D3F037F00E021EA /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA668AA62D3F037F00E021EA /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
EA668A802D3F037E00E021EA = {
isa = PBXGroup;
children = (
EA668AC02D3F04D600E021EA /* README.MD */,
EA668A8B2D3F037E00E021EA /* EmployeeDirectory */,
EA668AA22D3F037F00E021EA /* EmployeeDirectoryTests */,
EA668AAC2D3F037F00E021EA /* EmployeeDirectoryUITests */,
EA668A8A2D3F037E00E021EA /* Products */,
);
sourceTree = "<group>";
};
EA668A8A2D3F037E00E021EA /* Products */ = {
isa = PBXGroup;
children = (
EA668A892D3F037E00E021EA /* EmployeeDirectory.app */,
EA668A9F2D3F037F00E021EA /* EmployeeDirectoryTests.xctest */,
EA668AA92D3F037F00E021EA /* EmployeeDirectoryUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
EA668A882D3F037E00E021EA /* EmployeeDirectory */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA668AB22D3F037F00E021EA /* Build configuration list for PBXNativeTarget "EmployeeDirectory" */;
buildPhases = (
EA668A852D3F037E00E021EA /* Sources */,
EA668A862D3F037E00E021EA /* Frameworks */,
EA668A872D3F037E00E021EA /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
EA668A8B2D3F037E00E021EA /* EmployeeDirectory */,
);
name = EmployeeDirectory;
packageProductDependencies = (
);
productName = EmployeeDirectory;
productReference = EA668A892D3F037E00E021EA /* EmployeeDirectory.app */;
productType = "com.apple.product-type.application";
};
EA668A9E2D3F037F00E021EA /* EmployeeDirectoryTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA668AB72D3F037F00E021EA /* Build configuration list for PBXNativeTarget "EmployeeDirectoryTests" */;
buildPhases = (
EA668A9B2D3F037F00E021EA /* Sources */,
EA668A9C2D3F037F00E021EA /* Frameworks */,
EA668A9D2D3F037F00E021EA /* Resources */,
);
buildRules = (
);
dependencies = (
EA668AA12D3F037F00E021EA /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
EA668AA22D3F037F00E021EA /* EmployeeDirectoryTests */,
);
name = EmployeeDirectoryTests;
packageProductDependencies = (
);
productName = EmployeeDirectoryTests;
productReference = EA668A9F2D3F037F00E021EA /* EmployeeDirectoryTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
EA668AA82D3F037F00E021EA /* EmployeeDirectoryUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA668ABA2D3F037F00E021EA /* Build configuration list for PBXNativeTarget "EmployeeDirectoryUITests" */;
buildPhases = (
EA668AA52D3F037F00E021EA /* Sources */,
EA668AA62D3F037F00E021EA /* Frameworks */,
EA668AA72D3F037F00E021EA /* Resources */,
);
buildRules = (
);
dependencies = (
EA668AAB2D3F037F00E021EA /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
EA668AAC2D3F037F00E021EA /* EmployeeDirectoryUITests */,
);
name = EmployeeDirectoryUITests;
packageProductDependencies = (
);
productName = EmployeeDirectoryUITests;
productReference = EA668AA92D3F037F00E021EA /* EmployeeDirectoryUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
EA668A812D3F037E00E021EA /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620;
TargetAttributes = {
EA668A882D3F037E00E021EA = {
CreatedOnToolsVersion = 16.2;
};
EA668A9E2D3F037F00E021EA = {
CreatedOnToolsVersion = 16.2;
TestTargetID = EA668A882D3F037E00E021EA;
};
EA668AA82D3F037F00E021EA = {
CreatedOnToolsVersion = 16.2;
TestTargetID = EA668A882D3F037E00E021EA;
};
};
};
buildConfigurationList = EA668A842D3F037E00E021EA /* Build configuration list for PBXProject "EmployeeDirectory" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = EA668A802D3F037E00E021EA;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = EA668A8A2D3F037E00E021EA /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
EA668A882D3F037E00E021EA /* EmployeeDirectory */,
EA668A9E2D3F037F00E021EA /* EmployeeDirectoryTests */,
EA668AA82D3F037F00E021EA /* EmployeeDirectoryUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
EA668A872D3F037E00E021EA /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
EA668AC12D3F04D600E021EA /* README.MD in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
EA668A9D2D3F037F00E021EA /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA668AA72D3F037F00E021EA /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
EA668A852D3F037E00E021EA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA668A9B2D3F037F00E021EA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA668AA52D3F037F00E021EA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
EA668AA12D3F037F00E021EA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EA668A882D3F037E00E021EA /* EmployeeDirectory */;
targetProxy = EA668AA02D3F037F00E021EA /* PBXContainerItemProxy */;
};
EA668AAB2D3F037F00E021EA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EA668A882D3F037E00E021EA /* EmployeeDirectory */;
targetProxy = EA668AAA2D3F037F00E021EA /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
EA668AB32D3F037F00E021EA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = EmployeeDirectory/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.EmployeeDirectory;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
EA668AB42D3F037F00E021EA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = EmployeeDirectory/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.EmployeeDirectory;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
EA668AB52D3F037F00E021EA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
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;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
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 = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
EA668AB62D3F037F00E021EA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
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;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
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 = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
EA668AB82D3F037F00E021EA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.EmployeeDirectoryTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EmployeeDirectory.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EmployeeDirectory";
};
name = Debug;
};
EA668AB92D3F037F00E021EA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.EmployeeDirectoryTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EmployeeDirectory.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EmployeeDirectory";
};
name = Release;
};
EA668ABB2D3F037F00E021EA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.EmployeeDirectoryUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = EmployeeDirectory;
};
name = Debug;
};
EA668ABC2D3F037F00E021EA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.EmployeeDirectoryUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = EmployeeDirectory;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
EA668A842D3F037E00E021EA /* Build configuration list for PBXProject "EmployeeDirectory" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA668AB52D3F037F00E021EA /* Debug */,
EA668AB62D3F037F00E021EA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EA668AB22D3F037F00E021EA /* Build configuration list for PBXNativeTarget "EmployeeDirectory" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA668AB32D3F037F00E021EA /* Debug */,
EA668AB42D3F037F00E021EA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EA668AB72D3F037F00E021EA /* Build configuration list for PBXNativeTarget "EmployeeDirectoryTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA668AB82D3F037F00E021EA /* Debug */,
EA668AB92D3F037F00E021EA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EA668ABA2D3F037F00E021EA /* Build configuration list for PBXNativeTarget "EmployeeDirectoryUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA668ABB2D3F037F00E021EA /* Debug */,
EA668ABC2D3F037F00E021EA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = EA668A812D3F037E00E021EA /* Project object */;
}

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>EmployeeDirectory.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@ -1,36 +0,0 @@
//
// AppDelegate.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}
// MARK: UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
}

View File

@ -1,38 +0,0 @@
//
// String.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
extension String {
/// Formats a string into a US phone number format (XXX-XXX-XXXX).
/// Non-numeric characters are removed, and formatting is applied based on the length of the string.
/// - Returns: A formatted phone number as a string.
internal func formatUSNumber() -> String {
// mask for the phone numver
let mask = "XXX-XXX-XXXX"
let numbers = filter { $0.isNumber }
var result = ""
var index = numbers.startIndex // numbers iterator
// iterate over the mask characters until the iterator of numbers ends
for ch in mask where index < numbers.endIndex {
if ch == "X" {
// mask requires a number in this place, so take the next one
result.append(numbers[index])
// move numbers iterator to the next index
index = numbers.index(after: index)
} else {
result.append(ch) // just append a mask character
}
}
return result
}
}

View File

@ -1,18 +0,0 @@
//
// URL.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
import CryptoKit
extension URL {
/// This will has the URL absoluteString into a that is consistent.
internal var uniqueIdentifier: String {
let data = Data(absoluteString.utf8)
let hash = SHA256.hash(data: data)
return hash.compactMap { String(format: "%02x", $0) }.joined()
}
}

View File

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
</dict>
</plist>

View File

@ -1,70 +0,0 @@
//
// Employee.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
/// EmployeeType enum
/// - How the employee is classified.
public enum EmployeeType: String, Codable, CustomStringConvertible {
case fullTime = "FULL_TIME"
case partTime = "PART_TIME"
case contractor = "CONTRACTOR"
/// Format the employee type for display
public var description: String {
switch self {
case .fullTime: return "Full-Time"
case .partTime: return "Part-Time"
case .contractor: return "Contractor"
}
}
}
/// Employee Object
/// JSON Object defintion
/// - https://square.github.io/microsite/mobile-interview-project/
public struct Employee: Codable {
/// The unique identifier for the employee. Represented as a UUID.
public let uuid: UUID
/// The full name of the employee.
public let fullName: String
/// The phone number of the employee, sent as an unformatted string (eg, 5556661234).
public let phoneNumber: String?
/// The email address of the employee.
public let emailAddress: String
/// A short, tweet-length (~300 chars) string that the employee provided to describe themselves.
public let biography: String?
/// The URL of the employees small photo. Useful for list view.
public let photoURLSmall: String?
/// The URL of the employees full-size photo.
public let photoURLLarge: String?
/// The team they are on, represented as a human readable string.
public let team: String
/// How the employee is classified.
public let employeeType: EmployeeType
private enum CodingKeys: String, CodingKey {
case uuid
case fullName = "full_name"
case phoneNumber = "phone_number"
case emailAddress = "email_address"
case biography
case photoURLSmall = "photo_url_small"
case photoURLLarge = "photo_url_large"
case team
case employeeType = "employee_type"
}
}

View File

@ -1,18 +0,0 @@
//
// Employees.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
/// Wrapper JSON Class for the Employees
public struct Employees: Codable {
/// Array of Employees
public var employees: [Employee]
private enum CodingKeys: String, CodingKey {
case employees
}
}

View File

@ -1,16 +0,0 @@
//
// EmployeeServiceable.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
/// This will be the interface for the API for Employees
public protocol EmployeeServiceProtocol {
/// This will get a list of all employees
/// - Parameter serviceMode: Mode in which to hit.
/// - Returns: An Employees struct
func getEmployees(_ serviceMode: EmployeeServiceMode) async throws -> Employees
}

View File

@ -1,63 +0,0 @@
//
// SceneDelegate.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Ensure the scene is properly configured
guard let windowScene = (scene as? UIWindowScene) else { return }
// Create a new UIWindow
let window = UIWindow(windowScene: windowScene)
// Set up the root view controller
let employeeListVC = EmployeesViewController()
let navigationController = UINavigationController(rootViewController: employeeListVC)
window.rootViewController = navigationController
// Set the window to the scene
self.window = window
window.makeKeyAndVisible()
}
func sceneDidDisconnect(_ scene: UIScene) {
// Called as the scene is being released by the system.
// This occurs shortly after the scene enters the background, or when its session is discarded.
// Release any resources associated with this scene that can be re-created the next time the scene connects.
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
}
func sceneDidBecomeActive(_ scene: UIScene) {
// Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
}
func sceneWillResignActive(_ scene: UIScene) {
// Called when the scene will move from an active state to an inactive state.
// This may occur due to temporary interruptions (ex. an incoming phone call).
}
func sceneWillEnterForeground(_ scene: UIScene) {
// Called as the scene transitions from the background to the foreground.
// Use this method to undo the changes made on entering the background.
}
func sceneDidEnterBackground(_ scene: UIScene) {
// Called as the scene transitions from the foreground to the background.
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
}
}

View File

@ -1,45 +0,0 @@
//
// EmployeeService.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
/// These are the testing URL Endpoints for different states
public enum EmployeeServiceMode: String, CaseIterable {
case production
case malformed
case empty
/// Enpoint in which to grabe employees from.
public var endpoint: String {
switch self {
case .production:
return "https://s3.amazonaws.com/sq-mobile-interview/employees.json"
case .malformed:
return "https://s3.amazonaws.com/sq-mobile-interview/employees_malformed.json"
case .empty:
return "https://s3.amazonaws.com/sq-mobile-interview/employees_empty.json"
}
}
}
/// Service Layer for Employees
public class EmployeeService: EmployeeServiceProtocol {
// MARK: - Properties
public static let shared = EmployeeService() // Default shared instance
// MARK: - Initializer
public init() {}
// MARK: - Public Methods
/// This will get a list of all employees
/// - Parameter serviceMode: Mode in which to hit.
/// - Returns: An Employees struct
public func getEmployees(_ serviceMode: EmployeeServiceMode = .production) async throws -> Employees {
return try await NetworkService.shared.fetchData(from: serviceMode.endpoint, as: Employees.self)
}
}

View File

@ -1,89 +0,0 @@
//
// ImageCacheService.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
import UIKit
/// A service that handles image caching using memory, disk, and network in priority order.
public class ImageCacheService {
// MARK: - Properties
public static let shared = ImageCacheService() // Default shared instance
/// Memory cache for storing images in RAM.
private let memoryCache = NSCache<NSString, UIImage>()
/// File manager for handling disk operations.
private let fileManager = FileManager.default
/// Directory where cached images are stored on disk.
private let cacheDirectory: URL = {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
}()
// MARK: - Initializer
public init() {}
// MARK: - Public Methods
/// Loads an image from memory, disk, or network.
///
/// - Parameter url: The URL of the image to load.
/// - Returns: The loaded `UIImage` or `nil` if the image could not be loaded.
public func loadImage(from url: URL) async -> UIImage? {
let uniqueKey = url.uniqueIdentifier
let cacheKey = uniqueKey as NSString
// Step 1: Check the memory cache
if let cachedImage = memoryCache.object(forKey: cacheKey) {
return cachedImage
}
// Step 2: Check the disk cache
let diskImagePath = cacheDirectory.appendingPathComponent(uniqueKey)
if let diskImage = UIImage(contentsOfFile: diskImagePath.path) {
memoryCache.setObject(diskImage, forKey: cacheKey) // Cache in memory for faster access next time
return diskImage
}
// Step 3: Fetch the image from the network
if let networkImage = await fetchFromNetwork(url: url) {
memoryCache.setObject(networkImage, forKey: cacheKey) // Cache in memory
try? saveImageToDisk(image: networkImage, at: diskImagePath) // Cache on disk
return networkImage
}
// Step 4: Return nil if all options fail
return nil
}
// MARK: - Private Methods
/// Fetches an image from the network.
///
/// - Parameter url: The URL of the image to fetch.
/// - Returns: The fetched `UIImage` or `nil` if the network request fails.
private func fetchFromNetwork(url: URL) async -> UIImage? {
do {
let (data, _) = try await URLSession.shared.data(from: url)
return UIImage(data: data)
} catch {
print("Failed to fetch image from network for URL: \(url). Error: \(error)")
return nil
}
}
/// Saves an image to disk at the specified path.
///
/// - Parameters:
/// - image: The `UIImage` to save.
/// - path: The file path where the image should be saved.
private func saveImageToDisk(image: UIImage, at path: URL) throws {
guard let data = image.pngData() else { return }
try data.write(to: path)
}
}

View File

@ -1,79 +0,0 @@
//
// NetworkService.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
public enum NetworkServiceError: Error {
/// The response from the server was invalid (e.g., non-200 status code or malformed URL).
case invalidResponse
/// The url giving is invalid or malformed
case invalidURL
/// The data received was invalid or could not be decoded.
case decodingError(DecodingError)
/// A network-related error occurred.
case networkError(URLError)
/// An unexpected, uncategorized error occurred.
case unknownError(Error)
}
public class NetworkService {
// MARK: - Properties
public static let shared = NetworkService() // Default shared instance
private let session: URLSession
// MARK: - Initializer
/// Public initializer to allow customization
public init(session: URLSession = .shared) {
self.session = session
}
// MARK: - Public Methods
/// Fetches data from a URL and decodes it into a generic Decodable type.
/// - Parameters:
/// - endpoint: The url to fetch data from.
/// - type: The type to decode the data into.
/// - Throws: A `ServiceError` for network, decoding, or unexpected errors.
/// - Returns: The decoded object of the specified type.
public func fetchData<T: Decodable>(from endpoint: String, as type: T.Type) async throws -> T {
do {
//ensure a valid URL
guard let url = URL(string: endpoint) else {
throw NetworkServiceError.invalidURL
}
// Perform network request
let (data, response) = try await URLSession.shared.data(for: URLRequest(url: url))
// Validate HTTP response
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw NetworkServiceError.invalidResponse
}
// Decode the response into the specified type
return try JSONDecoder().decode(T.self, from: data)
} catch let urlError as URLError {
throw NetworkServiceError.networkError(urlError)
} catch let decodingError as DecodingError {
throw NetworkServiceError.decodingError(decodingError)
} catch {
throw NetworkServiceError.unknownError(error)
}
}
}

View File

@ -1,11 +0,0 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,35 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,44 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Employee Directory" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Srq-cZ-OvD">
<rect key="frame" x="118.66666666666667" y="214" width="155.66666666666663" height="21"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="Srq-cZ-OvD" firstAttribute="top" secondItem="6Tk-OE-BBY" secondAttribute="top" constant="155" id="5gs-bk-PQs"/>
<constraint firstItem="Srq-cZ-OvD" firstAttribute="centerX" secondItem="6Tk-OE-BBY" secondAttribute="centerX" id="oRP-ik-vWc"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@ -1,157 +0,0 @@
//
// ViewController.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import UIKit
import Combine
class EmployeesViewController: UIViewController {
// MARK: - Properties
/// List for the employees
private let tableView = UITableView()
/// Will only show when fetching data occurs
private let activityIndicator = UIActivityIndicatorView(style: .large)
/// Allows the user to pick between service modes
private let modeSegmentedControl = UISegmentedControl(items: EmployeeServiceMode.allCases.map{ $0.rawValue } )
/// ViewModel in which drives the screen
private let viewModel = EmployeesViewModel()
/// Holds onto the ViewModels Subscribers
private var cancellables = Set<AnyCancellable>()
/// Will show specific state of the viewModel
private var footerView: TableFooterView?
// MARK: - Public Methods
public override func viewDidLoad() {
super.viewDidLoad()
setupUI()
bindViewModel()
viewModel.fetchEmployees()
}
// MARK: - Private Methods
/// Setup the UI by adding the views to the main view
private func setupUI() {
view.backgroundColor = .white
// Configure TableView
tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier)
tableView.dataSource = self
view.addSubview(tableView)
tableView.frame = view.bounds
//add pull to refresh
tableView.refreshControl = UIRefreshControl()
tableView.refreshControl?.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged)
// Configure Activity Indicator
activityIndicator.center = view.center
view.addSubview(activityIndicator)
// Configure Mode Selector
modeSegmentedControl.selectedSegmentIndex = 0
modeSegmentedControl.addTarget(self, action: #selector(onServiceModeChange), for: .valueChanged)
navigationItem.titleView = modeSegmentedControl
}
/// Using the ViewModel setup combine handlers
private func bindViewModel() {
viewModel.$employees
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.updateFooter()
self?.tableView.reloadData()
self?.tableView.refreshControl?.endRefreshing()
}
.store(in: &cancellables)
viewModel.$isLoading
.receive(on: RunLoop.main)
.sink { [weak self] isLoading in
if isLoading {
self?.activityIndicator.startAnimating()
} else {
self?.activityIndicator.stopAnimating()
}
}
.store(in: &cancellables)
viewModel.$errorMessage
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.updateFooter()
}
.store(in: &cancellables)
}
/// Show state in specific use-cases for the EmployeesViewModel
private func updateFooter() {
var message: String? {
guard !viewModel.isLoading else { return nil }
return viewModel.errorMessage ?? (viewModel.employees.isEmpty ? "No employees found, please try to refresh." : nil)
}
if let message, !viewModel.isLoading {
// Lazy initialize footerView if needed
if footerView == nil {
footerView = TableFooterView(message: message)
} else { // Update the message
footerView?.update(message: message)
}
footerView?.frame = CGRect(x: 0, y: 0, width: tableView.frame.width, height: 150)
tableView.tableFooterView = footerView
} else {
tableView.tableFooterView = nil
}
}
}
// Mark: - Objective-C Methods
extension EmployeesViewController {
/// Fetch the Employees
@objc private func didPullToRefresh() {
viewModel.fetchEmployees()
}
/// This will handle services changes to test conditions to ensure UI works correctly.
/// - Parameter sender: Mode in which to test
@objc private func onServiceModeChange(_ sender: UISegmentedControl) {
let selectedMode: EmployeeServiceMode
switch sender.selectedSegmentIndex {
case 0: selectedMode = .production
case 1: selectedMode = .malformed
case 2: selectedMode = .empty
default: return
}
viewModel.changeMode(to: selectedMode)
}
}
/// Mark: - UITableViewDataSource
extension EmployeesViewController: UITableViewDataSource {
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.employees.count
}
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmployeeTableViewCell.identifier, for: indexPath) as? EmployeeTableViewCell else {
return UITableViewCell()
}
let employee = viewModel.employees[indexPath.row]
cell.configure(with: EmployeeCellViewModel(employee: employee))
return cell
}
}

View File

@ -1,50 +0,0 @@
//
// EmployeeCellViewModel.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
import UIKit
/// ViewModel that will be used along with the EmployeeTableViewCell.
@MainActor
public class EmployeeCellViewModel: ObservableObject {
// MARK: - Properties
private let employee: Employee
public private(set) var uuid: String
public private(set) var fullName: String
public private(set) var phoneNumber: String?
public private(set) var emailAddress: String
public private(set) var biography: String?
public private(set) var team: String
public private(set) var employeeType: String
@Published public private(set) var smallPhoto: UIImage?
// MARK: - Initializer
public init(employee: Employee) {
self.employee = employee
// Initialize properties
uuid = employee.uuid.uuidString
fullName = employee.fullName
phoneNumber = employee.phoneNumber?.formatUSNumber()
emailAddress = employee.emailAddress
biography = employee.biography
team = employee.team
employeeType = employee.employeeType.description
// Fetch the image for the url if it exists
if let endpoint = employee.photoURLSmall {
Task{
if let smallPhotoURL = URL(string: endpoint) {
smallPhoto = await ImageCacheService.shared.loadImage(from: smallPhotoURL)
}
}
}
}
}

View File

@ -1,51 +0,0 @@
//
// EmployeesViewModel.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
/// ViewModel that will be bound to an Employees model and used
/// specifically with the EmployeesViewController.
@MainActor
public class EmployeesViewModel: ObservableObject {
private var serviceMode: EmployeeServiceMode = .production
@Published public private(set) var employees: [Employee] = []
@Published public private(set) var errorMessage: String? = nil
@Published public private(set) var isLoading: Bool = false
public init() {}
public func fetchEmployees() {
// resetting values out the values before fetching new data
errorMessage = nil
isLoading = true
Task {
do {
// Fetch employees using the async method
let wrapper = try await EmployeeService.shared.getEmployees(serviceMode)
// Update published properties
employees = wrapper.employees
isLoading = false
} catch {
// Handle errors
employees = []
isLoading = false
errorMessage = "An unexpected error occurred, please try to refresh"
}
}
}
public func changeMode(to mode: EmployeeServiceMode) {
serviceMode = mode
fetchEmployees()
}
}

View File

@ -1,149 +0,0 @@
//
// EmployeeTableViewCell.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import UIKit
import Combine
/// This is the Cell used in the EmployeesTableViewController to show
/// the properties of an Employee model.
public class EmployeeTableViewCell: UITableViewCell {
/// Used in the TableView registration
static let identifier = "EmployeeTableViewCell"
// MARK: - Properties
/// UI Elements
private let photoImageView = UIImageView()
private let nameLabel = UILabel()
private let emailLabel = UILabel()
private let teamLabel = UILabel()
private let employeeTypeLabel = UILabel()
private let phoneLabel = UILabel()
private let bioLabel = UILabel()
private let stackView = UIStackView()
/// Used for grabbing the photo
private var smallPhotoSubscriber: AnyCancellable?
// MARK: - Initializer
public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Private Methods
private func setupUI() {
// Configure photoImageView
photoImageView.contentMode = .scaleAspectFill
photoImageView.clipsToBounds = true
photoImageView.layer.cornerRadius = 25
photoImageView.translatesAutoresizingMaskIntoConstraints = false
photoImageView.image = UIImage(systemName: "person.crop.circle")
// Configure labels with text styles
nameLabel.font = UIFont.preferredFont(forTextStyle: .headline) // Bold, prominent text
nameLabel.adjustsFontForContentSizeCategory = true // Adapts to Dynamic Type
emailLabel.font = UIFont.preferredFont(forTextStyle: .body)
emailLabel.textColor = .blue
emailLabel.adjustsFontForContentSizeCategory = true // Adapts to Dynamic Type
teamLabel.font = UIFont.preferredFont(forTextStyle: .subheadline) // Secondary, smaller text
teamLabel.textColor = .gray
teamLabel.adjustsFontForContentSizeCategory = true // Adapts to Dynamic Type
employeeTypeLabel.font = UIFont.preferredFont(forTextStyle: .subheadline)
employeeTypeLabel.adjustsFontForContentSizeCategory = true // Adapts to Dynamic Type
phoneLabel.font = UIFont.preferredFont(forTextStyle: .body) // Standard body text
phoneLabel.textColor = .darkGray
phoneLabel.adjustsFontForContentSizeCategory = true// Adapts to Dynamic Type
bioLabel.font = UIFont.preferredFont(forTextStyle: .footnote) // Smaller text for additional info
bioLabel.numberOfLines = 0
bioLabel.textColor = .lightGray
bioLabel.adjustsFontForContentSizeCategory = true // Adapts to Dynamic Type
// Configure stackView
stackView.axis = .vertical
stackView.spacing = 5
stackView.translatesAutoresizingMaskIntoConstraints = false
// Add labels to stackView
stackView.addArrangedSubview(nameLabel)
stackView.addArrangedSubview(teamLabel)
stackView.addArrangedSubview(employeeTypeLabel)
stackView.addArrangedSubview(phoneLabel)
stackView.addArrangedSubview(emailLabel)
stackView.addArrangedSubview(bioLabel)
// Add subviews
contentView.addSubview(photoImageView)
contentView.addSubview(stackView)
// Add constraints
NSLayoutConstraint.activate([
photoImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
photoImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
photoImageView.widthAnchor.constraint(equalToConstant: 50),
photoImageView.heightAnchor.constraint(equalToConstant: 50),
stackView.leadingAnchor.constraint(equalTo: photoImageView.trailingAnchor, constant: 10),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
stackView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -10)
])
}
// MARK: - Public Methods
/// Override for setting back to a default state
public override func prepareForReuse() {
super.prepareForReuse()
smallPhotoSubscriber = nil
photoImageView.image = UIImage(systemName: "person.crop.circle")
nameLabel.text = nil
emailLabel.text = nil
teamLabel.text = nil
employeeTypeLabel.text = nil
phoneLabel.text = nil
bioLabel.text = nil
}
/// Configures the UI Elements with the properties of the EmployeeCellViewModel.
/// - Parameter viewModel: Employee Model wrapped for Cell.
public func configure(with viewModel: EmployeeCellViewModel) {
// Bind the image to the photoImageView
smallPhotoSubscriber = viewModel.$smallPhoto
.receive(on: DispatchQueue.main)
.sink { [weak self] image in
self?.photoImageView.image = image
}
// Bind data to UI components
nameLabel.text = viewModel.fullName
emailLabel.text = viewModel.emailAddress
teamLabel.text = viewModel.team
employeeTypeLabel.text = viewModel.employeeType
phoneLabel.text = viewModel.phoneNumber
bioLabel.text = viewModel.biography
// Dynamically show or hide elements based on their content
phoneLabel.isHidden = viewModel.phoneNumber == nil
bioLabel.isHidden = viewModel.biography == nil
}
}

View File

@ -1,59 +0,0 @@
//
// EmptyStateFooterView.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import UIKit
/// Meant to be used as a Message for State in a TableView.
public class TableFooterView: UIView {
// MARK: - Properties
/// Label used to show the message
private let messageLabel: UILabel = {
let label = UILabel()
label.textColor = .gray
label.textAlignment = .center
label.font = UIFont.preferredFont(forTextStyle: .body) // Use a text style for Dynamic Type
label.adjustsFontForContentSizeCategory = true // Enable Dynamic Type adjustments
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
// MARK: - Initializer
init(message: String) {
super.init(frame: .zero)
setupUI()
update(message: message)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Private Methods
/// Setup the UI
private func setupUI() {
addSubview(messageLabel)
NSLayoutConstraint.activate([
messageLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
messageLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
messageLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
messageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16)
])
}
// MARK: - Public Methods
/// Updates the Current Message
/// - Parameter message: message to show
public func update(message: String) {
messageLabel.text = message
}
}

View File

@ -1,69 +0,0 @@
//
// EmployeeDirectoryTests.swift
// EmployeeDirectoryTests
//
// Created by Matt Bruce on 1/20/25.
//
import Testing
@testable import EmployeeDirectory
struct EmployeeDirectoryTests {
@Test func getEmployeesValid() async throws {
do {
let wrapper = try await EmployeeService.shared.getEmployees(.production)
#expect(wrapper.employees.count == 11)
} catch {
#expect(Bool(false), "Unexpected error: \(error)")
}
}
@Test func getEmployeesMalformed() async throws {
do {
_ = try await EmployeeService.shared.getEmployees(.malformed)
#expect(Bool(false), "Expected invalidResponse error, but no error was thrown")
} catch let error as NetworkServiceError {
switch error {
case .decodingError(let decodingError):
#expect(Bool(true), "Expected NetworkServiceError.decodingError, but got \(decodingError)")
default:
#expect(Bool(false), "Expected NetworkServiceError.decodingError, but got \(error)")
}
} catch {
#expect(Bool(false), "Unexpected error: \(error)")
}
}
@Test func getEmployeesEmpty() async throws {
do {
let wrapper = try await EmployeeService.shared.getEmployees(.empty)
#expect(wrapper.employees.count == 0)
} catch {
#expect(Bool(false), "Unexpected error: \(error)")
}
}
}
// Testing extension since this isn't needed in the code.
extension NetworkServiceError: @retroactive Equatable {
public static func == (lhs: NetworkServiceError, rhs: NetworkServiceError) -> Bool {
switch (lhs, rhs) {
case (.invalidResponse, .invalidResponse):
return true
case (.invalidURL, .invalidURL):
return true
case (.decodingError(let lhsError), .decodingError(let rhsError)):
return lhsError.localizedDescription == rhsError.localizedDescription
case (.networkError(let lhsError), .networkError(let rhsError)):
return lhsError.code == rhsError.code
case (.unknownError(let lhsError), .unknownError(let rhsError)):
return lhsError.localizedDescription == rhsError.localizedDescription
default:
return false
}
}
}

View File

@ -1,43 +0,0 @@
//
// EmployeeDirectoryUITests.swift
// EmployeeDirectoryUITests
//
// Created by Matt Bruce on 1/20/25.
//
import XCTest
final class EmployeeDirectoryUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
@MainActor
func testLaunchPerformance() throws {
if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}
}

View File

@ -1,33 +0,0 @@
//
// EmployeeDirectoryUITestsLaunchTests.swift
// EmployeeDirectoryUITests
//
// Created by Matt Bruce on 1/20/25.
//
import XCTest
final class EmployeeDirectoryUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}

View File

@ -1,39 +0,0 @@
# Employee Directory
## Build tools & versions used
Xcode 16.2, built with Swift 5.9.
## Steps to run the app
No additional steps are required. Simply build and run the project. The app supports iPhone and iPad in portrait mode.
## What areas of the app did you focus on?
I focused on designing a robust architecture using a separation of concerns approach. I also implemented a functional UI for the employee directory.
## What was the reason for your focus? What problems were you trying to solve?
My primary goal was to ensure the app's architecture was clean, modular, and easy to maintain or extend. I aimed to minimize tightly coupled code and ensure future features could be added with minimal changes. This approach improves testability and makes the codebase easier for others to understand and work with.
## How long did you spend on this project?
Approximately 5 hours:
- 4 hours on development and testing.
- 1 hour on code refinement, documentation, and ensuring best practices.
## Did you make any trade-offs for this project? What would you have done differently with more time?
**Trade-offs**:
- The networking layer currently lacks a request builder. This limits the ability to add query parameters or authentication headers in a structured way.
- The UI uses standard UIKit components, which could be improved for a more modern or visually appealing design.
**With more time**, I would:
- Implement a dedicated `RequestBuilder` to make the networking layer more flexible and extensible.
- Enhance the UI by incorporating more modern design elements, animations, and responsiveness.
- Expand test coverage to include additional edge cases and integration tests.
## What do you think is the weakest part of your project?
The weakest areas are:
- The UI, which is functional but minimal, using standard UIKit elements.
- The networking layer, which, while functional, would benefit from additional abstraction (e.g., `RequestBuilder`) to handle authentication or dynamic query parameters more effectively.
## Did you copy any code or dependencies? Please make sure to attribute them here!
I adapted a regular expression-based string extension for phone number formatting from a Stack Overflow post. While the logic was modified for this project, credit is due to the original author for the inspiration.
## Is there any other information youd like us to know?
When building projects from scratch, I prioritize setting up the API/Service/Model layer and validating it with tests before focusing on the UI. My strengths lie in designing clean, scalable app architectures. While I am proficient in UIKit, I am less experienced with SwiftUI but eager to continue improving in that area.

2
README.md Normal file
View File

@ -0,0 +1,2 @@
# block-employee-directory