440 lines
15 KiB
Swift
440 lines
15 KiB
Swift
//
|
||
// ContentView.swift
|
||
// EmployeeDirectory-Observed
|
||
//
|
||
// Created by Matt Bruce on 3/14/25.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
struct ContentView: View {
|
||
let service: EmployeeDirectoryService
|
||
@Environment(\.editMode) private var editMode
|
||
|
||
var body: some View {
|
||
EmployeeDirectoryList(viewModel: .init(service: service))
|
||
.onChange(of: editMode?.wrappedValue) { oldValue, newValue in
|
||
print("Edit mode changed from \(String(describing: oldValue)) to \(String(describing: newValue))")
|
||
}
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
ContentView(service: MockEmployeeService())
|
||
}
|
||
|
||
//MARK: - Views
|
||
public struct EmployeeDirectoryList: View {
|
||
@State public var viewModel: EmployeesViewModel
|
||
@State public var path = NavigationPath()
|
||
@State private var selectedEmployees = Set<Employee.ID>()
|
||
@State private var editMode: EditMode = .inactive
|
||
@State private var searchText = ""
|
||
|
||
init(viewModel: EmployeesViewModel? = nil) {
|
||
_viewModel = .init(wrappedValue: viewModel ?? .init(service: EmployeeService()))
|
||
}
|
||
|
||
var filteredEmployees: [Employee] {
|
||
if searchText.isEmpty {
|
||
return viewModel.employees
|
||
} else {
|
||
return viewModel.employees.filter { employee in
|
||
employee.firstName.lowercased().contains(searchText.lowercased()) ||
|
||
employee.lastName.lowercased().contains(searchText.lowercased())
|
||
}
|
||
}
|
||
}
|
||
|
||
public var body: some View {
|
||
NavigationStack(path: $path) {
|
||
List(selection: $selectedEmployees) {
|
||
ForEach(filteredEmployees, id: \.id) { employee in
|
||
let employeeViewModel = EmployeeViewModel(employee: employee)
|
||
EmployeeListItem(employeeViewModel: employeeViewModel)
|
||
.onTapGesture {
|
||
if editMode != .active {
|
||
path.append(employee)
|
||
}
|
||
}
|
||
.swipeActions(edge: .leading) {
|
||
Button("Pin") {
|
||
print("\(employee.firstName) pinned")
|
||
}
|
||
.tint(.orange)
|
||
Button("Flagged") {
|
||
print("\(employee.firstName) flagged")
|
||
}
|
||
.tint(.yellow)
|
||
}
|
||
}
|
||
.onMove(perform: moveEmployee)
|
||
.onDelete(perform: deleteEmployees)
|
||
|
||
if viewModel.hasNextPage {
|
||
ProgressView()
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
.task {
|
||
await viewModel.loadNextPage()
|
||
}
|
||
}
|
||
}
|
||
.refreshable {
|
||
if editMode != .active {
|
||
await viewModel.refresh()
|
||
}
|
||
}
|
||
.searchable(text: $searchText, prompt: "Search employees by name")
|
||
.navigationTitle("Employee Directory")
|
||
.navigationDestination(for: Employee.self) { employee in
|
||
EmployeeDetailView(viewModel: .init(employee: employee))
|
||
}
|
||
.listStyle(.insetGrouped)
|
||
.task {
|
||
if viewModel.employees.isEmpty {
|
||
await viewModel.loadNextPage()
|
||
}
|
||
}
|
||
.toolbar {
|
||
EditButton()
|
||
.onTapGesture {
|
||
editMode = editMode == .active ? .inactive : .active
|
||
}
|
||
}
|
||
.toolbar {
|
||
if !selectedEmployees.isEmpty {
|
||
ToolbarItemGroup(placement: .bottomBar) {
|
||
Button("Delete") {
|
||
withAnimation {
|
||
viewModel.delete(selectedEmployees: selectedEmployees)
|
||
selectedEmployees.removeAll()
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
}
|
||
.environment(\.editMode, $editMode)
|
||
}
|
||
}
|
||
|
||
private func moveEmployee(from source: IndexSet, to destination: Int) {
|
||
viewModel.move(employeeFrom: source, to: destination)
|
||
print("moveEmployee called")
|
||
}
|
||
|
||
private func deleteEmployees(at offsets: IndexSet) {
|
||
viewModel.delete(employeeAt: offsets)
|
||
print("deleteEmployees called")
|
||
}
|
||
}
|
||
|
||
public struct EmployeeListItem: View {
|
||
public let employeeViewModel: EmployeeViewModel
|
||
|
||
public var body: some View {
|
||
HStack(spacing: 10) {
|
||
ProfileImageView(urlString: employeeViewModel.smallPhoto, size: 51)
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
Text(employeeViewModel.fullName)
|
||
.font(.headline)
|
||
if let bio = employeeViewModel.biography {
|
||
Text(bio)
|
||
.font(.footnote)
|
||
.foregroundColor(.gray)
|
||
.lineLimit(2)
|
||
}
|
||
Label("Email: \(employeeViewModel.emailAddress)", systemImage: "envelope.fill")
|
||
.foregroundColor(.gray)
|
||
.font(.caption)
|
||
if let phoneNumber = employeeViewModel.phoneNumber {
|
||
Label("Call: \(phoneNumber)", systemImage: "phone.fill")
|
||
.foregroundColor(.gray)
|
||
.font(.caption)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
|
||
#Preview("EmployeeListItem") {
|
||
EmployeeListItem(employeeViewModel: .init(employee: MockEmployeeService.sample))
|
||
}
|
||
|
||
public struct EmployeeDetailView: View {
|
||
public let viewModel: EmployeeViewModel
|
||
|
||
public 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)
|
||
}
|
||
|
||
Label("Email: \(viewModel.emailAddress)", systemImage: "envelope.fill")
|
||
.foregroundColor(viewModel.emailAddressURL != nil ? .blue : .gray)
|
||
.font(.caption)
|
||
.onTapGesture {
|
||
if let url = viewModel.emailAddressURL {
|
||
UIApplication.shared.open(url)
|
||
}
|
||
}
|
||
|
||
if let phoneNumber = viewModel.phoneNumber {
|
||
Label("Call: \(phoneNumber)", systemImage: "phone.fill")
|
||
.foregroundColor(viewModel.phoneNumberURL != nil ? .blue : .gray)
|
||
.font(.caption)
|
||
.onTapGesture {
|
||
if let url = viewModel.phoneNumberURL {
|
||
UIApplication.shared.open(url)
|
||
}
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
.navigationTitle("Employee Details")
|
||
.padding(.all)
|
||
}
|
||
}
|
||
|
||
public struct ProfileImageView: View {
|
||
public let urlString: String?
|
||
public let size: CGFloat
|
||
|
||
public var body: some View {
|
||
return AsyncImage(url: URL(string: urlString ?? "")) { phase in
|
||
if let image = phase.image {
|
||
image.resizable()
|
||
} else if phase.error != nil || urlString == nil {
|
||
Image(systemName: "person.circle.fill")
|
||
.resizable()
|
||
.foregroundColor(.gray)
|
||
} else {
|
||
ProgressView()
|
||
.scaleEffect(min(size / 50, 2)) // Dynamically adjust based on `size`
|
||
}
|
||
}
|
||
.scaledToFill()
|
||
.frame(width: size, height: size)
|
||
.clipShape(.circle)
|
||
.overlay(Circle().stroke(Color.gray, lineWidth: 1))
|
||
}
|
||
}
|
||
//MARK: - ViewModels
|
||
@Observable
|
||
public class EmployeesViewModel {
|
||
public var employees: [Employee] = []
|
||
public var isLoading: Bool = false
|
||
public var hasNextPage: Bool = true
|
||
|
||
private var currentPage: Int = 1
|
||
private let service: EmployeeDirectoryService
|
||
|
||
public init(service: EmployeeDirectoryService = EmployeeService()) {
|
||
self.service = service
|
||
}
|
||
|
||
public func loadNextPage() async {
|
||
guard !isLoading else { return }
|
||
isLoading = true
|
||
do {
|
||
let response = try await service.fetchEmployees(page: currentPage)
|
||
employees.append(contentsOf: response.result)
|
||
hasNextPage = response.hasNextPage
|
||
currentPage += 1
|
||
} catch {
|
||
print("Error loading employees: \(error)")
|
||
}
|
||
isLoading = false
|
||
}
|
||
|
||
public func refresh() async {
|
||
currentPage = 1
|
||
employees.removeAll()
|
||
await loadNextPage()
|
||
}
|
||
|
||
public func move(employeeFrom source: IndexSet, to destination: Int) {
|
||
employees.move(fromOffsets: source, toOffset: destination)
|
||
}
|
||
|
||
public func delete(employeeAt offsets: IndexSet) {
|
||
employees.remove(atOffsets: offsets)
|
||
}
|
||
|
||
public func delete(employee: Employee) {
|
||
employees.removeAll { $0.id == employee.id }
|
||
}
|
||
|
||
public func delete(selectedEmployees: Set<Employee.ID>) {
|
||
employees.removeAll { selectedEmployees.contains($0.id) }
|
||
}
|
||
}
|
||
|
||
public struct EmployeeViewModel {
|
||
// MARK: - Properties
|
||
|
||
private let employee: Employee
|
||
|
||
public let uuid: String
|
||
public let fullName: String
|
||
public let phoneNumber: String?
|
||
public let emailAddress: String
|
||
public let biography: String?
|
||
public let smallPhoto: String?
|
||
public let largePhoto: String?
|
||
public let emailAddressURL: URL?
|
||
public let phoneNumberURL: URL?
|
||
|
||
// MARK: - Initializer
|
||
public init(employee: Employee) {
|
||
self.employee = employee
|
||
uuid = employee.uuid.uuidString
|
||
fullName = "\(employee.firstName) \(employee.lastName)"
|
||
phoneNumber = employee.phoneNumber
|
||
emailAddress = employee.emailAddress
|
||
biography = employee.biography
|
||
smallPhoto = employee.photoURLSmall
|
||
largePhoto = employee.photoURLLarge
|
||
|
||
if let url = URL(string: "mailto:\(emailAddress)"), UIApplication.shared.canOpenURL(url) {
|
||
emailAddressURL = url
|
||
} else { emailAddressURL = nil }
|
||
|
||
if let phoneNumber, let url = URL(string: "tel:\(phoneNumber)"), UIApplication.shared.canOpenURL(url) {
|
||
phoneNumberURL = url
|
||
} else { phoneNumberURL = nil }
|
||
}
|
||
}
|
||
|
||
//MARK: - Protocols
|
||
public protocol EmployeeDirectoryService {
|
||
func fetchEmployees(page: Int?) async throws -> Employees
|
||
}
|
||
|
||
//MARK: - Services
|
||
public class EmployeeService: EmployeeDirectoryService {
|
||
public init() {}
|
||
|
||
public func fetchEmployees(page: Int? = nil) async throws -> Employees {
|
||
var endpoint = "https://my.api.mockaroo.com/users.json?key=f298b840"
|
||
if let page {
|
||
endpoint += "&page=\(page)"
|
||
}
|
||
return try await NetworkService.shared.fetch(endpoint, type: Employees.self)
|
||
}
|
||
}
|
||
|
||
struct MockEmployeeService: EmployeeDirectoryService {
|
||
static let sample = createUser(page: 1, index: 1)
|
||
|
||
static func createUser(page: Int?, index: Int) -> Employee {
|
||
let id = UUID()
|
||
return Employee(
|
||
uuid: id,
|
||
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://robohash.org/\(id.uuidString).png?size=100x100&set=set1",
|
||
photoURLLarge: "https://robohash.org/\(id.uuidString).png?size=400x400&set=set1"
|
||
)
|
||
}
|
||
func fetchEmployees(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)
|
||
}
|
||
}
|
||
|
||
public class NetworkService {
|
||
public static let shared = NetworkService()
|
||
|
||
public init() {}
|
||
|
||
public func fetch<T: Decodable>(_ endpoint: String, type: T.Type) async throws -> T {
|
||
guard let url = URL(string: endpoint) else {
|
||
throw URLError(.badURL)
|
||
}
|
||
|
||
let (data, response) = try await URLSession.shared.data(from: url)
|
||
|
||
guard let httpResponse = response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode else {
|
||
throw URLError(.badServerResponse)
|
||
}
|
||
|
||
return try JSONDecoder().decode(T.self, from: data)
|
||
}
|
||
}
|
||
|
||
//MARK: - Models
|
||
public struct Employee: Codable, Identifiable, CustomStringConvertible, Hashable {
|
||
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"
|
||
}
|
||
|
||
public var description: String {
|
||
"\(firstName) \(lastName)"
|
||
}
|
||
}
|
||
|
||
public struct Employees: Codable {
|
||
/// Array of Employees
|
||
public var result: [Employee]
|
||
public var hasNextPage: Bool = false
|
||
}
|