From 09bd1740de9e98014adb5f9a896527ad52459517 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 4 Mar 2025 14:54:57 -0600 Subject: [PATCH] initial commit --- EmployeeDirectory.xcodeproj/project.pbxproj | 15 ++++ .../xcdebugger/Breakpoints_v2.xcbkptlist | 6 ++ EmployeeDirectory/ContentView.swift | 24 ------ EmployeeDirectory/EmployeeDirectoryApp.swift | 2 +- EmployeeDirectory/Extensions/String.swift | 26 ++++++ EmployeeDirectory/Info.plist | 19 +++++ EmployeeDirectory/Models/Employee.swift | 50 ++++++++++++ EmployeeDirectory/Models/Employees.swift | 14 ++++ .../Protocols/EmployeeServiceProtocol.swift | 10 +++ .../Services/EmployeeService.swift | 76 ++++++++++++++++++ .../ViewModels/EmployeeViewModel.swift | 35 ++++++++ .../ViewModels/EmployeesViewModel.swift | 40 ++++++++++ .../Views/ContactButtonView.swift | 80 +++++++++++++++++++ .../Views/EmployeeDetailsView.swift | 43 ++++++++++ .../Views/EmployeeListView.swift | 49 ++++++++++++ EmployeeDirectory/Views/EmployeeRowView.swift | 47 +++++++++++ .../Views/ProfileImageView.swift | 37 +++++++++ 17 files changed, 548 insertions(+), 25 deletions(-) create mode 100644 EmployeeDirectory.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist delete mode 100644 EmployeeDirectory/ContentView.swift create mode 100644 EmployeeDirectory/Extensions/String.swift create mode 100644 EmployeeDirectory/Info.plist create mode 100644 EmployeeDirectory/Models/Employee.swift create mode 100644 EmployeeDirectory/Models/Employees.swift create mode 100644 EmployeeDirectory/Protocols/EmployeeServiceProtocol.swift create mode 100644 EmployeeDirectory/Services/EmployeeService.swift create mode 100644 EmployeeDirectory/ViewModels/EmployeeViewModel.swift create mode 100644 EmployeeDirectory/ViewModels/EmployeesViewModel.swift create mode 100644 EmployeeDirectory/Views/ContactButtonView.swift create mode 100644 EmployeeDirectory/Views/EmployeeDetailsView.swift create mode 100644 EmployeeDirectory/Views/EmployeeListView.swift create mode 100644 EmployeeDirectory/Views/EmployeeRowView.swift create mode 100644 EmployeeDirectory/Views/ProfileImageView.swift diff --git a/EmployeeDirectory.xcodeproj/project.pbxproj b/EmployeeDirectory.xcodeproj/project.pbxproj index 3da99ee..69cf907 100644 --- a/EmployeeDirectory.xcodeproj/project.pbxproj +++ b/EmployeeDirectory.xcodeproj/project.pbxproj @@ -29,9 +29,22 @@ EAC304C22D76693000D9006D /* EmployeeDirectoryUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EmployeeDirectoryUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + EA57F4B72D778F140015D7BC /* Exceptions for "EmployeeDirectory" folder in "EmployeeDirectory" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = EAC304A72D76692F00D9006D /* EmployeeDirectory */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ EAC304AA2D76692F00D9006D /* EmployeeDirectory */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + EA57F4B72D778F140015D7BC /* Exceptions for "EmployeeDirectory" folder in "EmployeeDirectory" target */, + ); path = EmployeeDirectory; sourceTree = ""; }; @@ -399,6 +412,7 @@ DEVELOPMENT_TEAM = 6R7KLBPBLZ; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = EmployeeDirectory/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -428,6 +442,7 @@ DEVELOPMENT_TEAM = 6R7KLBPBLZ; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = EmployeeDirectory/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/EmployeeDirectory.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/EmployeeDirectory.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..7b5a656 --- /dev/null +++ b/EmployeeDirectory.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/EmployeeDirectory/ContentView.swift b/EmployeeDirectory/ContentView.swift deleted file mode 100644 index 7790aa8..0000000 --- a/EmployeeDirectory/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// EmployeeDirectory -// -// Created by Matt Bruce on 3/3/25. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/EmployeeDirectory/EmployeeDirectoryApp.swift b/EmployeeDirectory/EmployeeDirectoryApp.swift index 58d2741..5ca6fef 100644 --- a/EmployeeDirectory/EmployeeDirectoryApp.swift +++ b/EmployeeDirectory/EmployeeDirectoryApp.swift @@ -11,7 +11,7 @@ import SwiftUI struct EmployeeDirectoryApp: App { var body: some Scene { WindowGroup { - ContentView() + EmployeeListView() } } } diff --git a/EmployeeDirectory/Extensions/String.swift b/EmployeeDirectory/Extensions/String.swift new file mode 100644 index 0000000..4de337e --- /dev/null +++ b/EmployeeDirectory/Extensions/String.swift @@ -0,0 +1,26 @@ +// +// String.swift +// EmployeeDirectory +// +// Created by Matt Bruce on 3/4/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 { + let mask = "XXX-XXX-XXXX" + let digits = filter { $0.isNumber } + var index = digits.startIndex + return mask.reduce(into: "") { result, char in + if char == "X", index < digits.endIndex { + result.append(digits[index]) + index = digits.index(after: index) + } else { + result.append(char) + } + } + } +} diff --git a/EmployeeDirectory/Info.plist b/EmployeeDirectory/Info.plist new file mode 100644 index 0000000..8c35f83 --- /dev/null +++ b/EmployeeDirectory/Info.plist @@ -0,0 +1,19 @@ + + + + + NSAppTransportSecurity + + NSExceptionDomains + + dummyimage.com + + NSIncludesSubdomains + + NSExceptionAllowsInsecureHTTPLoads + + + + + + diff --git a/EmployeeDirectory/Models/Employee.swift b/EmployeeDirectory/Models/Employee.swift new file mode 100644 index 0000000..18c7dcd --- /dev/null +++ b/EmployeeDirectory/Models/Employee.swift @@ -0,0 +1,50 @@ +// +// Employee.swift +// EmployeeDirectory +// +// Created by Matt Bruce on 3/3/25. +// +import Foundation + +/// Employee Object +/// JSON Object defintion +/// - https://square.github.io/microsite/mobile-interview-project/ +public struct Employee: Identifiable, Hashable, Codable { + public var id: UUID { uuid } + + /// The unique identifier for the employee. Represented as a UUID. + public let uuid: UUID + + /// The first name of the employee. + public let firstName: String + + /// The last name of the employee. + public let lastName: 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 employee’s small photo. Useful for list view. + public let photoURLSmall: String? + + /// The URL of the employee’s full-size photo. + public let photoURLLarge: String? + + private enum CodingKeys: String, CodingKey { + case uuid + case firstName = "first_name" + case lastName = "last_name" + case phoneNumber = "phone_number" + case emailAddress = "email_address" + case biography + case photoURLSmall = "photo_url_small" + case photoURLLarge = "photo_url_large" + } +} + diff --git a/EmployeeDirectory/Models/Employees.swift b/EmployeeDirectory/Models/Employees.swift new file mode 100644 index 0000000..1502843 --- /dev/null +++ b/EmployeeDirectory/Models/Employees.swift @@ -0,0 +1,14 @@ +// +// Employees.swift +// EmployeeDirectory +// +// Created by Matt Bruce on 3/3/25. +// +import Foundation + +/// Wrapper JSON Class for the Employees +public struct Employees: Codable { + /// Array of Employees + public var result: [Employee] + public var hasNextPage: Bool = false +} diff --git a/EmployeeDirectory/Protocols/EmployeeServiceProtocol.swift b/EmployeeDirectory/Protocols/EmployeeServiceProtocol.swift new file mode 100644 index 0000000..286fcb8 --- /dev/null +++ b/EmployeeDirectory/Protocols/EmployeeServiceProtocol.swift @@ -0,0 +1,10 @@ +// +// EmployeeServiceProtocol.swift +// EmployeeDirectory +// +// Created by Matt Bruce on 3/3/25. +// + +public protocol EmployeeServiceProtocol { + func getUsers(page: Int?) async throws -> Employees +} diff --git a/EmployeeDirectory/Services/EmployeeService.swift b/EmployeeDirectory/Services/EmployeeService.swift new file mode 100644 index 0000000..88f2d4e --- /dev/null +++ b/EmployeeDirectory/Services/EmployeeService.swift @@ -0,0 +1,76 @@ +// +// EmployeeService.swift +// EmployeeDirectory +// +// Created by Matt Bruce on 3/3/25. +// +import Foundation + +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 + /// - Returns: An Employees struct + public func getUsers(page: Int? = nil) async throws -> Employees { + var endpoint = "https://my.api.mockaroo.com/users.json?key=f298b840" + if let page { + endpoint += "&page=\(page)" + } + + //ensure a valid URL + guard let url = URL(string: endpoint) else { + throw URLError(.badURL) + } + + // Perform network request + let (data, response) = try await URLSession.shared.data(from: url) + + // Validate HTTP response + guard let httpResponse = response as? HTTPURLResponse, + 200..<300 ~= httpResponse.statusCode else { + throw URLError(.badServerResponse) + } + + // Decode the response into the specified type + return try JSONDecoder().decode(Employees.self, from: data) + } +} + +// Mock Service Implementation for testing & previews. +struct MockEmployeeService: EmployeeServiceProtocol { + static let sample = createUser(page: 1, index: 1) + + static func createUser(page: Int?, index: Int) -> Employee { + Employee( + uuid: UUID(), + firstName: "First \(index + ((page ?? 1) - 1) * 20)", + lastName: "Last \(index + ((page ?? 1) - 1) * 20)", + phoneNumber: "555555\(1000 + index)", + emailAddress: "user\(index)@example.com", + biography: "Biography for employee \(index)", + photoURLSmall: "https://example.com/photo_small.jpg", + photoURLLarge: "https://example.com/photo_large.jpg" + ) + } + func getUsers(page: Int?) async throws -> Employees { + // Simulate network delay. + try await Task.sleep(nanoseconds: 500_000_000) + + // Create dummy data (20 employees per page). + let dummyEmployees: [Employee] = (0..<20).map { i in + Self.createUser(page: page, index: i) + } + + // Simulate that there are more pages if page is less than 5. + let hasNext = (page ?? 1) < 5 + + return Employees(result: dummyEmployees, hasNextPage: hasNext) + } +} diff --git a/EmployeeDirectory/ViewModels/EmployeeViewModel.swift b/EmployeeDirectory/ViewModels/EmployeeViewModel.swift new file mode 100644 index 0000000..b19c782 --- /dev/null +++ b/EmployeeDirectory/ViewModels/EmployeeViewModel.swift @@ -0,0 +1,35 @@ +// +// EmployeeViewModel.swift +// EmployeeDirectory +// +// Created by Matt Bruce on 3/3/25. +// +import Foundation +import Combine + +@MainActor +public class EmployeeViewModel { + // 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 smallPhoto: String? + public private(set) var largePhoto: String? + + // MARK: - Initializer + public init(employee: Employee) { + self.employee = employee + uuid = employee.uuid.uuidString + fullName = "\(employee.firstName) \(employee.lastName)" + phoneNumber = employee.phoneNumber?.formatUSNumber() + emailAddress = employee.emailAddress + biography = employee.biography + smallPhoto = employee.photoURLSmall + largePhoto = employee.photoURLLarge + } +} diff --git a/EmployeeDirectory/ViewModels/EmployeesViewModel.swift b/EmployeeDirectory/ViewModels/EmployeesViewModel.swift new file mode 100644 index 0000000..1f00cf3 --- /dev/null +++ b/EmployeeDirectory/ViewModels/EmployeesViewModel.swift @@ -0,0 +1,40 @@ +// +// EmployeesViewModel.swift +// EmployeeDirectory +// +// Created by Matt Bruce on 3/3/25. +// +import Foundation +import Combine + +/// ViewModel that will be bound to an Employees model and used +/// specifically with the EmployeesViewController. +@MainActor +public class EmployeesViewModel: ObservableObject { + @Published var employees: [Employee] = [] + @Published var isLoading = false + @Published var hasNextPage = false + + private var currentPage = 1 + let service: EmployeeServiceProtocol + + init(service: EmployeeServiceProtocol) { + self.service = service + } + + /// Loads the next page of employees. + func loadEmployees() async { + guard !isLoading else { return } + isLoading = true + do { + let response = try await service.getUsers(page: currentPage) + employees.append(contentsOf: response.result) + hasNextPage = response.hasNextPage + currentPage += 1 + } catch { + print("Error loading employees: \(error)") + } + isLoading = false + } +} + diff --git a/EmployeeDirectory/Views/ContactButtonView.swift b/EmployeeDirectory/Views/ContactButtonView.swift new file mode 100644 index 0000000..8dec510 --- /dev/null +++ b/EmployeeDirectory/Views/ContactButtonView.swift @@ -0,0 +1,80 @@ +// +// ContactButtonView.swift +// EmployeeDirectory +// +// Created by Matt Bruce on 3/4/25. +// +import SwiftUI + +enum ContactType { + case phone, email +} + +struct ContactButtonView: View { + let contactType: ContactType + let contactValue: String + let enabled: Bool + + init(contactType: ContactType, contactValue: String, enabled: Bool = true) { + self.contactType = contactType + self.contactValue = contactValue + self.enabled = enabled + } + + var body: some View { + if !enabled { + Label(labelText(for: contactValue), systemImage: iconName) + .foregroundColor(.gray) + .font(.caption) + } else { + Button(action: { + if let url = URL(string: urlString(for: contactValue)) { + UIApplication.shared.open(url) + } + }) { + Label(labelText(for: contactValue), systemImage: iconName) + .foregroundColor(.blue) + .font(.caption) + } + } + } + + // MARK: - Helpers + + private var iconName: String { + switch contactType { + case .phone: + return "phone.fill" + case .email: + return "envelope.fill" + } + } + + private func labelText(for value: String) -> String { + switch contactType { + case .phone: + return "Call \(value)" + case .email: + return "Email \(value)" + } + } + + private func urlString(for value: String) -> String { + switch contactType { + case .phone: + return "tel://\(value)" + case .email: + return "mailto:\(value)" + } + } +} + + +#Preview { + VStack (alignment: .leading) { + Text("Phone Number:") + ContactButtonView(contactType: .phone, contactValue: MockEmployeeService.sample.phoneNumber!.formatUSNumber()).padding() + Text("Email Address:") + ContactButtonView(contactType: .email, contactValue: MockEmployeeService.sample.emailAddress).padding() + } +} diff --git a/EmployeeDirectory/Views/EmployeeDetailsView.swift b/EmployeeDirectory/Views/EmployeeDetailsView.swift new file mode 100644 index 0000000..b9217d2 --- /dev/null +++ b/EmployeeDirectory/Views/EmployeeDetailsView.swift @@ -0,0 +1,43 @@ +// +// EmployeeDetailsView.swift +// EmployeeDirectory +// +// Created by Matt Bruce on 3/4/25. +// + +import SwiftUI + +struct EmployeeDetailsView: View { + let viewModel: EmployeeViewModel + let defaultImage = "person.circle.fill" // SF Symbol as fallback + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Profile Image with Default Placeholder + ProfileImageView(urlString: viewModel.largePhoto, size: 250) // Uses reusable component + .padding(.bottom) + Text(viewModel.fullName) + .font(.headline) + + if let bio = viewModel.biography { + Text(bio) + .font(.footnote) + .foregroundColor(.gray) + .lineLimit(2) + } + + ContactButtonView(contactType: .email, contactValue: viewModel.emailAddress) + + if let phone = viewModel.phoneNumber { + ContactButtonView(contactType: .phone, contactValue: phone) + } + Spacer() + } + .padding(.all) + } +} + +#Preview { + EmployeeDetailsView(viewModel: .init(employee: MockEmployeeService.sample)).padding(.all) +} + diff --git a/EmployeeDirectory/Views/EmployeeListView.swift b/EmployeeDirectory/Views/EmployeeListView.swift new file mode 100644 index 0000000..dcb39d3 --- /dev/null +++ b/EmployeeDirectory/Views/EmployeeListView.swift @@ -0,0 +1,49 @@ +// +// ContentView.swift +// EmployeeDirectory +// +// Created by Matt Bruce on 3/3/25. +// + +import SwiftUI + +@MainActor +struct EmployeeListView: View { + @StateObject public var viewModel: EmployeesViewModel + + // Dependency injection via the initializer. + init(viewModel: EmployeesViewModel? = nil) { + _viewModel = StateObject(wrappedValue: viewModel ?? .init(service: EmployeeService())) + } + + var body: some View { + NavigationView { + List { + ForEach(viewModel.employees) { employee in + NavigationLink(destination: EmployeeDetailsView(viewModel: .init(employee: employee))) { + EmployeeRowView(viewModel: .init(employee: employee)) + } + .listRowSeparator(.hidden) + } + if viewModel.hasNextPage { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .task { + await viewModel.loadEmployees() + } + } + } + .navigationTitle("Employees") + .listStyle(.plain) + } + .task { + if viewModel.employees.isEmpty { + await viewModel.loadEmployees() + } + } + } +} + +#Preview { + EmployeeListView(viewModel: .init(service: MockEmployeeService())) +} diff --git a/EmployeeDirectory/Views/EmployeeRowView.swift b/EmployeeDirectory/Views/EmployeeRowView.swift new file mode 100644 index 0000000..076be28 --- /dev/null +++ b/EmployeeDirectory/Views/EmployeeRowView.swift @@ -0,0 +1,47 @@ +// +// EmployeeRowView.swift +// EmployeeDirectory +// +// Created by Matt Bruce on 3/3/25. +// + +import SwiftUI + +struct EmployeeRowView: View { + let viewModel: EmployeeViewModel + let defaultImage = "person.circle.fill" // SF Symbol as fallback + + var body: some View { + HStack(alignment: .top, spacing: 12) { + // Profile Image with Default Placeholder + ProfileImageView(urlString: viewModel.smallPhoto, size: 50) // Uses reusable component + + // Employee Info Stack + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.fullName) + .font(.headline) + + if let bio = viewModel.biography { + Text(bio) + .font(.footnote) + .foregroundColor(.gray) + .lineLimit(2) + } + + ContactButtonView(contactType: .email, contactValue: viewModel.emailAddress, enabled: false) + + if let phone = viewModel.phoneNumber { + ContactButtonView(contactType: .phone, contactValue: phone, enabled: false) + } + } + + Spacer() // Pushes everything to the left + } + .padding(.vertical, 8) + } +} + +#Preview { + EmployeeRowView(viewModel: .init(employee: MockEmployeeService.sample)).padding() +} + diff --git a/EmployeeDirectory/Views/ProfileImageView.swift b/EmployeeDirectory/Views/ProfileImageView.swift new file mode 100644 index 0000000..cf846e2 --- /dev/null +++ b/EmployeeDirectory/Views/ProfileImageView.swift @@ -0,0 +1,37 @@ +// +// ProfileImageView.swift +// EmployeeDirectory +// +// Created by Matt Bruce on 3/3/25. +// + +import SwiftUI + +struct ProfileImageView: View { + let urlString: String? + let size: CGFloat // Allows us to customize the image size + + private let defaultImage = "person.circle.fill" // SF Symbol + + var body: some View { + AsyncImage(url: URL(string: urlString ?? "")) { phase in + if let image = phase.image { + image.resizable() + } else if phase.error != nil || urlString == nil { + Image(systemName: defaultImage) + .resizable() + .foregroundColor(.gray) + } else { + ProgressView() // Show loader while fetching + } + } + .scaledToFill() + .frame(width: size, height: size) + .clipShape(Circle()) // Rounded shape + .overlay(Circle().stroke(Color.gray, lineWidth: 1)) // Border + } +} + +#Preview { + ProfileImageView(urlString: MockEmployeeService.sample.photoURLSmall, size: 100).padding() +}